From bd10b48d0092ac3f2487160ff67e658015e1dfe1 Mon Sep 17 00:00:00 2001
From: Sergey Usenko
Date: Wed, 28 Aug 2024 16:47:33 +0300
Subject: [PATCH 1/4] added 2.7.12 original
---
src/2.7.12/.eslintignore | 2 +
src/2.7.12/.eslintrc.js | 32 +
src/2.7.12/.gitignore | 9 +
src/2.7.12/.meteor/.finished-upgraders | 19 +
src/2.7.12/.meteor/.gitignore | 2 +
src/2.7.12/.meteor/.id | 7 +
src/2.7.12/.meteor/cordova-plugins | 0
src/2.7.12/.meteor/packages | 25 +
src/2.7.12/.meteor/platforms | 2 +
src/2.7.12/.meteor/release | 1 +
src/2.7.12/.meteor/versions | 78 +
.../client/collection-mirror-initializer.js | 79 +
src/2.7.12/client/legacy.jsx | 15 +
src/2.7.12/client/main.html | 161 +
src/2.7.12/client/main.jsx | 100 +
src/2.7.12/client/stylesheets/bbb-icons.css | 356 +
src/2.7.12/client/stylesheets/fontSizing.css | 19 +
src/2.7.12/client/stylesheets/fonts.css | 216 +
src/2.7.12/client/stylesheets/modals.css | 76 +
src/2.7.12/client/stylesheets/normalize.css | 420 +
src/2.7.12/client/stylesheets/toastify.css | 120 +
.../client/stylesheets/toggleSwitch.css | 123 +
src/2.7.12/deploy_to_usr_share.sh | 106 +
.../imports/api/annotations/addAnnotation.js | 38 +
src/2.7.12/imports/api/annotations/index.js | 23 +
.../api/annotations/server/eventHandlers.js | 11 +
.../server/handlers/whiteboardAnnotations.js | 30 +
.../server/handlers/whiteboardCleared.js | 24 +
.../server/handlers/whiteboardDelete.js | 18 +
.../server/handlers/whiteboardSend.js | 58 +
.../server/handlers/whiteboardUndo.js | 16 +
.../imports/api/annotations/server/index.js | 3 +
.../imports/api/annotations/server/methods.js | 12 +
.../server/methods/clearWhiteboard.js | 27 +
.../server/methods/deleteAnnotations.js | 29 +
.../server/methods/sendAnnotationHelper.js | 34 +
.../server/methods/sendAnnotations.js | 17 +
.../server/methods/sendBulkAnnotations.js | 20 +
.../server/modifiers/addAnnotation.js | 22 +
.../server/modifiers/clearAnnotations.js | 43 +
.../server/modifiers/removeAnnotation.js | 25 +
.../api/annotations/server/publishers.js | 26 +
.../api/annotations/server/streamer.js | 24 +
.../imports/api/audio-captions/index.js | 9 +
.../audio-captions/server/eventHandlers.js | 6 +
.../server/handlers/transcriptUpdated.js | 38 +
.../handlers/transcriptionProviderError.js | 38 +
.../api/audio-captions/server/index.js | 3 +
.../api/audio-captions/server/methods.js | 6 +
.../server/methods/updateTranscript.js | 41 +
.../server/modifiers/clearAudioCaptions.js | 26 +
.../server/modifiers/setTranscript.js | 32 +
.../api/audio-captions/server/publishers.js | 26 +
.../imports/api/audio/client/bridge/base.js | 177 +
.../audio/client/bridge/bridge-whitelist.js | 17 +
.../api/audio/client/bridge/service.js | 134 +
.../audio/client/bridge/sfu-audio-bridge.js | 483 +
.../imports/api/audio/client/bridge/sip.js | 1331 +
.../api/auth-token-validation/index.js | 20 +
.../modifiers/clearAuthTokenValidation.js | 15 +
.../server/modifiers/removeValidationState.js | 14 +
.../server/modifiers/upsertValidationState.js | 36 +
.../imports/api/breakouts-history/index.js | 13 +
.../breakouts-history/server/eventHandlers.js | 6 +
.../server/handlers/breakoutRoomsList.js | 35 +
.../server/handlers/messageToAllSent.js | 42 +
.../api/breakouts-history/server/index.js | 2 +
.../breakouts-history/server/publishers.js | 58 +
src/2.7.12/imports/api/breakouts/index.js | 18 +
.../api/breakouts/server/eventHandlers.js | 14 +
.../server/handlers/breakoutClosed.js | 9 +
.../server/handlers/breakoutJoinURL.js | 47 +
.../breakouts/server/handlers/breakoutList.js | 57 +
.../server/handlers/joinedUsersChanged.js | 53 +
.../server/handlers/updateTimeRemaining.js | 33 +
.../server/handlers/userBreakoutChanged.js | 65 +
.../imports/api/breakouts/server/index.js | 3 +
.../imports/api/breakouts/server/methods.js | 16 +
.../server/methods/createBreakout.js | 39 +
.../server/methods/endAllBreakouts.js | 22 +
.../api/breakouts/server/methods/moveUser.js | 32 +
.../server/methods/requestJoinURL.js | 31 +
.../methods/sendMessageToAllBreakouts.js | 28 +
.../server/methods/setBreakoutsTime.js | 28 +
.../server/modifiers/clearBreakouts.js | 29 +
.../api/breakouts/server/publishers.js | 89 +
src/2.7.12/imports/api/captions/index.js | 13 +
.../api/captions/server/eventHandlers.js | 4 +
.../server/handlers/captionsOwnerUpdated.js | 11 +
.../imports/api/captions/server/helpers.js | 35 +
.../imports/api/captions/server/index.js | 3 +
.../imports/api/captions/server/methods.js | 12 +
.../server/methods/pushSpeechTranscript.js | 36 +
.../captions/server/methods/startDictation.js | 25 +
.../captions/server/methods/stopDictation.js | 25 +
.../server/methods/updateCaptionsOwner.js | 30 +
.../server/modifiers/clearCaptions.js | 26 +
.../server/modifiers/createCaptions.js | 33 +
.../captions/server/modifiers/setDictation.js | 33 +
.../server/modifiers/setTranscript.js | 32 +
.../server/modifiers/updateCaptionsOwner.js | 33 +
.../imports/api/captions/server/publishers.js | 26 +
.../imports/api/common/server/helpers.js | 94 +
.../imports/api/connection-status/index.js | 13 +
.../api/connection-status/server/index.js | 2 +
.../api/connection-status/server/methods.js | 8 +
.../server/methods/addConnectionStatus.js | 56 +
.../server/methods/voidConnection.js | 9 +
.../server/modifiers/clearConnectionStatus.js | 26 +
.../modifiers/updateConnectionStatus.js | 64 +
.../connection-status/server/publishers.js | 62 +
src/2.7.12/imports/api/cursor/index.js | 0
.../api/cursor/server/eventHandlers.js | 4 +
.../cursor/server/handlers/cursorUpdate.js | 45 +
src/2.7.12/imports/api/cursor/server/index.js | 2 +
.../imports/api/cursor/server/methods.js | 6 +
.../server/methods/publishCursorUpdate.js | 10 +
.../imports/api/cursor/server/streamer.js | 37 +
.../imports/api/external-videos/index.js | 11 +
.../external-videos/server/eventHandlers.js | 8 +
.../server/handlers/startExternalVideo.js | 13 +
.../server/handlers/stopExternalVideo.js | 11 +
.../server/handlers/updateExternalVideo.js | 19 +
.../api/external-videos/server/index.js | 2 +
.../api/external-videos/server/methods.js | 10 +
.../server/methods/emitExternalVideoEvent.js | 40 +
.../methods/startWatchingExternalVideo.js | 26 +
.../methods/stopWatchingExternalVideo.js | 25 +
.../server/modifiers/startExternalVideo.js | 19 +
.../server/modifiers/stopExternalVideo.js | 18 +
.../server/modifiers/updateExternalVideo.js | 27 +
.../api/external-videos/server/streamer.js | 49 +
.../imports/api/group-chat-msg/index.js | 20 +
.../group-chat-msg/server/eventHandlers.js | 12 +
.../server/handlers/clearPublicGroupChat.js | 13 +
.../server/handlers/groupChatMsgBroadcast.js | 25 +
.../server/handlers/syncGroupsChat.js | 12 +
.../server/handlers/userTyping.js | 12 +
.../api/group-chat-msg/server/index.js | 3 +
.../api/group-chat-msg/server/methods.js | 16 +
.../methods/chatMessageBeforeJoinCounter.js | 45 +
.../server/methods/clearPublicChatHistory.js | 28 +
.../server/methods/fetchMessagePerPage.js | 37 +
.../server/methods/sendGroupChatMsg.js | 32 +
.../server/methods/startUserTyping.js | 29 +
.../server/methods/stopUserTyping.js | 25 +
.../server/modifiers/addBulkGroupChatMsgs.js | 47 +
.../server/modifiers/addGroupChatMsg.js | 61 +
.../server/modifiers/addSystemMsg.js | 48 +
.../server/modifiers/clearGroupChatMsg.js | 74 +
.../server/modifiers/removeGroupChatMsg.js | 23 +
.../server/modifiers/startTyping.js | 51 +
.../server/modifiers/stopTyping.js | 28 +
.../server/modifiers/syncMeetingChatMsgs.js | 49 +
.../api/group-chat-msg/server/publishers.js | 71 +
src/2.7.12/imports/api/group-chat/index.js | 23 +
.../api/group-chat/server/eventHandlers.js | 10 +
.../server/handlers/groupChatCreated.js | 9 +
.../server/handlers/groupChatDestroyed.js | 9 +
.../group-chat/server/handlers/groupChats.js | 14 +
.../imports/api/group-chat/server/index.js | 4 +
.../imports/api/group-chat/server/methods.js | 8 +
.../server/methods/createGroupChat.js | 31 +
.../server/methods/destroyGroupChat.js | 28 +
.../server/modifiers/addGroupChat.js | 47 +
.../server/modifiers/clearGroupChat.js | 16 +
.../server/modifiers/removeGroupChat.js | 25 +
.../api/group-chat/server/publishers.js | 37 +
src/2.7.12/imports/api/guest-users/index.js | 9 +
.../api/guest-users/server/eventHandlers.js | 11 +
.../server/handlers/guestApproved.js | 15 +
.../server/handlers/guestWaitingLeft.js | 11 +
.../handlers/guestsWaitingForApproval.js | 42 +
.../privateGuestLobbyMessageChanged.js | 13 +
.../imports/api/guest-users/server/index.js | 3 +
.../imports/api/guest-users/server/methods.js | 12 +
.../server/methods/allowPendingUsers.js | 31 +
.../server/methods/changeGuestPolicy.js | 30 +
.../server/methods/setGuestLobbyMessage.js | 27 +
.../methods/setPrivateGuestLobbyMessage.js | 27 +
.../methods/updatePositionInWaitingQueue.js | 34 +
.../server/modifiers/clearGuestUsers.js | 26 +
.../server/modifiers/removeGuest.js | 28 +
.../server/modifiers/setGuestStatus.js | 39 +
.../modifiers/setPrivateGuestLobbyMessage.js | 32 +
.../api/guest-users/server/publishers.js | 54 +
.../imports/api/local-settings/index.js | 11 +
.../api/local-settings/server/index.js | 2 +
.../api/local-settings/server/methods.js | 6 +
.../methods/userChangedLocalSettings.js | 30 +
.../server/modifiers/clearLocalSettings.js | 14 +
.../modifiers/setChangedLocalSettings.js | 30 +
.../api/local-settings/server/publishers.js | 27 +
.../imports/api/log-client/server/index.js | 1 +
.../imports/api/log-client/server/methods.js | 4 +
.../log-client/server/methods/logClient.js | 15 +
src/2.7.12/imports/api/meetings/index.js | 32 +
.../api/meetings/notificationEmitter.js | 3 +
.../api/meetings/server/eventHandlers.js | 41 +
.../server/handlers/broadcastLayout.js | 27 +
.../server/handlers/broadcastPushLayout.js | 8 +
.../server/handlers/getAllMeetings.js | 6 +
.../handlers/guestLobbyMessageChanged.js | 11 +
.../server/handlers/guestPolicyChanged.js | 12 +
.../handleNotifyAllInMeetingEvtMsg.js | 14 +
.../handlers/handleNotifyRoleInMeeting.js | 15 +
.../handlers/handleNotifyUserInMeeting.js | 15 +
.../handlers/handleUserChatLockChange.js | 5 +
.../server/handlers/meetingCreation.js | 11 +
.../server/handlers/meetingDestruction.js | 20 +
.../meetings/server/handlers/meetingEnd.js | 56 +
.../server/handlers/meetingLockChange.js | 5 +
.../server/handlers/recordingStatusChange.js | 26 +
.../server/handlers/recordingTimerChange.js | 27 +
.../server/handlers/selectRandomViewer.js | 14 +
.../server/handlers/timeRemainingUpdate.js | 60 +
.../server/handlers/userLockChange.js | 5 +
.../server/handlers/webcamOnlyModerator.js | 5 +
.../imports/api/meetings/server/index.js | 3 +
.../imports/api/meetings/server/methods.js | 20 +
.../meetings/server/methods/changeLayout.js | 54 +
.../methods/clearRandomlySelectedUser.js | 30 +
.../api/meetings/server/methods/endMeeting.js | 27 +
.../meetings/server/methods/setPushLayout.js | 25 +
.../server/methods/toggleLockSettings.js | 62 +
.../server/methods/toggleRecording.js | 55 +
.../methods/toggleWebcamsOnlyForModerator.js | 28 +
.../meetings/server/methods/transferUser.js | 30 +
.../meetings/server/modifiers/addMeeting.js | 286 +
.../meetings/server/modifiers/changeLayout.js | 48 +
.../server/modifiers/changeLockSettings.js | 67 +
.../server/modifiers/changeUserChatLock.js | 37 +
.../server/modifiers/changeUserLock.js | 37 +
.../modifiers/clearExternalVideoMeeting.js | 26 +
.../modifiers/clearMeetingTimeRemaining.js | 26 +
.../server/modifiers/clearRecordMeeting.js | 14 +
.../server/modifiers/emitNotification.js | 10 +
.../server/modifiers/meetingHasEnded.js | 77 +
.../server/modifiers/setGuestLobbyMessage.js | 28 +
.../server/modifiers/setGuestPolicy.js | 28 +
.../server/modifiers/setPublishedPoll.js | 28 +
.../server/modifiers/setPushLayout.js | 29 +
.../server/modifiers/updateRandomViewer.js | 143 +
.../server/modifiers/webcamOnlyModerator.js | 34 +
.../imports/api/meetings/server/publishers.js | 193 +
src/2.7.12/imports/api/pads/index.js | 22 +
.../imports/api/pads/server/eventHandlers.js | 20 +
.../server/handlers/captureSharedNotes.js | 22 +
.../api/pads/server/handlers/groupCreated.js | 16 +
.../api/pads/server/handlers/padContent.js | 16 +
.../api/pads/server/handlers/padCreated.js | 11 +
.../api/pads/server/handlers/padPinned.js | 14 +
.../api/pads/server/handlers/padTail.js | 14 +
.../api/pads/server/handlers/padUpdated.js | 17 +
.../pads/server/handlers/sessionCreated.js | 15 +
.../pads/server/handlers/sessionDeleted.js | 15 +
src/2.7.12/imports/api/pads/server/helpers.js | 51 +
src/2.7.12/imports/api/pads/server/index.js | 3 +
src/2.7.12/imports/api/pads/server/methods.js | 12 +
.../api/pads/server/methods/createGroup.js | 31 +
.../api/pads/server/methods/createPad.js | 26 +
.../api/pads/server/methods/createSession.js | 27 +
.../api/pads/server/methods/getPadId.js | 32 +
.../api/pads/server/methods/padCapture.js | 64 +
.../imports/api/pads/server/methods/pinPad.js | 29 +
.../api/pads/server/methods/updatePad.js | 27 +
.../server/methods/updateTranscriptPad.js | 29 +
.../api/pads/server/modifiers/clearPads.js | 30 +
.../api/pads/server/modifiers/contentPad.js | 33 +
.../api/pads/server/modifiers/createGroup.js | 39 +
.../api/pads/server/modifiers/createPad.js | 33 +
.../pads/server/modifiers/createSession.js | 35 +
.../pads/server/modifiers/deleteSession.js | 30 +
.../api/pads/server/modifiers/pinPad.js | 35 +
.../api/pads/server/modifiers/tailPad.js | 27 +
.../api/pads/server/modifiers/updatePad.js | 35 +
.../imports/api/pads/server/publishers.js | 79 +
src/2.7.12/imports/api/polls/index.js | 17 +
src/2.7.12/imports/api/polls/index.test.js | 103 +
.../imports/api/polls/server/eventHandlers.js | 14 +
.../polls/server/handlers/pollPublished.js | 18 +
.../api/polls/server/handlers/pollStarted.js | 22 +
.../api/polls/server/handlers/pollStopped.js | 23 +
.../polls/server/handlers/sendPollChatMsg.js | 37 +
.../polls/server/handlers/userResponded.js | 34 +
.../server/handlers/userTypedResponse.js | 34 +
.../api/polls/server/handlers/userVoted.js | 26 +
src/2.7.12/imports/api/polls/server/index.js | 3 +
.../imports/api/polls/server/methods.js | 14 +
.../api/polls/server/methods/publishPoll.js | 37 +
.../polls/server/methods/publishTypedVote.js | 79 +
.../api/polls/server/methods/publishVote.js | 71 +
.../api/polls/server/methods/startPoll.js | 39 +
.../api/polls/server/methods/stopPoll.js | 21 +
.../api/polls/server/modifiers/addPoll.js | 56 +
.../api/polls/server/modifiers/clearPolls.js | 26 +
.../api/polls/server/modifiers/removePoll.js | 23 +
.../api/polls/server/modifiers/updateVotes.js | 41 +
.../imports/api/polls/server/publishers.js | 146 +
.../imports/api/presentation-pods/index.js | 16 +
.../presentation-pods/server/eventHandlers.js | 10 +
.../handlers/createNewPresentationPod.js | 19 +
.../server/handlers/removePresentationPod.js | 13 +
.../server/handlers/setPresenterInPod.js | 13 +
.../handlers/syncGetPresentationPods.js | 32 +
.../api/presentation-pods/server/index.js | 3 +
.../api/presentation-pods/server/methods.js | 0
.../server/modifiers/addPresentationPod.js | 46 +
.../server/modifiers/clearPresentationPods.js | 32 +
.../server/modifiers/removePresentationPod.js | 27 +
.../server/modifiers/setPresenterInPod.js | 30 +
.../presentation-pods/server/publishers.js | 26 +
.../api/presentation-upload-token/index.js | 7 +
.../server/eventHandlers.js | 8 +
.../handlers/presentationUploadTokenFail.js | 32 +
.../handlers/presentationUploadTokenPass.js | 45 +
.../presentation-upload-token/server/index.js | 3 +
.../server/methods.js | 8 +
.../methods/requestPresentationUploadToken.js | 34 +
.../server/methods/setUsedToken.js | 32 +
.../modifiers/clearPresentationUploadToken.js | 44 +
.../server/publishers.js | 40 +
src/2.7.12/imports/api/presentations/index.js | 20 +
.../api/presentations/server/eventHandlers.js | 22 +
.../server/handlers/presentationAdded.js | 14 +
.../handlers/presentationConversionUpdate.js | 137 +
.../server/handlers/presentationCurrentSet.js | 15 +
.../handlers/presentationDownloadableSet.js | 20 +
.../server/handlers/presentationExport.js | 42 +
.../handlers/presentationExportToastUpdate.js | 21 +
.../server/handlers/presentationRemove.js | 14 +
.../sendExportedPresentationChatMsg.js | 37 +
.../imports/api/presentations/server/index.js | 3 +
.../api/presentations/server/methods.js | 14 +
.../server/methods/exportPresentation.js | 61 +
.../server/methods/removePresentation.js | 28 +
.../server/methods/setPresentation.js | 28 +
.../methods/setPresentationDownloadable.js | 31 +
.../methods/setPresentationRenderedInToast.js | 28 +
.../server/modifiers/addPresentation.js | 88 +
.../server/modifiers/clearPresentations.js | 44 +
.../server/modifiers/removePresentation.js | 28 +
.../modifiers/setCurrentPresentation.js | 70 +
.../modifiers/setOriginalUriDownload.js | 30 +
.../modifiers/setPresentationDownloadable.js | 39 +
.../modifiers/setPresentationExporting.js | 30 +
.../api/presentations/server/publishers.js | 27 +
.../api/screenshare/client/bridge/errors.js | 59 +
.../api/screenshare/client/bridge/index.js | 5 +
.../api/screenshare/client/bridge/kurento.js | 405 +
.../api/screenshare/client/bridge/service.js | 164 +
src/2.7.12/imports/api/screenshare/index.js | 16 +
.../api/screenshare/server/eventHandlers.js | 8 +
.../server/handlers/screenshareStarted.js | 9 +
.../server/handlers/screenshareStopped.js | 11 +
.../server/handlers/screenshareSync.js | 20 +
.../imports/api/screenshare/server/index.js | 3 +
.../imports/api/screenshare/server/methods.js | 4 +
.../server/modifiers/addScreenshare.js | 29 +
.../server/modifiers/clearScreenshare.js | 22 +
.../api/screenshare/server/publishers.js | 27 +
src/2.7.12/imports/api/slides/index.js | 29 +
.../api/slides/server/eventHandlers.js | 6 +
.../api/slides/server/handlers/slideChange.js | 13 +
.../api/slides/server/handlers/slideResize.js | 10 +
.../imports/api/slides/server/helpers.js | 27 +
src/2.7.12/imports/api/slides/server/index.js | 3 +
.../imports/api/slides/server/methods.js | 8 +
.../api/slides/server/methods/switchSlide.js | 56 +
.../api/slides/server/methods/zoomSlide.js | 65 +
.../api/slides/server/modifiers/addSlide.js | 130 +
.../server/modifiers/addSlidePositions.js | 56 +
.../server/modifiers/changeCurrentSlide.js | 66 +
.../slides/server/modifiers/clearSlides.js | 38 +
.../modifiers/clearSlidesPresentation.js | 30 +
.../slides/server/modifiers/resizeSlide.js | 63 +
.../imports/api/slides/server/publishers.js | 50 +
src/2.7.12/imports/api/timer/index.js | 9 +
.../imports/api/timer/server/eventHandlers.js | 20 +
.../timer/server/handlers/timerActivated.js | 14 +
.../timer/server/handlers/timerDeactivated.js | 14 +
.../api/timer/server/handlers/timerEnded.js | 13 +
.../api/timer/server/handlers/timerReset.js | 14 +
.../api/timer/server/handlers/timerSet.js | 17 +
.../api/timer/server/handlers/timerStarted.js | 14 +
.../api/timer/server/handlers/timerStopped.js | 17 +
.../timer/server/handlers/timerSwitched.js | 17 +
.../api/timer/server/handlers/trackSet.js | 18 +
.../imports/api/timer/server/helpers.js | 40 +
src/2.7.12/imports/api/timer/server/index.js | 3 +
.../imports/api/timer/server/methods.js | 24 +
.../api/timer/server/methods/activateTimer.js | 23 +
.../api/timer/server/methods/createTimer.js | 29 +
.../timer/server/methods/deactivateTimer.js | 20 +
.../api/timer/server/methods/endTimer.js | 33 +
.../api/timer/server/methods/getServerTime.js | 5 +
.../api/timer/server/methods/resetTimer.js | 20 +
.../api/timer/server/methods/setTimer.js | 25 +
.../api/timer/server/methods/setTrack.js | 30 +
.../api/timer/server/methods/startTimer.js | 20 +
.../api/timer/server/methods/stopTimer.js | 92 +
.../api/timer/server/methods/switchTimer.js | 25 +
.../api/timer/server/modifiers/addTimer.js | 34 +
.../api/timer/server/modifiers/clearTimer.js | 14 +
.../api/timer/server/modifiers/endTimer.js | 18 +
.../api/timer/server/modifiers/stopTimer.js | 46 +
.../api/timer/server/modifiers/updateTimer.js | 188 +
.../imports/api/timer/server/publishers.js | 22 +
src/2.7.12/imports/api/user-reaction/index.js | 14 +
.../api/user-reaction/server/eventHandlers.js | 6 +
.../server/handlers/clearUsersReaction.js | 7 +
.../server/handlers/setUserReaction.js | 7 +
.../api/user-reaction/server/helpers.js | 44 +
.../imports/api/user-reaction/server/index.js | 3 +
.../api/user-reaction/server/methods.js | 8 +
.../server/methods/clearAllUsersReaction.js | 30 +
.../server/methods/setUserReaction.js | 36 +
.../server/modifiers/addUserReaction.js | 34 +
.../server/modifiers/clearReactions.js | 26 +
.../api/user-reaction/server/publishers.js | 27 +
src/2.7.12/imports/api/users-infos/index.js | 13 +
.../api/users-infos/server/eventHandlers.js | 4 +
.../server/handlers/userInformation.js | 17 +
.../imports/api/users-infos/server/index.js | 3 +
.../imports/api/users-infos/server/methods.js | 8 +
.../server/methods/removeUserInformation.js | 26 +
.../server/methods/requestUserInformation.js | 27 +
.../server/modifiers/addUserInfo.js | 20 +
.../server/modifiers/clearUserInfo.js | 14 +
.../modifiers/clearUserInfoForRequester.js | 14 +
.../api/users-infos/server/publishers.js | 27 +
.../api/users-persistent-data/index.js | 13 +
.../api/users-persistent-data/server/index.js | 1 +
.../server/modifiers/addUserPersistentData.js | 87 +
.../modifiers/changeHasConnectionStatus.js | 25 +
.../server/modifiers/changeHasMessages.js | 30 +
.../server/modifiers/clearChatHasMessages.js | 30 +
.../modifiers/clearUsersPersistentData.js | 26 +
.../server/modifiers/setloggedOutStatus.js | 26 +
.../server/modifiers/updateRole.js | 26 +
.../modifiers/updateUserBreakoutRoom.js | 38 +
.../server/publishers.js | 54 +
.../imports/api/users-settings/index.js | 15 +
.../api/users-settings/server/index.js | 2 +
.../api/users-settings/server/methods.js | 6 +
.../server/methods/addUserSettings.js | 137 +
.../server/modifiers/addUserSetting.js | 34 +
.../server/modifiers/clearUsersSettings.js | 14 +
.../api/users-settings/server/publishers.js | 27 +
src/2.7.12/imports/api/users/index.js | 18 +
.../imports/api/users/server/eventHandlers.js | 30 +
.../api/users/server/handlers/changeAway.js | 11 +
.../users/server/handlers/changeMobileFlag.js | 13 +
.../users/server/handlers/changeRaiseHand.js | 11 +
.../api/users/server/handlers/changeRole.js | 11 +
.../users/server/handlers/clearUsersEmoji.js | 7 +
.../api/users/server/handlers/emojiStatus.js | 32 +
.../server/handlers/presenterAssigned.js | 64 +
.../users/server/handlers/reactionEmoji.js | 32 +
.../api/users/server/handlers/removeUser.js | 13 +
.../server/handlers/userInactivityInspect.js | 13 +
.../api/users/server/handlers/userJoin.js | 22 +
.../api/users/server/handlers/userJoined.js | 11 +
.../server/handlers/userLeftFlagUpdated.js | 13 +
.../users/server/handlers/userPinChanged.js | 14 +
.../handlers/userSpeechLocaleChanged.js | 13 +
.../server/handlers/validateAuthToken.js | 157 +
src/2.7.12/imports/api/users/server/index.js | 3 +
.../imports/api/users/server/methods.js | 42 +
.../users/server/methods/assignPresenter.js | 41 +
.../api/users/server/methods/changeAway.js | 31 +
.../api/users/server/methods/changePin.js | 33 +
.../users/server/methods/changeRaiseHand.js | 31 +
.../api/users/server/methods/changeRole.js | 34 +
.../server/methods/clearAllUsersEmoji.js | 30 +
.../api/users/server/methods/removeUser.js | 30 +
.../server/methods/sendAwayStatusChatMsg.js | 72 +
.../users/server/methods/setEmojiStatus.js | 33 +
.../api/users/server/methods/setExitReason.js | 21 +
.../api/users/server/methods/setMobileUser.js | 28 +
.../api/users/server/methods/setRandomUser.js | 26 +
.../users/server/methods/setSpeechLocale.js | 32 +
.../users/server/methods/setSpeechOptions.js | 30 +
.../methods/setUserEffectiveConnectionType.js | 33 +
.../server/methods/toggleUserChatLock.js | 34 +
.../users/server/methods/toggleUserLock.js | 34 +
.../users/server/methods/userActivitySign.js | 40 +
.../api/users/server/methods/userLeaving.js | 66 +
.../users/server/methods/userLeftMeeting.js | 32 +
.../users/server/methods/validateAuthToken.js | 78 +
.../users/server/modifiers/addDialInUser.js | 36 +
.../api/users/server/modifiers/addUser.js | 112 +
.../api/users/server/modifiers/changeAway.js | 30 +
.../api/users/server/modifiers/changePin.js | 25 +
.../users/server/modifiers/changePresenter.js | 26 +
.../users/server/modifiers/changeRaiseHand.js | 26 +
.../api/users/server/modifiers/changeRole.js | 28 +
.../api/users/server/modifiers/clearUsers.js | 26 +
.../users/server/modifiers/clearUsersEmoji.js | 35 +
.../users/server/modifiers/createDummyUser.js | 34 +
.../api/users/server/modifiers/removeUser.js | 83 +
.../modifiers/setConnectionIdAndAuthToken.js | 37 +
.../api/users/server/modifiers/setMobile.js | 25 +
.../setUserEffectiveConnectionType.js | 35 +
.../server/modifiers/setUserExitReason.js | 25 +
.../server/modifiers/updateSpeechLocale.js | 25 +
.../modifiers/updateUserConnectionId.js | 32 +
.../api/users/server/modifiers/userEjected.js | 33 +
.../server/modifiers/userInactivityInspect.js | 30 +
.../server/modifiers/userLeftFlagUpdated.js | 24 +
.../imports/api/users/server/publishers.js | 104 +
.../server/store/pendingAuthentications.js | 47 +
src/2.7.12/imports/api/video-streams/index.js | 16 +
.../api/video-streams/server/eventHandlers.js | 12 +
.../server/handlers/floorChanged.js | 12 +
.../server/handlers/userPinChanged.js | 13 +
.../server/handlers/userSharedHtml5Webcam.js | 16 +
.../handlers/userUnsharedHtml5Webcam.js | 16 +
.../server/handlers/webcamSync.js | 51 +
.../api/video-streams/server/helpers.js | 28 +
.../imports/api/video-streams/server/index.js | 3 +
.../api/video-streams/server/methods.js | 10 +
.../server/methods/ejectUserCameras.js | 28 +
.../server/methods/userShareWebcam.js | 34 +
.../server/methods/userUnshareWebcam.js | 34 +
.../server/modifiers/changePin.js | 26 +
.../server/modifiers/clearVideoStreams.js | 26 +
.../server/modifiers/floorChanged.js | 37 +
.../server/modifiers/sharedWebcam.js | 103 +
.../server/modifiers/unsharedWebcam.js | 26 +
.../server/modifiers/updatedVideoStream.js | 40 +
.../api/video-streams/server/publisher.js | 31 +
.../imports/api/voice-call-states/index.js | 13 +
.../voice-call-states/server/eventHandlers.js | 4 +
.../server/handlers/voiceCallStateEvent.js | 50 +
.../api/voice-call-states/server/index.js | 2 +
.../server/modifiers/clearVoiceCallStates.js | 26 +
.../voice-call-states/server/publishers.js | 27 +
.../api/voice-call-states/utils/callStates.js | 8 +
src/2.7.12/imports/api/voice-users/index.js | 18 +
.../api/voice-users/server/eventHandlers.js | 19 +
.../server/handlers/floorChanged.js | 10 +
.../server/handlers/getVoiceUsers.js | 64 +
.../server/handlers/joinVoiceUser.js | 41 +
.../server/handlers/leftVoiceUser.js | 32 +
.../server/handlers/meetingMuted.js | 5 +
.../server/handlers/mutedVoiceUser.js | 17 +
.../server/handlers/talkingVoiceUser.js | 12 +
.../voice-users/server/handlers/voiceUsers.js | 66 +
.../imports/api/voice-users/server/index.js | 3 +
.../imports/api/voice-users/server/methods.js | 12 +
.../server/methods/ejectUserFromVoice.js | 30 +
.../methods/muteAllExceptPresenterToggle.js | 31 +
.../server/methods/muteAllToggle.js | 31 +
.../voice-users/server/methods/muteToggle.js | 66 +
.../server/modifiers/addVoiceUser.js | 46 +
.../server/modifiers/changeMuteMeeting.js | 31 +
.../server/modifiers/clearVoiceUser.js | 20 +
.../server/modifiers/clearVoiceUsers.js | 26 +
.../server/modifiers/removeVoiceUser.js | 41 +
.../server/modifiers/updateVoiceUser.js | 88 +
.../api/voice-users/server/publishers.js | 26 +
.../api/whiteboard-multi-user/index.js | 16 +
.../server/eventHandlers.js | 7 +
.../server/handlers/modifyWhiteboardAccess.js | 12 +
.../whiteboard-multi-user/server/helpers.js | 31 +
.../api/whiteboard-multi-user/server/index.js | 3 +
.../whiteboard-multi-user/server/methods.js | 12 +
.../server/methods/addGlobalAccess.js | 32 +
.../server/methods/addIndividualAccess.js | 37 +
.../server/methods/removeGlobalAccess.js | 29 +
.../server/methods/removeIndividualAccess.js | 35 +
.../modifiers/clearWhiteboardMultiUser.js | 26 +
.../modifiers/modifyWhiteboardAccess.js | 31 +
.../server/publishers.js | 27 +
src/2.7.12/imports/startup/client/base.jsx | 438 +
src/2.7.12/imports/startup/client/intl.jsx | 198 +
src/2.7.12/imports/startup/client/logger.js | 126 +
.../startup/server/ClientConnections.js | 180 +
src/2.7.12/imports/startup/server/index.js | 360 +
src/2.7.12/imports/startup/server/logger.js | 45 +
src/2.7.12/imports/startup/server/metrics.js | 146 +
.../startup/server/minBrowserVersion.js | 19 +
.../startup/server/prom-metrics/index.js | 35 +
.../startup/server/prom-metrics/metrics.js | 68 +
.../startup/server/prom-metrics/promAgent.js | 96 +
.../prom-metrics/winstonPromTransport.js | 19 +
src/2.7.12/imports/startup/server/redis.js | 416 +
src/2.7.12/imports/startup/server/settings.js | 35 +
.../imports/ui/components/about/component.jsx | 80 +
.../imports/ui/components/about/container.jsx | 22 +
.../actions-dropdown/component.jsx | 490 +
.../actions-dropdown/container.jsx | 49 +
.../actions-bar/actions-dropdown/styles.js | 15 +
.../ui/components/actions-bar/component.jsx | 176 +
.../ui/components/actions-bar/container.jsx | 85 +
.../create-breakout-room/component.jsx | 1481 ++
.../create-breakout-room/container.jsx | 51 +
.../sort-user-list/component.jsx | 151 +
.../sort-user-list/styles.js | 133 +
.../create-breakout-room/styles.js | 390 +
.../presentation-options/component.jsx | 84 +
.../quick-poll-dropdown/component.jsx | 269 +
.../quick-poll-dropdown/container.jsx | 18 +
.../actions-bar/quick-poll-dropdown/styles.js | 26 +
.../actions-bar/raise-hand/component.jsx | 140 +
.../actions-bar/raise-hand/container.jsx | 13 +
.../actions-bar/raise-hand/styles.js | 20 +
.../reactions-button/component.jsx | 181 +
.../reactions-button/container.jsx | 39 +
.../actions-bar/reactions-button/styles.js | 88 +
.../actions-bar/screenshare/component.jsx | 213 +
.../actions-bar/screenshare/container.jsx | 24 +
.../actions-bar/screenshare/styles.js | 29 +
.../ui/components/actions-bar/service.js | 86 +
.../ui/components/actions-bar/styles.js | 132 +
.../components/activity-check/component.jsx | 122 +
.../components/activity-check/container.jsx | 7 +
.../ui/components/activity-check/styles.js | 23 +
.../imports/ui/components/app/component.jsx | 685 +
.../imports/ui/components/app/container.jsx | 335 +
.../imports/ui/components/app/service.js | 52 +
.../imports/ui/components/app/styles.js | 98 +
.../audio/audio-controls/component.jsx | 179 +
.../audio/audio-controls/container.jsx | 97 +
.../input-stream-live-selector/component.jsx | 509 +
.../input-stream-live-selector/container.jsx | 23 +
.../input-stream-live-selector/styles.js | 83 +
.../components/audio/audio-controls/styles.js | 102 +
.../components/audio/audio-dial/component.jsx | 65 +
.../ui/components/audio/audio-dial/styles.js | 55 +
.../audio/audio-modal/component.jsx | 672 +
.../audio/audio-modal/container.jsx | 105 +
.../components/audio/audio-modal/service.js | 91 +
.../ui/components/audio/audio-modal/styles.js | 150 +
.../audio/audio-settings/component.jsx | 377 +
.../components/audio/audio-settings/styles.js | 178 +
.../audio/audio-stream-volume/component.jsx | 98 +
.../audio/audio-stream-volume/styles.js | 12 +
.../components/audio/audio-test/component.jsx | 57 +
.../components/audio/audio-test/container.jsx | 16 +
.../ui/components/audio/audio-test/styles.js | 34 +
.../components/audio/autoplay/component.jsx | 48 +
.../ui/components/audio/autoplay/styles.js | 24 +
.../audio/captions/button/component.jsx | 267 +
.../audio/captions/button/container.jsx | 25 +
.../audio/captions/button/styles.js | 61 +
.../audio/captions/history/component.jsx | 33 +
.../audio/captions/history/container.jsx | 18 +
.../audio/captions/live/component.jsx | 107 +
.../audio/captions/live/container.jsx | 10 +
.../audio/captions/live/user/component.jsx | 39 +
.../audio/captions/live/user/container.jsx | 44 +
.../audio/captions/select/component.jsx | 146 +
.../audio/captions/select/container.jsx | 12 +
.../ui/components/audio/captions/service.js | 73 +
.../audio/captions/speech/component.jsx | 151 +
.../audio/captions/speech/container.jsx | 20 +
.../audio/captions/speech/service.js | 194 +
.../imports/ui/components/audio/container.jsx | 278 +
.../audio/device-selector/component.jsx | 171 +
.../audio/device-selector/styles.js | 37 +
.../components/audio/echo-test/component.jsx | 89 +
.../ui/components/audio/echo-test/styles.js | 41 +
.../ui/components/audio/help/component.jsx | 192 +
.../ui/components/audio/help/styles.js | 79 +
.../components/audio/local-echo/component.jsx | 87 +
.../components/audio/local-echo/container.jsx | 10 +
.../ui/components/audio/local-echo/service.js | 121 +
.../ui/components/audio/local-echo/styles.js | 34 +
.../audio/permissions-overlay/component.jsx | 48 +
.../audio/permissions-overlay/styles.js | 108 +
.../imports/ui/components/audio/service.js | 174 +
.../authenticated-handler/component.jsx | 100 +
.../ui/components/banner-bar/component.jsx | 37 +
.../ui/components/banner-bar/container.jsx | 18 +
.../ui/components/banner-bar/styles.js | 11 +
.../breakout-join-confirmation/component.jsx | 236 +
.../breakout-join-confirmation/container.jsx | 50 +
.../breakout-join-confirmation/styles.js | 20 +
.../breakout-dropdown/component.jsx | 137 +
.../ui/components/breakout-room/component.jsx | 602 +
.../ui/components/breakout-room/container.jsx | 101 +
.../breakout-room/invitation/component.jsx | 138 +
.../breakout-room/invitation/container.jsx | 20 +
.../breakout-room/message-form/component.jsx | 255 +
.../breakout-room/message-form/container.jsx | 23 +
.../breakout-room/message-form/styles.js | 103 +
.../ui/components/breakout-room/service.js | 246 +
.../ui/components/breakout-room/styles.js | 271 +
.../components/captions/button/component.jsx | 41 +
.../components/captions/button/container.jsx | 13 +
.../ui/components/captions/button/styles.js | 17 +
.../ui/components/captions/component.jsx | 136 +
.../ui/components/captions/container.jsx | 39 +
.../ui/components/captions/live/component.jsx | 89 +
.../ui/components/captions/live/container.jsx | 10 +
.../reader-menu/color-picker/component.jsx | 25 +
.../reader-menu/color-picker/styles.js | 38 +
.../captions/reader-menu/component.jsx | 416 +
.../captions/reader-menu/container.jsx | 13 +
.../components/captions/reader-menu/styles.js | 133 +
.../imports/ui/components/captions/service.js | 261 +
.../components/captions/speech/component.jsx | 143 +
.../components/captions/speech/container.jsx | 18 +
.../ui/components/captions/speech/service.js | 43 +
.../imports/ui/components/captions/styles.js | 27 +
.../captions/writer-menu/component.jsx | 161 +
.../captions/writer-menu/container.jsx | 25 +
.../components/captions/writer-menu/styles.js | 81 +
.../ui/components/chat/alert/component.jsx | 269 +
.../ui/components/chat/alert/container.jsx | 70 +
.../chat/alert/push-alert/component.jsx | 83 +
.../ui/components/chat/alert/styles.js | 55 +
.../chat/chat-dropdown/component.jsx | 161 +
.../chat/chat-dropdown/container.jsx | 24 +
.../components/chat/chat-logger/ChatLogger.js | 37 +
.../imports/ui/components/chat/component.jsx | 181 +
.../imports/ui/components/chat/container.jsx | 271 +
.../chat/message-form/component.jsx | 392 +
.../chat/message-form/container.jsx | 40 +
.../ui/components/chat/message-form/styles.js | 143 +
.../typing-indicator/component.jsx | 139 +
.../typing-indicator/container.jsx | 54 +
.../message-form/typing-indicator/styles.js | 73 +
.../imports/ui/components/chat/service.js | 388 +
.../imports/ui/components/chat/styles.js | 61 +
.../chat/time-window-list/component.jsx | 386 +
.../chat/time-window-list/container.jsx | 27 +
.../chat/time-window-list/styles.js | 142 +
.../time-window-chat-item/component.jsx | 525 +
.../time-window-chat-item/container.jsx | 69 +
.../message-chat-item/component.jsx | 189 +
.../time-window-chat-item/styles.js | 285 +
.../ui/components/click-outside/component.jsx | 39 +
.../common/button/base/component.jsx | 197 +
.../button/button-emoji/ButtonEmoji.jsx | 85 +
.../common/button/button-emoji/styles.js | 91 +
.../ui/components/common/button/component.jsx | 257 +
.../ui/components/common/button/styles.js | 1223 +
.../ui/components/common/checkbox/base.jsx | 66 +
.../components/common/checkbox/component.jsx | 38 +
.../ui/components/common/checkbox/styles.js | 25 +
.../common/control-header/component.jsx | 43 +
.../common/control-header/left/component.jsx | 30 +
.../common/control-header/left/styles.js | 31 +
.../common/control-header/right/component.jsx | 32 +
.../common/control-header/right/styles.js | 18 +
.../common/control-header/styles.js | 20 +
.../common/error-boundary/component.jsx | 43 +
.../fallback-modal/component.jsx | 26 +
.../fallback-view/component.jsx | 62 +
.../fallback-errors/fallback-view/styles.js | 63 +
.../common/file-reader/component.jsx | 179 +
.../components/common/file-reader/styles.js | 93 +
.../common/fullscreen-button/component.jsx | 110 +
.../common/fullscreen-button/container.jsx | 31 +
.../common/fullscreen-button/service.js | 56 +
.../common/fullscreen-button/styles.js | 82 +
.../ui/components/common/icon/component.jsx | 36 +
.../ui/components/common/icon/styles.js | 12 +
.../common/loading-screen/component.jsx | 20 +
.../common/loading-screen/styles.js | 72 +
.../common/locales-dropdown/component.jsx | 86 +
.../ui/components/common/menu/component.jsx | 269 +
.../ui/components/common/menu/styles.js | 129 +
.../common/modal/base/component.jsx | 55 +
.../ui/components/common/modal/base/styles.js | 25 +
.../common/modal/confirmation/component.jsx | 119 +
.../common/modal/confirmation/styles.js | 81 +
.../common/modal/fullscreen/component.jsx | 149 +
.../common/modal/fullscreen/styles.js | 93 +
.../common/modal/header/component.jsx | 81 +
.../components/common/modal/header/styles.js | 94 +
.../common/modal/random-user/component.jsx | 216 +
.../common/modal/random-user/container.jsx | 106 +
.../common/modal/random-user/styles.js | 57 +
.../common/modal/simple/component.jsx | 143 +
.../components/common/modal/simple/styles.js | 37 +
.../ui/components/common/radio/component.jsx | 39 +
.../ui/components/common/radio/styles.js | 33 +
.../ui/components/common/switch/component.jsx | 94 +
.../ui/components/common/switch/styles.js | 170 +
.../ui/components/common/toast/component.jsx | 56 +
.../ui/components/common/toast/container.jsx | 30 +
.../common/toast/inject-notify/component.jsx | 7 +
.../ui/components/common/toast/styles.js | 210 +
.../ui/components/common/tooltip/bbbtip.css | 17 +
.../components/common/tooltip/component.jsx | 200 +
.../components/common/tooltip/container.jsx | 10 +
.../components-data/chat-context/adapter.jsx | 156 +
.../components-data/chat-context/context.jsx | 397 +
.../group-chat-context/adapter.jsx | 25 +
.../group-chat-context/context.jsx | 76 +
.../components-data/users-context/adapter.jsx | 84 +
.../components-data/users-context/context.jsx | 133 +
.../components-data/users-context/service.js | 31 +
.../connection-status/button/component.jsx | 123 +
.../connection-status/button/container.jsx | 16 +
.../connection-status/button/styles.js | 15 +
.../connection-status/icon/component.jsx | 30 +
.../connection-status/icon/styles.js | 86 +
.../connection-status/modal/component.jsx | 588 +
.../connection-status/modal/container.jsx | 10 +
.../connection-status/modal/styles.js | 417 +
.../components/connection-status/service.js | 561 +
.../status-helper/component.jsx | 113 +
.../status-helper/container.jsx | 12 +
.../connection-status/status-helper/styles.js | 45 +
.../context-providers/component.jsx | 21 +
.../ui/components/debug-window/component.jsx | 203 +
.../ui/components/debug-window/styles.js | 132 +
.../ui/components/dropdown/component.jsx | 330 +
.../components/dropdown/content/component.jsx | 41 +
.../ui/components/dropdown/content/styles.js | 154 +
.../ui/components/dropdown/list/component.jsx | 206 +
.../dropdown/list/item/component.jsx | 103 +
.../components/dropdown/list/item/styles.js | 99 +
.../dropdown/list/separator/component.jsx | 17 +
.../dropdown/list/separator/styles.js | 18 +
.../ui/components/dropdown/list/styles.js | 47 +
.../dropdown/list/title/component.jsx | 22 +
.../components/dropdown/list/title/styles.js | 12 +
.../imports/ui/components/dropdown/styles.js | 37 +
.../components/dropdown/trigger/component.jsx | 101 +
.../ui/components/emoji-picker/component.jsx | 71 +
.../emoji-picker/reactions-picker/styles.js | 35 +
.../emoji-picker/reactions-picker/styles.scss | 27 +
.../ui/components/emoji-rain/component.jsx | 121 +
.../ui/components/emoji-rain/container.jsx | 11 +
.../ui/components/emoji-rain/service.js | 13 +
.../end-meeting-confirmation/component.jsx | 80 +
.../end-meeting-confirmation/container.jsx | 21 +
.../end-meeting-confirmation/service.js | 25 +
.../ui/components/error-screen/component.jsx | 149 +
.../ui/components/error-screen/styles.js | 57 +
.../external-video-player/component.jsx | 728 +
.../external-video-player/container.jsx | 48 +
.../custom-players/arc-player.jsx | 208 +
.../custom-players/panopto.jsx | 16 +
.../custom-players/peertube.jsx | 216 +
.../external-video-player/modal/component.jsx | 147 +
.../external-video-player/modal/container.jsx | 11 +
.../external-video-player/modal/styles.js | 110 +
.../external-video-player/service.js | 103 +
.../external-video-player/styles.js | 119 +
.../subtitles/component.jsx | 25 +
.../external-video-player/subtitles/styles.js | 46 +
.../volume-slider/component.jsx | 91 +
.../volume-slider/styles.js | 37 +
.../ui/components/join-handler/component.jsx | 267 +
.../imports/ui/components/layout/context.jsx | 1243 +
.../ui/components/layout/defaultValues.js | 55 +
.../imports/ui/components/layout/enums.js | 112 +
.../imports/ui/components/layout/initState.js | 246 +
.../layout/layout-manager/customLayout.jsx | 754 +
.../layout/layout-manager/layoutEngine.jsx | 319 +
.../presentationFocusLayout.jsx | 514 +
.../layout/layout-manager/smartLayout.jsx | 585 +
.../layout-manager/videoFocusLayout.jsx | 524 +
.../ui/components/layout/modal/component.jsx | 201 +
.../ui/components/layout/modal/container.jsx | 24 +
.../ui/components/layout/modal/styles.js | 190 +
.../layout/push-layout/pushLayoutEngine.jsx | 274 +
.../imports/ui/components/layout/service.js | 14 +
.../imports/ui/components/layout/utils.js | 69 +
.../components/learning-dashboard/service.js | 56 +
.../ui/components/legacy/component.jsx | 205 +
.../imports/ui/components/legacy/styles.css | 26 +
.../ui/components/lock-viewers/component.jsx | 449 +
.../ui/components/lock-viewers/container.jsx | 26 +
.../lock-viewers/context/consumer.jsx | 11 +
.../lock-viewers/context/container.jsx | 32 +
.../lock-viewers/context/context.js | 29 +
.../lock-viewers/context/provider.jsx | 11 +
.../lock-viewers/context/withContext.jsx | 19 +
.../ui/components/lock-viewers/service.js | 10 +
.../ui/components/lock-viewers/styles.js | 143 +
.../media/autoplay-overlay/component.jsx | 53 +
.../media/autoplay-overlay/styles.js | 43 +
.../imports/ui/components/media/service.js | 92 +
.../ui/components/meeting-ended/component.jsx | 386 +
.../meeting-ended/rating/component.jsx | 91 +
.../components/meeting-ended/rating/styles.js | 76 +
.../ui/components/meeting-ended/service.js | 22 +
.../ui/components/meeting-ended/styles.js | 84 +
.../components/mobile-app-modal/component.jsx | 153 +
.../components/mobile-app-modal/container.jsx | 8 +
.../ui/components/mobile-app-modal/styles.js | 22 +
.../ui/components/muted-alert/component.jsx | 156 +
.../ui/components/muted-alert/styles.js | 62 +
.../ui/components/nav-bar/component.jsx | 271 +
.../ui/components/nav-bar/container.jsx | 134 +
.../leave-meeting-button/component.jsx | 185 +
.../leave-meeting-button/container.jsx | 27 +
.../nav-bar/leave-meeting-button/styles.js | 25 +
.../nav-bar/recording-indicator/component.jsx | 290 +
.../nav-bar/recording-indicator/container.jsx | 37 +
.../recording-indicator/notify/component.jsx | 77 +
.../recording-indicator/notify/container.jsx | 13 +
.../recording-indicator/notify/styles.js | 23 +
.../nav-bar/recording-indicator/service.js | 14 +
.../nav-bar/recording-indicator/styles.js | 146 +
.../nav-bar/settings-dropdown/component.jsx | 457 +
.../nav-bar/settings-dropdown/container.jsx | 41 +
.../nav-bar/settings-dropdown/styles.js | 20 +
.../imports/ui/components/nav-bar/styles.js | 131 +
.../nav-bar/talking-indicator/component.jsx | 163 +
.../nav-bar/talking-indicator/container.jsx | 99 +
.../nav-bar/talking-indicator/service.js | 8 +
.../nav-bar/talking-indicator/styles.js | 172 +
.../imports/ui/components/notes/component.jsx | 213 +
.../imports/ui/components/notes/container.jsx | 39 +
.../notes/notes-dropdown/component.jsx | 137 +
.../notes/notes-dropdown/container.jsx | 16 +
.../notes/notes-dropdown/service.js | 63 +
.../imports/ui/components/notes/service.js | 79 +
.../imports/ui/components/notes/styles.js | 37 +
.../notifications-bar/component.jsx | 47 +
.../notifications-bar/container.jsx | 213 +
.../meeting-remaining-time/component.jsx | 9 +
.../meeting-remaining-time/container.jsx | 165 +
.../meeting-remaining-time/styles.js | 26 +
.../ui/components/notifications-bar/styles.js | 58 +
.../ui/components/notifications/container.jsx | 46 +
.../imports/ui/components/pads/component.jsx | 66 +
.../imports/ui/components/pads/container.jsx | 25 +
.../ui/components/pads/content/component.jsx | 28 +
.../ui/components/pads/content/container.jsx | 14 +
.../ui/components/pads/content/styles.js | 54 +
.../imports/ui/components/pads/service.js | 143 +
.../ui/components/pads/sessions/container.jsx | 16 +
.../ui/components/pads/sessions/service.js | 37 +
.../imports/ui/components/pads/styles.js | 50 +
.../imports/ui/components/poll/component.jsx | 1033 +
.../imports/ui/components/poll/container.jsx | 76 +
.../components/poll/dragAndDrop/component.jsx | 122 +
.../ui/components/poll/dragAndDrop/styles.js | 19 +
.../components/poll/live-result/component.jsx | 286 +
.../ui/components/poll/live-result/service.js | 38 +
.../ui/components/poll/live-result/styles.js | 228 +
.../imports/ui/components/poll/service.js | 314 +
.../imports/ui/components/poll/styles.js | 340 +
.../ui/components/polling/component.jsx | 343 +
.../ui/components/polling/container.jsx | 60 +
.../imports/ui/components/polling/service.js | 51 +
.../imports/ui/components/polling/styles.js | 245 +
.../components/presentation-pod/component.jsx | 24 +
.../components/presentation-pod/container.jsx | 32 +
.../ui/components/presentation-pod/service.js | 21 +
.../ui/components/presentation/component.jsx | 995 +
.../ui/components/presentation/container.jsx | 166 +
.../component.jsx | 46 +
.../download-presentation-button/styles.js | 74 +
.../presentation-area/component.jsx | 30 +
.../presentation-area/container.jsx | 17 +
.../presentation-menu/component.jsx | 380 +
.../presentation-menu/container.jsx | 57 +
.../presentation/presentation-menu/styles.js | 149 +
.../presentation-uploader-toast/component.jsx | 453 +
.../presentation-toolbar/component.jsx | 509 +
.../presentation-toolbar/container.jsx | 91 +
.../presentation-toolbar/service.js | 46 +
.../smart-video-share/component.jsx | 92 +
.../smart-video-share/container.jsx | 21 +
.../smart-video-share/styles.js | 25 +
.../presentation-toolbar/styles.js | 297 +
.../zoom-tool/component.jsx | 254 +
.../zoom-tool/holdButton/component.jsx | 113 +
.../presentation-toolbar/zoom-tool/styles.js | 56 +
.../presentation-uploader/component.jsx | 1301 +
.../presentation-uploader/container.jsx | 69 +
.../component.jsx | 207 +
.../component.jsx | 39 +
.../styles.js | 16 +
.../presentation-uploader/service.js | 497 +
.../presentation-uploader/styles.js | 669 +
.../presentation/resize-wrapper/component.jsx | 19 +
.../ui/components/presentation/service.js | 244 +
.../presentation/slide/component.jsx | 43 +
.../ui/components/presentation/styles.js | 184 +
.../raisehand-notifier/component.jsx | 203 +
.../raisehand-notifier/container.jsx | 47 +
.../components/raisehand-notifier/styles.js | 151 +
.../ui/components/recording/component.jsx | 95 +
.../ui/components/recording/container.jsx | 24 +
.../ui/components/reload-button/component.jsx | 19 +
.../ui/components/reload-button/styles.js | 39 +
.../screenreader-alert/collection.js | 3 +
.../screenreader-alert/component.jsx | 18 +
.../screenreader-alert/container.jsx | 19 +
.../components/screenreader-alert/service.js | 19 +
.../ui/components/screenshare/component.jsx | 578 +
.../ui/components/screenshare/container.jsx | 160 +
.../ui/components/screenshare/service.js | 427 +
.../ui/components/screenshare/styles.js | 105 +
.../screenshare/switch-button/component.jsx | 64 +
.../screenshare/switch-button/container.jsx | 8 +
.../screenshare/switch-button/styles.js | 70 +
.../ui/components/settings/component.jsx | 348 +
.../ui/components/settings/container.jsx | 40 +
.../imports/ui/components/settings/service.js | 67 +
.../imports/ui/components/settings/styles.js | 139 +
.../submenus/application/component.jsx | 633 +
.../settings/submenus/application/styles.js | 102 +
.../settings/submenus/base/component.jsx | 27 +
.../submenus/data-saving/component.jsx | 112 +
.../settings/submenus/data-saving/styles.js | 29 +
.../submenus/notification/component.jsx | 264 +
.../settings/submenus/notification/styles.js | 41 +
.../ui/components/settings/submenus/styles.js | 118 +
.../submenus/transcription/component.jsx | 97 +
.../settings/submenus/transcription/styles.js | 29 +
.../settings/submenus/video/component.jsx | 114 +
.../settings/submenus/video/styles.js | 37 +
.../ui/components/shortcut-help/component.jsx | 459 +
.../ui/components/shortcut-help/service.jsx | 39 +
.../ui/components/shortcut-help/styles.js | 66 +
.../components/sidebar-content/component.jsx | 162 +
.../components/sidebar-content/container.jsx | 26 +
.../ui/components/sidebar-content/styles.js | 43 +
.../sidebar-navigation/component.jsx | 110 +
.../sidebar-navigation/container.jsx | 19 +
.../ui/components/subscriptions/component.jsx | 167 +
.../ui/components/text-input/component.jsx | 181 +
.../ui/components/text-input/styles.js | 79 +
.../imports/ui/components/timer/component.jsx | 449 +
.../imports/ui/components/timer/container.jsx | 28 +
.../components/timer/indicator/component.jsx | 460 +
.../components/timer/indicator/container.jsx | 31 +
.../ui/components/timer/indicator/styles.js | 132 +
.../imports/ui/components/timer/service.js | 348 +
.../imports/ui/components/timer/styles.js | 266 +
.../ui/components/user-avatar/component.jsx | 100 +
.../ui/components/user-avatar/styles.js | 279 +
.../ui/components/user-info/component.jsx | 70 +
.../ui/components/user-info/container.jsx | 7 +
.../ui/components/user-info/service.js | 7 +
.../imports/ui/components/user-info/styles.js | 33 +
.../captions-list-item/component.jsx | 84 +
.../user-list/captions-list-item/styles.js | 9 +
.../user-list/chat-list-item/component.jsx | 205 +
.../user-list/chat-list-item/container.jsx | 30 +
.../user-list/chat-list-item/styles.js | 108 +
.../ui/components/user-list/component.jsx | 43 +
.../ui/components/user-list/container.jsx | 14 +
.../user-list/custom-logo/component.jsx | 13 +
.../user-list/custom-logo/styles.js | 28 +
.../ui/components/user-list/service.js | 842 +
.../imports/ui/components/user-list/styles.js | 80 +
.../breakout-room/component.jsx | 91 +
.../breakout-room/container.jsx | 35 +
.../user-list-content/breakout-room/styles.js | 44 +
.../user-list/user-list-content/component.jsx | 60 +
.../user-list/user-list-content/container.jsx | 40 +
.../user-list/user-list-content/styles.js | 175 +
.../user-list-content/timer/component.jsx | 75 +
.../user-list-content/timer/container.jsx | 17 +
.../user-list-content/timer/styles.js | 27 +
.../user-captions/component.jsx | 139 +
.../user-captions/container.jsx | 18 +
.../user-list-content/user-captions/styles.js | 61 +
.../user-messages/component.jsx | 141 +
.../user-messages/container.jsx | 35 +
.../user-list-content/user-messages/styles.js | 69 +
.../user-notes/component.jsx | 199 +
.../user-notes/container.jsx | 22 +
.../user-list-content/user-notes/styles.js | 56 +
.../user-participants/component.jsx | 279 +
.../user-participants/container.jsx | 78 +
.../user-participants/styles.js | 82 +
.../user-list-item/component.jsx | 964 +
.../user-list-item/container.jsx | 65 +
.../user-list-item/styles.js | 189 +
.../user-options/component.jsx | 446 +
.../user-options/container.jsx | 108 +
.../user-participants/user-options/styles.js | 23 +
.../user-polls/component.jsx | 77 +
.../user-polls/container.jsx | 25 +
.../user-list-content/user-polls/styles.js | 25 +
.../waiting-users/component.jsx | 82 +
.../waiting-users/container.jsx | 21 +
.../user-list-content/waiting-users/styles.js | 31 +
.../ui/components/user-reaction/service.js | 52 +
.../ui/components/utils/hooks/index.js | 19 +
.../ui/components/video-preview/component.jsx | 1282 +
.../ui/components/video-preview/container.jsx | 64 +
.../ui/components/video-preview/service.js | 252 +
.../ui/components/video-preview/styles.js | 267 +
.../virtual-background/component.jsx | 523 +
.../virtual-background/context.jsx | 104 +
.../virtual-background/service.js | 137 +
.../virtual-background/styles.js | 181 +
.../components/video-provider/component.jsx | 1280 +
.../components/video-provider/container.jsx | 42 +
.../many-users-notify/component.jsx | 101 +
.../many-users-notify/container.jsx | 37 +
.../many-users-notify/styles.js | 35 +
.../ui/components/video-provider/service.js | 1099 +
.../video-provider/stream-sorting.js | 140 +
.../video-provider/video-button/component.jsx | 244 +
.../video-provider/video-button/container.jsx | 33 +
.../video-provider/video-button/styles.js | 9 +
.../video-provider/video-list/component.jsx | 397 +
.../video-provider/video-list/container.jsx | 44 +
.../video-provider/video-list/styles.js | 118 +
.../video-list/video-list-item/component.jsx | 305 +
.../video-list/video-list-item/container.jsx | 65 +
.../drag-and-drop/component.jsx | 173 +
.../video-list-item/pin-area/component.jsx | 52 +
.../video-list-item/pin-area/styles.js | 43 +
.../video-list/video-list-item/styles.js | 192 +
.../user-actions/component.jsx | 249 +
.../video-list-item/user-actions/styles.js | 123 +
.../video-list-item/user-avatar/component.jsx | 63 +
.../video-list-item/user-avatar/styles.js | 52 +
.../video-list-item/user-status/component.jsx | 29 +
.../video-list-item/user-status/styles.js | 32 +
.../view-actions/component.jsx | 55 +
.../video-list-item/view-actions/styles.js | 9 +
.../components/waiting-users/alert/service.js | 58 +
.../components/waiting-users/alert/styles.js | 16 +
.../ui/components/waiting-users/component.jsx | 408 +
.../ui/components/waiting-users/container.jsx | 52 +
.../waiting-users/guest-policy/component.jsx | 152 +
.../waiting-users/guest-policy/container.jsx | 22 +
.../waiting-users/guest-policy/styles.js | 50 +
.../ui/components/waiting-users/service.js | 83 +
.../ui/components/waiting-users/styles.js | 257 +
.../ui/components/wake-lock/component.jsx | 104 +
.../ui/components/wake-lock/container.jsx | 55 +
.../ui/components/wake-lock/service.js | 100 +
.../imports/ui/components/wake-lock/styles.js | 9 +
.../ui/components/webcam/component.jsx | 281 +
.../ui/components/webcam/container.jsx | 88 +
.../webcam/drop-areas/component.jsx | 39 +
.../webcam/drop-areas/container.jsx | 15 +
.../ui/components/webcam/drop-areas/styles.js | 40 +
.../imports/ui/components/webcam/styles.js | 38 +
.../ui/components/whiteboard/component.jsx | 1259 +
.../ui/components/whiteboard/container.jsx | 170 +
.../whiteboard/cursors/component.jsx | 349 +
.../whiteboard/cursors/container.jsx | 22 +
.../whiteboard/cursors/cursor/component.jsx | 93 +
.../cursors/position-label/component.jsx | 93 +
.../components/whiteboard/cursors/service.js | 108 +
.../pan-tool-injector/component.jsx | 143 +
.../ui/components/whiteboard/service.js | 429 +
.../ui/components/whiteboard/styles.js | 151 +
.../imports/ui/components/whiteboard/utils.js | 388 +
.../LiveDataEventBroker.js | 93 +
.../LocalCollectionSynchronizer.js | 147 +
src/2.7.12/imports/ui/services/api/index.js | 40 +
.../ui/services/audio-manager/error-codes.js | 9 +
.../ui/services/audio-manager/index.js | 1174 +
src/2.7.12/imports/ui/services/auth/index.js | 280 +
.../services/bbb-webrtc-sfu/audio-broker.js | 260 +
.../bbb-webrtc-sfu/broker-base-errors.js | 37 +
.../ui/services/bbb-webrtc-sfu/load-play.js | 32 +
.../bbb-webrtc-sfu/screenshare-broker.js | 296 +
.../bbb-webrtc-sfu/sfu-base-broker.js | 366 +
.../bbb-webrtc-sfu/stream-state-service.js | 45 +
.../ui/services/bbb-webrtc-sfu/utils.js | 25 +
.../imports/ui/services/features/index.js | 107 +
.../imports/ui/services/locale/index.js | 75 +
.../ui/services/meeting-settings/index.js | 11 +
.../imports/ui/services/mobile-app/index.js | 301 +
.../imports/ui/services/notification/index.js | 52 +
.../imports/ui/services/settings/index.js | 123 +
.../imports/ui/services/storage/index.js | 8 +
.../imports/ui/services/storage/local.js | 5 +
.../imports/ui/services/storage/reactive.js | 74 +
.../imports/ui/services/storage/session.js | 5 +
.../subscriptionRegistry.js | 26 +
.../ui/services/unread-messages/index.js | 55 +
.../ui/services/users-settings/index.js | 18 +
.../ui/services/virtual-background/README.md | 80 +
.../services/virtual-background/TimeWorker.js | 62 +
.../ui/services/virtual-background/index.js | 412 +
.../ui/services/virtual-background/service.js | 111 +
.../services/webrtc-base/bbb-video-stream.js | 153 +
.../services/webrtc-base/local-pc-loopback.js | 111 +
.../imports/ui/services/webrtc-base/peer.js | 477 +
.../imports/ui/services/webrtc-base/utils.js | 29 +
.../styled-components/breakpoints.js | 21 +
.../stylesheets/styled-components/general.js | 173 +
.../styled-components/globalStyles.js | 170 +
.../stylesheets/styled-components/palette.js | 218 +
.../styled-components/placeholders.js | 61 +
.../styled-components/scrollable.js | 126 +
.../styled-components/typography.js | 38 +
src/2.7.12/imports/utils/array-utils.js | 39 +
src/2.7.12/imports/utils/browserInfo.js | 56 +
.../imports/utils/caseInsensitiveReducer.js | 17 +
src/2.7.12/imports/utils/debounce.js | 52 +
src/2.7.12/imports/utils/deviceInfo.js | 30 +
src/2.7.12/imports/utils/dom-utils.js | 32 +
.../imports/utils/fetchStunTurnServers.js | 96 +
src/2.7.12/imports/utils/hexInt.js | 16 +
src/2.7.12/imports/utils/humanizeSeconds.js | 16 +
.../utils/ios-webview-audio-polyfills.js | 100 +
src/2.7.12/imports/utils/keyCodes.js | 25 +
src/2.7.12/imports/utils/lineEndings.js | 11 +
.../imports/utils/logoutRouteHandler.js | 13 +
.../imports/utils/media-stream-utils.js | 72 +
.../imports/utils/mediaElementPlayRetry.js | 27 +
src/2.7.12/imports/utils/mimeTypes.js | 33 +
src/2.7.12/imports/utils/regex-weburl.js | 120 +
src/2.7.12/imports/utils/sdpUtils.js | 351 +
src/2.7.12/imports/utils/slideCalcUtils.js | 73 +
src/2.7.12/imports/utils/stats.js | 195 +
src/2.7.12/imports/utils/statuses.js | 14 +
src/2.7.12/imports/utils/string-utils.js | 78 +
src/2.7.12/imports/utils/throttle.js | 45 +
src/2.7.12/jsconfig.json | 6 +
src/2.7.12/package-lock.json | 7447 ++++++
src/2.7.12/package.json | 126 +
.../private/config/fallbackLocales.json | 30 +
src/2.7.12/private/config/settings.yml | 1064 +
.../private/static/guest-wait/guest-wait.html | 402 +
src/2.7.12/public/compatibility/sip.js | 20751 ++++++++++++++++
.../public/compatibility/tflite-simd.js | 32 +
src/2.7.12/public/compatibility/tflite.js | 32 +
.../public/fonts/BbbIcons/bbb-icons.woff | Bin 0 -> 21276 bytes
.../public/fonts/BbbIcons/bbb-icons.woff2 | Bin 0 -> 13264 bytes
src/2.7.12/public/fonts/SourceSansPro/OFL.txt | 93 +
.../SourceSansPro/SourceSansPro-Bold.woff | Bin 0 -> 151868 bytes
.../SourceSansPro-BoldItalic.woff | Bin 0 -> 117964 bytes
.../SourceSansPro/SourceSansPro-Italic.woff | Bin 0 -> 115924 bytes
.../SourceSansPro/SourceSansPro-Light.woff | Bin 0 -> 146020 bytes
.../SourceSansPro-LightItalic.woff | Bin 0 -> 113140 bytes
.../SourceSansPro/SourceSansPro-Regular.woff | Bin 0 -> 148620 bytes
.../SourceSansPro/SourceSansPro-Semibold.woff | Bin 0 -> 150320 bytes
.../SourceSansPro-SemiboldItalic.woff | Bin 0 -> 116848 bytes
src/2.7.12/public/locales/af.json | 770 +
src/2.7.12/public/locales/ar.json | 1417 ++
src/2.7.12/public/locales/az.json | 707 +
src/2.7.12/public/locales/bg_BG.json | 621 +
src/2.7.12/public/locales/bn.json | 801 +
src/2.7.12/public/locales/ca.json | 1413 ++
src/2.7.12/public/locales/cs_CZ.json | 1062 +
src/2.7.12/public/locales/da.json | 704 +
src/2.7.12/public/locales/de.json | 1413 ++
src/2.7.12/public/locales/dv.json | 785 +
src/2.7.12/public/locales/el_GR.json | 1417 ++
src/2.7.12/public/locales/en.json | 1417 ++
src/2.7.12/public/locales/eo.json | 677 +
src/2.7.12/public/locales/es.json | 1413 ++
src/2.7.12/public/locales/es_419.json | 180 +
src/2.7.12/public/locales/es_ES.json | 1413 ++
src/2.7.12/public/locales/es_MX.json | 459 +
src/2.7.12/public/locales/et.json | 1417 ++
src/2.7.12/public/locales/eu.json | 1417 ++
src/2.7.12/public/locales/fa_IR.json | 1417 ++
src/2.7.12/public/locales/fi.json | 1413 ++
src/2.7.12/public/locales/fr.json | 1413 ++
src/2.7.12/public/locales/gl.json | 1413 ++
src/2.7.12/public/locales/he.json | 909 +
src/2.7.12/public/locales/hi_IN.json | 484 +
src/2.7.12/public/locales/hr.json | 631 +
src/2.7.12/public/locales/hu_HU.json | 1391 ++
src/2.7.12/public/locales/hy.json | 1314 +
src/2.7.12/public/locales/id.json | 915 +
src/2.7.12/public/locales/it_IT.json | 1417 ++
src/2.7.12/public/locales/ja.json | 1413 ++
src/2.7.12/public/locales/ka.json | 714 +
src/2.7.12/public/locales/km.json | 561 +
src/2.7.12/public/locales/kn.json | 618 +
src/2.7.12/public/locales/ko_KR.json | 1391 ++
src/2.7.12/public/locales/lo_LA.json | 50 +
src/2.7.12/public/locales/lt_LT.json | 1391 ++
src/2.7.12/public/locales/lv.json | 623 +
src/2.7.12/public/locales/ml.json | 810 +
src/2.7.12/public/locales/mn_MN.json | 5 +
src/2.7.12/public/locales/nb_NO.json | 1391 ++
src/2.7.12/public/locales/nl.json | 1314 +
src/2.7.12/public/locales/oc.json | 305 +
src/2.7.12/public/locales/pl_PL.json | 845 +
src/2.7.12/public/locales/pt.json | 1391 ++
src/2.7.12/public/locales/pt_BR.json | 1394 ++
src/2.7.12/public/locales/ro_RO.json | 588 +
src/2.7.12/public/locales/ru.json | 1413 ++
src/2.7.12/public/locales/sk_SK.json | 771 +
src/2.7.12/public/locales/sl.json | 680 +
src/2.7.12/public/locales/sr.json | 621 +
src/2.7.12/public/locales/sv_SE.json | 1391 ++
src/2.7.12/public/locales/ta.json | 790 +
src/2.7.12/public/locales/te.json | 907 +
src/2.7.12/public/locales/th.json | 591 +
src/2.7.12/public/locales/tr.json | 1417 ++
src/2.7.12/public/locales/tr_TR.json | 1315 +
src/2.7.12/public/locales/uk_UA.json | 1413 ++
src/2.7.12/public/locales/vi.json | 116 +
src/2.7.12/public/locales/vi_VN.json | 896 +
src/2.7.12/public/locales/zh_CN.json | 755 +
src/2.7.12/public/locales/zh_TW.json | 1405 ++
.../public/resources/images/allow-mic.png | Bin 0 -> 7880 bytes
src/2.7.12/public/resources/images/avatar.png | Bin 0 -> 14417 bytes
.../resources/images/layouts/custom.svg | 19 +
.../images/layouts/presentationFocus.svg | 18 +
.../public/resources/images/layouts/smart.svg | 18 +
.../resources/images/layouts/videoFocus.svg | 18 +
.../resources/images/video-menu/icon-swap.svg | 32 +
.../images/video-menu/icon-webcam-off.svg | 20 +
.../virtual-backgrounds/architecture.jpg | Bin 0 -> 348999 bytes
.../images/virtual-backgrounds/board.jpg | Bin 0 -> 509102 bytes
.../images/virtual-backgrounds/brickwall.jpg | Bin 0 -> 989152 bytes
.../images/virtual-backgrounds/coffeeshop.jpg | Bin 0 -> 446038 bytes
.../images/virtual-backgrounds/home.jpg | Bin 0 -> 316733 bytes
.../virtual-backgrounds/thumbnails/blur.jpg | Bin 0 -> 2170 bytes
.../virtual-backgrounds/thumbnails/board.jpg | Bin 0 -> 1755 bytes
.../thumbnails/coffeeshop.jpg | Bin 0 -> 3563 bytes
.../virtual-backgrounds/thumbnails/home.jpg | Bin 0 -> 3301 bytes
.../images/whiteboard-cursor/ellipse.png | Bin 0 -> 926 bytes
.../images/whiteboard-cursor/ellipse.svg | 120 +
.../images/whiteboard-cursor/line.png | Bin 0 -> 336 bytes
.../images/whiteboard-cursor/line.svg | 116 +
.../images/whiteboard-cursor/pan-closed.png | Bin 0 -> 605 bytes
.../images/whiteboard-cursor/pan-closed.svg | 26 +
.../images/whiteboard-cursor/pan.png | Bin 0 -> 919 bytes
.../images/whiteboard-cursor/pan.svg | 16 +
.../images/whiteboard-cursor/pencil.png | Bin 0 -> 850 bytes
.../images/whiteboard-cursor/pencil.svg | 111 +
.../images/whiteboard-cursor/square.png | Bin 0 -> 345 bytes
.../images/whiteboard-cursor/square.svg | 114 +
.../images/whiteboard-cursor/text.png | Bin 0 -> 621 bytes
.../images/whiteboard-cursor/text.svg | 122 +
.../images/whiteboard-cursor/triangle.png | Bin 0 -> 939 bytes
.../images/whiteboard-cursor/triangle.svg | 114 +
.../public/resources/sounds/CalmMusic.mp3 | Bin 0 -> 420224 bytes
.../public/resources/sounds/LeftCall.mp3 | Bin 0 -> 20257 bytes
src/2.7.12/public/resources/sounds/Poll.mp3 | Bin 0 -> 28338 bytes
.../public/resources/sounds/RelaxingMusic.mp3 | Bin 0 -> 238109 bytes
.../resources/sounds/ScreenshareOff.mp3 | Bin 0 -> 40805 bytes
src/2.7.12/public/resources/sounds/alarm.mp3 | Bin 0 -> 33853 bytes
.../resources/sounds/aristocratDrums.mp3 | Bin 0 -> 539117 bytes
.../public/resources/sounds/audioSample.mp3 | Bin 0 -> 43885 bytes
.../public/resources/sounds/bbb-handRaise.mp3 | Bin 0 -> 6975 bytes
.../public/resources/sounds/doorbell.mp3 | Bin 0 -> 12072 bytes
src/2.7.12/public/resources/sounds/notify.mp3 | Bin 0 -> 10702 bytes
.../public/resources/sounds/userJoin.mp3 | Bin 0 -> 10702 bytes
.../resources/tfmodels/segm_full_v679.tflite | Bin 0 -> 407248 bytes
.../resources/tfmodels/segm_lite_v681.tflite | Bin 0 -> 407232 bytes
src/2.7.12/public/svgs/bbb_audio_icon.svg | 1 +
.../public/svgs/bbb_headphones_icon.svg | 1 +
src/2.7.12/public/wasm/tflite-simd.wasm | Bin 0 -> 2601242 bytes
src/2.7.12/public/wasm/tflite.wasm | Bin 0 -> 2028341 bytes
src/2.7.12/run-dev.sh | 20 +
src/2.7.12/server/main.js | 49 +
src/2.7.12/test-html5.sh | 32 +
1350 files changed, 202997 insertions(+)
create mode 100644 src/2.7.12/.eslintignore
create mode 100644 src/2.7.12/.eslintrc.js
create mode 100644 src/2.7.12/.gitignore
create mode 100644 src/2.7.12/.meteor/.finished-upgraders
create mode 100644 src/2.7.12/.meteor/.gitignore
create mode 100644 src/2.7.12/.meteor/.id
create mode 100644 src/2.7.12/.meteor/cordova-plugins
create mode 100644 src/2.7.12/.meteor/packages
create mode 100644 src/2.7.12/.meteor/platforms
create mode 100644 src/2.7.12/.meteor/release
create mode 100644 src/2.7.12/.meteor/versions
create mode 100644 src/2.7.12/client/collection-mirror-initializer.js
create mode 100644 src/2.7.12/client/legacy.jsx
create mode 100644 src/2.7.12/client/main.html
create mode 100644 src/2.7.12/client/main.jsx
create mode 100644 src/2.7.12/client/stylesheets/bbb-icons.css
create mode 100644 src/2.7.12/client/stylesheets/fontSizing.css
create mode 100644 src/2.7.12/client/stylesheets/fonts.css
create mode 100644 src/2.7.12/client/stylesheets/modals.css
create mode 100644 src/2.7.12/client/stylesheets/normalize.css
create mode 100644 src/2.7.12/client/stylesheets/toastify.css
create mode 100644 src/2.7.12/client/stylesheets/toggleSwitch.css
create mode 100644 src/2.7.12/deploy_to_usr_share.sh
create mode 100644 src/2.7.12/imports/api/annotations/addAnnotation.js
create mode 100644 src/2.7.12/imports/api/annotations/index.js
create mode 100644 src/2.7.12/imports/api/annotations/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/annotations/server/handlers/whiteboardAnnotations.js
create mode 100644 src/2.7.12/imports/api/annotations/server/handlers/whiteboardCleared.js
create mode 100644 src/2.7.12/imports/api/annotations/server/handlers/whiteboardDelete.js
create mode 100644 src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js
create mode 100644 src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js
create mode 100644 src/2.7.12/imports/api/annotations/server/index.js
create mode 100644 src/2.7.12/imports/api/annotations/server/methods.js
create mode 100644 src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js
create mode 100644 src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js
create mode 100644 src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js
create mode 100644 src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js
create mode 100644 src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js
create mode 100644 src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js
create mode 100644 src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js
create mode 100644 src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js
create mode 100644 src/2.7.12/imports/api/annotations/server/publishers.js
create mode 100644 src/2.7.12/imports/api/annotations/server/streamer.js
create mode 100644 src/2.7.12/imports/api/audio-captions/index.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/index.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/methods.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js
create mode 100644 src/2.7.12/imports/api/audio-captions/server/publishers.js
create mode 100644 src/2.7.12/imports/api/audio/client/bridge/base.js
create mode 100644 src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js
create mode 100644 src/2.7.12/imports/api/audio/client/bridge/service.js
create mode 100644 src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js
create mode 100644 src/2.7.12/imports/api/audio/client/bridge/sip.js
create mode 100644 src/2.7.12/imports/api/auth-token-validation/index.js
create mode 100644 src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js
create mode 100644 src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js
create mode 100644 src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js
create mode 100644 src/2.7.12/imports/api/breakouts-history/index.js
create mode 100644 src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js
create mode 100644 src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js
create mode 100644 src/2.7.12/imports/api/breakouts-history/server/index.js
create mode 100644 src/2.7.12/imports/api/breakouts-history/server/publishers.js
create mode 100644 src/2.7.12/imports/api/breakouts/index.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/index.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods/moveUser.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js
create mode 100644 src/2.7.12/imports/api/breakouts/server/publishers.js
create mode 100644 src/2.7.12/imports/api/captions/index.js
create mode 100644 src/2.7.12/imports/api/captions/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js
create mode 100644 src/2.7.12/imports/api/captions/server/helpers.js
create mode 100644 src/2.7.12/imports/api/captions/server/index.js
create mode 100644 src/2.7.12/imports/api/captions/server/methods.js
create mode 100644 src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js
create mode 100644 src/2.7.12/imports/api/captions/server/methods/startDictation.js
create mode 100644 src/2.7.12/imports/api/captions/server/methods/stopDictation.js
create mode 100644 src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js
create mode 100644 src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js
create mode 100644 src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js
create mode 100644 src/2.7.12/imports/api/captions/server/modifiers/setDictation.js
create mode 100644 src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js
create mode 100644 src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js
create mode 100644 src/2.7.12/imports/api/captions/server/publishers.js
create mode 100644 src/2.7.12/imports/api/common/server/helpers.js
create mode 100644 src/2.7.12/imports/api/connection-status/index.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/index.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/methods.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js
create mode 100644 src/2.7.12/imports/api/connection-status/server/publishers.js
create mode 100644 src/2.7.12/imports/api/cursor/index.js
create mode 100644 src/2.7.12/imports/api/cursor/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js
create mode 100644 src/2.7.12/imports/api/cursor/server/index.js
create mode 100644 src/2.7.12/imports/api/cursor/server/methods.js
create mode 100644 src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js
create mode 100644 src/2.7.12/imports/api/cursor/server/streamer.js
create mode 100644 src/2.7.12/imports/api/external-videos/index.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/index.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/methods.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js
create mode 100644 src/2.7.12/imports/api/external-videos/server/streamer.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/index.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/index.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/addSystemMsg.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/clearGroupChatMsg.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/removeGroupChatMsg.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/startTyping.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/stopTyping.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/modifiers/syncMeetingChatMsgs.js
create mode 100644 src/2.7.12/imports/api/group-chat-msg/server/publishers.js
create mode 100644 src/2.7.12/imports/api/group-chat/index.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/handlers/groupChatCreated.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/handlers/groupChatDestroyed.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/handlers/groupChats.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/index.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/methods.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/methods/createGroupChat.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/methods/destroyGroupChat.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/modifiers/addGroupChat.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/modifiers/clearGroupChat.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/modifiers/removeGroupChat.js
create mode 100644 src/2.7.12/imports/api/group-chat/server/publishers.js
create mode 100644 src/2.7.12/imports/api/guest-users/index.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/handlers/guestApproved.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/handlers/guestWaitingLeft.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/handlers/guestsWaitingForApproval.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/handlers/privateGuestLobbyMessageChanged.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/index.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/methods.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/methods/allowPendingUsers.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/methods/changeGuestPolicy.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/methods/setGuestLobbyMessage.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/methods/setPrivateGuestLobbyMessage.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/methods/updatePositionInWaitingQueue.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/modifiers/clearGuestUsers.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/modifiers/removeGuest.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/modifiers/setGuestStatus.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/modifiers/setPrivateGuestLobbyMessage.js
create mode 100644 src/2.7.12/imports/api/guest-users/server/publishers.js
create mode 100644 src/2.7.12/imports/api/local-settings/index.js
create mode 100644 src/2.7.12/imports/api/local-settings/server/index.js
create mode 100644 src/2.7.12/imports/api/local-settings/server/methods.js
create mode 100644 src/2.7.12/imports/api/local-settings/server/methods/userChangedLocalSettings.js
create mode 100644 src/2.7.12/imports/api/local-settings/server/modifiers/clearLocalSettings.js
create mode 100644 src/2.7.12/imports/api/local-settings/server/modifiers/setChangedLocalSettings.js
create mode 100644 src/2.7.12/imports/api/local-settings/server/publishers.js
create mode 100644 src/2.7.12/imports/api/log-client/server/index.js
create mode 100644 src/2.7.12/imports/api/log-client/server/methods.js
create mode 100644 src/2.7.12/imports/api/log-client/server/methods/logClient.js
create mode 100644 src/2.7.12/imports/api/meetings/index.js
create mode 100644 src/2.7.12/imports/api/meetings/notificationEmitter.js
create mode 100644 src/2.7.12/imports/api/meetings/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/broadcastLayout.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/broadcastPushLayout.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/getAllMeetings.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/guestLobbyMessageChanged.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/guestPolicyChanged.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/handleNotifyAllInMeetingEvtMsg.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/handleNotifyRoleInMeeting.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/handleNotifyUserInMeeting.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/handleUserChatLockChange.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/meetingCreation.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/meetingDestruction.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/meetingEnd.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/meetingLockChange.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/recordingStatusChange.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/recordingTimerChange.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/selectRandomViewer.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/timeRemainingUpdate.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/userLockChange.js
create mode 100644 src/2.7.12/imports/api/meetings/server/handlers/webcamOnlyModerator.js
create mode 100644 src/2.7.12/imports/api/meetings/server/index.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/changeLayout.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/clearRandomlySelectedUser.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/endMeeting.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/setPushLayout.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/toggleLockSettings.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/toggleRecording.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/toggleWebcamsOnlyForModerator.js
create mode 100644 src/2.7.12/imports/api/meetings/server/methods/transferUser.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/addMeeting.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js
create mode 100644 src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js
create mode 100644 src/2.7.12/imports/api/meetings/server/publishers.js
create mode 100644 src/2.7.12/imports/api/pads/index.js
create mode 100644 src/2.7.12/imports/api/pads/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/groupCreated.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/padContent.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/padCreated.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/padPinned.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/padTail.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/padUpdated.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js
create mode 100644 src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js
create mode 100644 src/2.7.12/imports/api/pads/server/helpers.js
create mode 100644 src/2.7.12/imports/api/pads/server/index.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/createGroup.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/createPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/createSession.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/getPadId.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/padCapture.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/pinPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/updatePad.js
create mode 100644 src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/clearPads.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/contentPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/createGroup.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/createPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/createSession.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/pinPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/tailPad.js
create mode 100644 src/2.7.12/imports/api/pads/server/modifiers/updatePad.js
create mode 100644 src/2.7.12/imports/api/pads/server/publishers.js
create mode 100644 src/2.7.12/imports/api/polls/index.js
create mode 100644 src/2.7.12/imports/api/polls/index.test.js
create mode 100644 src/2.7.12/imports/api/polls/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/pollPublished.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/pollStarted.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/pollStopped.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/userResponded.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js
create mode 100644 src/2.7.12/imports/api/polls/server/handlers/userVoted.js
create mode 100644 src/2.7.12/imports/api/polls/server/index.js
create mode 100644 src/2.7.12/imports/api/polls/server/methods.js
create mode 100644 src/2.7.12/imports/api/polls/server/methods/publishPoll.js
create mode 100644 src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js
create mode 100644 src/2.7.12/imports/api/polls/server/methods/publishVote.js
create mode 100644 src/2.7.12/imports/api/polls/server/methods/startPoll.js
create mode 100644 src/2.7.12/imports/api/polls/server/methods/stopPoll.js
create mode 100644 src/2.7.12/imports/api/polls/server/modifiers/addPoll.js
create mode 100644 src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js
create mode 100644 src/2.7.12/imports/api/polls/server/modifiers/removePoll.js
create mode 100644 src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js
create mode 100644 src/2.7.12/imports/api/polls/server/publishers.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/index.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/index.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/methods.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js
create mode 100644 src/2.7.12/imports/api/presentation-pods/server/publishers.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/index.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/index.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/methods.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js
create mode 100644 src/2.7.12/imports/api/presentation-upload-token/server/publishers.js
create mode 100644 src/2.7.12/imports/api/presentations/index.js
create mode 100644 src/2.7.12/imports/api/presentations/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js
create mode 100644 src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js
create mode 100644 src/2.7.12/imports/api/presentations/server/index.js
create mode 100644 src/2.7.12/imports/api/presentations/server/methods.js
create mode 100644 src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js
create mode 100644 src/2.7.12/imports/api/presentations/server/methods/removePresentation.js
create mode 100644 src/2.7.12/imports/api/presentations/server/methods/setPresentation.js
create mode 100644 src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js
create mode 100644 src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js
create mode 100644 src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js
create mode 100644 src/2.7.12/imports/api/presentations/server/publishers.js
create mode 100644 src/2.7.12/imports/api/screenshare/client/bridge/errors.js
create mode 100644 src/2.7.12/imports/api/screenshare/client/bridge/index.js
create mode 100644 src/2.7.12/imports/api/screenshare/client/bridge/kurento.js
create mode 100644 src/2.7.12/imports/api/screenshare/client/bridge/service.js
create mode 100644 src/2.7.12/imports/api/screenshare/index.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/index.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/methods.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js
create mode 100644 src/2.7.12/imports/api/screenshare/server/publishers.js
create mode 100644 src/2.7.12/imports/api/slides/index.js
create mode 100644 src/2.7.12/imports/api/slides/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/slides/server/handlers/slideChange.js
create mode 100644 src/2.7.12/imports/api/slides/server/handlers/slideResize.js
create mode 100644 src/2.7.12/imports/api/slides/server/helpers.js
create mode 100644 src/2.7.12/imports/api/slides/server/index.js
create mode 100644 src/2.7.12/imports/api/slides/server/methods.js
create mode 100644 src/2.7.12/imports/api/slides/server/methods/switchSlide.js
create mode 100644 src/2.7.12/imports/api/slides/server/methods/zoomSlide.js
create mode 100644 src/2.7.12/imports/api/slides/server/modifiers/addSlide.js
create mode 100644 src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js
create mode 100644 src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js
create mode 100644 src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js
create mode 100644 src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js
create mode 100644 src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js
create mode 100644 src/2.7.12/imports/api/slides/server/publishers.js
create mode 100644 src/2.7.12/imports/api/timer/index.js
create mode 100644 src/2.7.12/imports/api/timer/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerActivated.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerEnded.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerReset.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerSet.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerStarted.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerStopped.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js
create mode 100644 src/2.7.12/imports/api/timer/server/handlers/trackSet.js
create mode 100644 src/2.7.12/imports/api/timer/server/helpers.js
create mode 100644 src/2.7.12/imports/api/timer/server/index.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/activateTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/createTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/endTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/getServerTime.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/resetTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/setTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/setTrack.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/startTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/stopTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/methods/switchTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/modifiers/addTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/modifiers/endTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js
create mode 100644 src/2.7.12/imports/api/timer/server/publishers.js
create mode 100644 src/2.7.12/imports/api/user-reaction/index.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/helpers.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/index.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/methods.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js
create mode 100644 src/2.7.12/imports/api/user-reaction/server/publishers.js
create mode 100644 src/2.7.12/imports/api/users-infos/index.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/index.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/methods.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js
create mode 100644 src/2.7.12/imports/api/users-infos/server/publishers.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/index.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/index.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js
create mode 100644 src/2.7.12/imports/api/users-persistent-data/server/publishers.js
create mode 100644 src/2.7.12/imports/api/users-settings/index.js
create mode 100644 src/2.7.12/imports/api/users-settings/server/index.js
create mode 100644 src/2.7.12/imports/api/users-settings/server/methods.js
create mode 100644 src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js
create mode 100644 src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js
create mode 100644 src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js
create mode 100644 src/2.7.12/imports/api/users-settings/server/publishers.js
create mode 100644 src/2.7.12/imports/api/users/index.js
create mode 100644 src/2.7.12/imports/api/users/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/changeAway.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/changeRole.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/emojiStatus.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/removeUser.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/userJoin.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/userJoined.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/userPinChanged.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js
create mode 100644 src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js
create mode 100644 src/2.7.12/imports/api/users/server/index.js
create mode 100644 src/2.7.12/imports/api/users/server/methods.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/assignPresenter.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/changeAway.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/changePin.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/changeRole.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/removeUser.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setExitReason.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setMobileUser.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setRandomUser.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/toggleUserLock.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/userActivitySign.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/userLeaving.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js
create mode 100644 src/2.7.12/imports/api/users/server/methods/validateAuthToken.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/addUser.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/changeAway.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/changePin.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/changePresenter.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/changeRole.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/clearUsers.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/removeUser.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/setMobile.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/userEjected.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js
create mode 100644 src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js
create mode 100644 src/2.7.12/imports/api/users/server/publishers.js
create mode 100644 src/2.7.12/imports/api/users/server/store/pendingAuthentications.js
create mode 100644 src/2.7.12/imports/api/video-streams/index.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/helpers.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/index.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/methods.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js
create mode 100644 src/2.7.12/imports/api/video-streams/server/publisher.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/index.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/server/index.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/server/publishers.js
create mode 100644 src/2.7.12/imports/api/voice-call-states/utils/callStates.js
create mode 100644 src/2.7.12/imports/api/voice-users/index.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/index.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/methods.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js
create mode 100644 src/2.7.12/imports/api/voice-users/server/publishers.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/index.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/index.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js
create mode 100644 src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js
create mode 100644 src/2.7.12/imports/startup/client/base.jsx
create mode 100644 src/2.7.12/imports/startup/client/intl.jsx
create mode 100644 src/2.7.12/imports/startup/client/logger.js
create mode 100644 src/2.7.12/imports/startup/server/ClientConnections.js
create mode 100644 src/2.7.12/imports/startup/server/index.js
create mode 100644 src/2.7.12/imports/startup/server/logger.js
create mode 100644 src/2.7.12/imports/startup/server/metrics.js
create mode 100644 src/2.7.12/imports/startup/server/minBrowserVersion.js
create mode 100644 src/2.7.12/imports/startup/server/prom-metrics/index.js
create mode 100644 src/2.7.12/imports/startup/server/prom-metrics/metrics.js
create mode 100644 src/2.7.12/imports/startup/server/prom-metrics/promAgent.js
create mode 100644 src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js
create mode 100644 src/2.7.12/imports/startup/server/redis.js
create mode 100644 src/2.7.12/imports/startup/server/settings.js
create mode 100644 src/2.7.12/imports/ui/components/about/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/about/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/presentation-options/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/raise-hand/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/raise-hand/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/raise-hand/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/reactions-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/reactions-button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/reactions-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/screenshare/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/screenshare/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/screenshare/styles.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/service.js
create mode 100644 src/2.7.12/imports/ui/components/actions-bar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/activity-check/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/activity-check/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/activity-check/styles.js
create mode 100644 src/2.7.12/imports/ui/components/app/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/app/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/app/service.js
create mode 100644 src/2.7.12/imports/ui/components/app/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-controls/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-controls/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-controls/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-dial/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-dial/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-modal/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-modal/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-modal/service.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-modal/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-settings/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-settings/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-stream-volume/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-stream-volume/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-test/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-test/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/audio-test/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/autoplay/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/autoplay/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/history/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/history/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/live/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/live/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/live/user/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/live/user/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/select/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/select/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/service.js
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/speech/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/speech/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/captions/speech/service.js
create mode 100644 src/2.7.12/imports/ui/components/audio/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/device-selector/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/device-selector/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/echo-test/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/echo-test/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/help/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/help/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/local-echo/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/local-echo/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/local-echo/service.js
create mode 100644 src/2.7.12/imports/ui/components/audio/local-echo/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/permissions-overlay/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/audio/permissions-overlay/styles.js
create mode 100644 src/2.7.12/imports/ui/components/audio/service.js
create mode 100644 src/2.7.12/imports/ui/components/authenticated-handler/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/banner-bar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/banner-bar/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/banner-bar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/breakout-join-confirmation/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-join-confirmation/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-join-confirmation/styles.js
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/breakout-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/invitation/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/invitation/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/message-form/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/message-form/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/message-form/styles.js
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/service.js
create mode 100644 src/2.7.12/imports/ui/components/breakout-room/styles.js
create mode 100644 src/2.7.12/imports/ui/components/captions/button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/captions/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/live/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/live/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/styles.js
create mode 100644 src/2.7.12/imports/ui/components/captions/reader-menu/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/reader-menu/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/reader-menu/styles.js
create mode 100644 src/2.7.12/imports/ui/components/captions/service.js
create mode 100644 src/2.7.12/imports/ui/components/captions/speech/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/speech/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/speech/service.js
create mode 100644 src/2.7.12/imports/ui/components/captions/styles.js
create mode 100644 src/2.7.12/imports/ui/components/captions/writer-menu/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/writer-menu/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/captions/writer-menu/styles.js
create mode 100644 src/2.7.12/imports/ui/components/chat/alert/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/alert/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/alert/push-alert/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/alert/styles.js
create mode 100644 src/2.7.12/imports/ui/components/chat/chat-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/chat-dropdown/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/chat-logger/ChatLogger.js
create mode 100644 src/2.7.12/imports/ui/components/chat/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/message-form/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/message-form/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/message-form/styles.js
create mode 100644 src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/styles.js
create mode 100644 src/2.7.12/imports/ui/components/chat/service.js
create mode 100644 src/2.7.12/imports/ui/components/chat/styles.js
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/styles.js
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/message-chat-item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/styles.js
create mode 100644 src/2.7.12/imports/ui/components/click-outside/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/button/base/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/button/button-emoji/ButtonEmoji.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/button/button-emoji/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/checkbox/base.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/checkbox/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/checkbox/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/control-header/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/control-header/left/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/control-header/left/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/control-header/right/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/control-header/right/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/control-header/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/error-boundary/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/fallback-errors/fallback-modal/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/file-reader/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/file-reader/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/fullscreen-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/fullscreen-button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/fullscreen-button/service.js
create mode 100644 src/2.7.12/imports/ui/components/common/fullscreen-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/icon/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/icon/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/loading-screen/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/loading-screen/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/locales-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/menu/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/menu/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/modal/base/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/base/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/modal/confirmation/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/confirmation/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/modal/fullscreen/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/fullscreen/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/modal/header/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/header/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/modal/random-user/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/random-user/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/random-user/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/modal/simple/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/modal/simple/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/radio/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/radio/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/switch/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/switch/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/toast/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/toast/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/toast/inject-notify/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/toast/styles.js
create mode 100644 src/2.7.12/imports/ui/components/common/tooltip/bbbtip.css
create mode 100644 src/2.7.12/imports/ui/components/common/tooltip/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/common/tooltip/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/chat-context/adapter.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/chat-context/context.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/group-chat-context/adapter.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/group-chat-context/context.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/users-context/adapter.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/users-context/context.jsx
create mode 100644 src/2.7.12/imports/ui/components/components-data/users-context/service.js
create mode 100644 src/2.7.12/imports/ui/components/connection-status/button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/connection-status/icon/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/icon/styles.js
create mode 100644 src/2.7.12/imports/ui/components/connection-status/modal/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/modal/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/modal/styles.js
create mode 100644 src/2.7.12/imports/ui/components/connection-status/service.js
create mode 100644 src/2.7.12/imports/ui/components/connection-status/status-helper/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/status-helper/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/connection-status/status-helper/styles.js
create mode 100644 src/2.7.12/imports/ui/components/context-providers/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/debug-window/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/debug-window/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/dropdown/content/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/dropdown/content/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/item/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/dropdown/list/title/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/styles.js
create mode 100644 src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/emoji-picker/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js
create mode 100644 src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss
create mode 100644 src/2.7.12/imports/ui/components/emoji-rain/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/emoji-rain/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/emoji-rain/service.js
create mode 100644 src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js
create mode 100644 src/2.7.12/imports/ui/components/error-screen/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/error-screen/styles.js
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/modal/styles.js
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/service.js
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/styles.js
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js
create mode 100644 src/2.7.12/imports/ui/components/join-handler/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/context.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/defaultValues.js
create mode 100644 src/2.7.12/imports/ui/components/layout/enums.js
create mode 100644 src/2.7.12/imports/ui/components/layout/initState.js
create mode 100644 src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/modal/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/modal/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/modal/styles.js
create mode 100644 src/2.7.12/imports/ui/components/layout/push-layout/pushLayoutEngine.jsx
create mode 100644 src/2.7.12/imports/ui/components/layout/service.js
create mode 100644 src/2.7.12/imports/ui/components/layout/utils.js
create mode 100644 src/2.7.12/imports/ui/components/learning-dashboard/service.js
create mode 100644 src/2.7.12/imports/ui/components/legacy/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/legacy/styles.css
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/context/consumer.jsx
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/context/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/context/context.js
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/context/provider.jsx
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/context/withContext.jsx
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/service.js
create mode 100644 src/2.7.12/imports/ui/components/lock-viewers/styles.js
create mode 100644 src/2.7.12/imports/ui/components/media/autoplay-overlay/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/media/autoplay-overlay/styles.js
create mode 100644 src/2.7.12/imports/ui/components/media/service.js
create mode 100644 src/2.7.12/imports/ui/components/meeting-ended/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/meeting-ended/rating/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/meeting-ended/rating/styles.js
create mode 100644 src/2.7.12/imports/ui/components/meeting-ended/service.js
create mode 100644 src/2.7.12/imports/ui/components/meeting-ended/styles.js
create mode 100644 src/2.7.12/imports/ui/components/mobile-app-modal/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/mobile-app-modal/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/mobile-app-modal/styles.js
create mode 100644 src/2.7.12/imports/ui/components/muted-alert/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/muted-alert/styles.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/styles.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/service.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/recording-indicator/styles.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/styles.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/talking-indicator/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/talking-indicator/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/talking-indicator/service.js
create mode 100644 src/2.7.12/imports/ui/components/nav-bar/talking-indicator/styles.js
create mode 100644 src/2.7.12/imports/ui/components/notes/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/notes/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/notes/notes-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/notes/notes-dropdown/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/notes/notes-dropdown/service.js
create mode 100644 src/2.7.12/imports/ui/components/notes/service.js
create mode 100644 src/2.7.12/imports/ui/components/notes/styles.js
create mode 100644 src/2.7.12/imports/ui/components/notifications-bar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/notifications-bar/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/styles.js
create mode 100644 src/2.7.12/imports/ui/components/notifications-bar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/notifications/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/pads/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/pads/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/pads/content/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/pads/content/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/pads/content/styles.js
create mode 100644 src/2.7.12/imports/ui/components/pads/service.js
create mode 100644 src/2.7.12/imports/ui/components/pads/sessions/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/pads/sessions/service.js
create mode 100644 src/2.7.12/imports/ui/components/pads/styles.js
create mode 100644 src/2.7.12/imports/ui/components/poll/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/poll/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/poll/dragAndDrop/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/poll/dragAndDrop/styles.js
create mode 100644 src/2.7.12/imports/ui/components/poll/live-result/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/poll/live-result/service.js
create mode 100644 src/2.7.12/imports/ui/components/poll/live-result/styles.js
create mode 100644 src/2.7.12/imports/ui/components/poll/service.js
create mode 100644 src/2.7.12/imports/ui/components/poll/styles.js
create mode 100644 src/2.7.12/imports/ui/components/polling/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/polling/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/polling/service.js
create mode 100644 src/2.7.12/imports/ui/components/polling/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation-pod/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation-pod/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation-pod/service.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/download-presentation-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/download-presentation-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-area/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-area/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-menu/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-menu/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-menu/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/service.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/holdButton/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/service.js
create mode 100644 src/2.7.12/imports/ui/components/presentation/slide/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/presentation/styles.js
create mode 100644 src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/raisehand-notifier/styles.js
create mode 100644 src/2.7.12/imports/ui/components/recording/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/recording/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/reload-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/reload-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/screenreader-alert/collection.js
create mode 100644 src/2.7.12/imports/ui/components/screenreader-alert/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/screenreader-alert/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/screenreader-alert/service.js
create mode 100644 src/2.7.12/imports/ui/components/screenshare/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/screenshare/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/screenshare/service.js
create mode 100644 src/2.7.12/imports/ui/components/screenshare/styles.js
create mode 100644 src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/service.js
create mode 100644 src/2.7.12/imports/ui/components/settings/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/application/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/settings/submenus/video/styles.js
create mode 100644 src/2.7.12/imports/ui/components/shortcut-help/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/shortcut-help/service.jsx
create mode 100644 src/2.7.12/imports/ui/components/shortcut-help/styles.js
create mode 100644 src/2.7.12/imports/ui/components/sidebar-content/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/sidebar-content/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/sidebar-content/styles.js
create mode 100644 src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/subscriptions/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/text-input/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/text-input/styles.js
create mode 100644 src/2.7.12/imports/ui/components/timer/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/timer/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/timer/indicator/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/timer/indicator/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/timer/indicator/styles.js
create mode 100644 src/2.7.12/imports/ui/components/timer/service.js
create mode 100644 src/2.7.12/imports/ui/components/timer/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-avatar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-avatar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-info/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-info/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-info/service.js
create mode 100644 src/2.7.12/imports/ui/components/user-info/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/service.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js
create mode 100644 src/2.7.12/imports/ui/components/user-reaction/service.js
create mode 100644 src/2.7.12/imports/ui/components/utils/hooks/index.js
create mode 100644 src/2.7.12/imports/ui/components/video-preview/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-preview/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-preview/service.js
create mode 100644 src/2.7.12/imports/ui/components/video-preview/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-preview/virtual-background/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-preview/virtual-background/context.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-preview/virtual-background/service.js
create mode 100644 src/2.7.12/imports/ui/components/video-preview/virtual-background/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/many-users-notify/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/many-users-notify/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/many-users-notify/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/service.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/stream-sorting.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-button/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-button/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-button/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/drag-and-drop/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/styles.js
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/styles.js
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/alert/service.js
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/alert/styles.js
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/guest-policy/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/guest-policy/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/guest-policy/styles.js
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/service.js
create mode 100644 src/2.7.12/imports/ui/components/waiting-users/styles.js
create mode 100644 src/2.7.12/imports/ui/components/wake-lock/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/wake-lock/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/wake-lock/service.js
create mode 100644 src/2.7.12/imports/ui/components/wake-lock/styles.js
create mode 100644 src/2.7.12/imports/ui/components/webcam/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/webcam/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/webcam/drop-areas/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/webcam/drop-areas/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/webcam/drop-areas/styles.js
create mode 100644 src/2.7.12/imports/ui/components/webcam/styles.js
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/cursors/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/cursors/container.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/cursors/cursor/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/cursors/position-label/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/cursors/service.js
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/pan-tool-injector/component.jsx
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/service.js
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/styles.js
create mode 100644 src/2.7.12/imports/ui/components/whiteboard/utils.js
create mode 100644 src/2.7.12/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker.js
create mode 100644 src/2.7.12/imports/ui/services/LocalCollectionSynchronizer/LocalCollectionSynchronizer.js
create mode 100644 src/2.7.12/imports/ui/services/api/index.js
create mode 100644 src/2.7.12/imports/ui/services/audio-manager/error-codes.js
create mode 100644 src/2.7.12/imports/ui/services/audio-manager/index.js
create mode 100644 src/2.7.12/imports/ui/services/auth/index.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/audio-broker.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/broker-base-errors.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/load-play.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/screenshare-broker.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js
create mode 100644 src/2.7.12/imports/ui/services/bbb-webrtc-sfu/utils.js
create mode 100644 src/2.7.12/imports/ui/services/features/index.js
create mode 100644 src/2.7.12/imports/ui/services/locale/index.js
create mode 100644 src/2.7.12/imports/ui/services/meeting-settings/index.js
create mode 100644 src/2.7.12/imports/ui/services/mobile-app/index.js
create mode 100644 src/2.7.12/imports/ui/services/notification/index.js
create mode 100644 src/2.7.12/imports/ui/services/settings/index.js
create mode 100644 src/2.7.12/imports/ui/services/storage/index.js
create mode 100644 src/2.7.12/imports/ui/services/storage/local.js
create mode 100644 src/2.7.12/imports/ui/services/storage/reactive.js
create mode 100644 src/2.7.12/imports/ui/services/storage/session.js
create mode 100644 src/2.7.12/imports/ui/services/subscription-registry/subscriptionRegistry.js
create mode 100644 src/2.7.12/imports/ui/services/unread-messages/index.js
create mode 100644 src/2.7.12/imports/ui/services/users-settings/index.js
create mode 100644 src/2.7.12/imports/ui/services/virtual-background/README.md
create mode 100644 src/2.7.12/imports/ui/services/virtual-background/TimeWorker.js
create mode 100644 src/2.7.12/imports/ui/services/virtual-background/index.js
create mode 100644 src/2.7.12/imports/ui/services/virtual-background/service.js
create mode 100644 src/2.7.12/imports/ui/services/webrtc-base/bbb-video-stream.js
create mode 100644 src/2.7.12/imports/ui/services/webrtc-base/local-pc-loopback.js
create mode 100644 src/2.7.12/imports/ui/services/webrtc-base/peer.js
create mode 100644 src/2.7.12/imports/ui/services/webrtc-base/utils.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/breakpoints.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/general.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/globalStyles.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/palette.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/placeholders.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/scrollable.js
create mode 100644 src/2.7.12/imports/ui/stylesheets/styled-components/typography.js
create mode 100644 src/2.7.12/imports/utils/array-utils.js
create mode 100644 src/2.7.12/imports/utils/browserInfo.js
create mode 100644 src/2.7.12/imports/utils/caseInsensitiveReducer.js
create mode 100644 src/2.7.12/imports/utils/debounce.js
create mode 100644 src/2.7.12/imports/utils/deviceInfo.js
create mode 100644 src/2.7.12/imports/utils/dom-utils.js
create mode 100644 src/2.7.12/imports/utils/fetchStunTurnServers.js
create mode 100644 src/2.7.12/imports/utils/hexInt.js
create mode 100644 src/2.7.12/imports/utils/humanizeSeconds.js
create mode 100644 src/2.7.12/imports/utils/ios-webview-audio-polyfills.js
create mode 100644 src/2.7.12/imports/utils/keyCodes.js
create mode 100644 src/2.7.12/imports/utils/lineEndings.js
create mode 100644 src/2.7.12/imports/utils/logoutRouteHandler.js
create mode 100644 src/2.7.12/imports/utils/media-stream-utils.js
create mode 100644 src/2.7.12/imports/utils/mediaElementPlayRetry.js
create mode 100644 src/2.7.12/imports/utils/mimeTypes.js
create mode 100644 src/2.7.12/imports/utils/regex-weburl.js
create mode 100644 src/2.7.12/imports/utils/sdpUtils.js
create mode 100644 src/2.7.12/imports/utils/slideCalcUtils.js
create mode 100644 src/2.7.12/imports/utils/stats.js
create mode 100644 src/2.7.12/imports/utils/statuses.js
create mode 100644 src/2.7.12/imports/utils/string-utils.js
create mode 100644 src/2.7.12/imports/utils/throttle.js
create mode 100644 src/2.7.12/jsconfig.json
create mode 100644 src/2.7.12/package-lock.json
create mode 100644 src/2.7.12/package.json
create mode 100644 src/2.7.12/private/config/fallbackLocales.json
create mode 100644 src/2.7.12/private/config/settings.yml
create mode 100644 src/2.7.12/private/static/guest-wait/guest-wait.html
create mode 100644 src/2.7.12/public/compatibility/sip.js
create mode 100644 src/2.7.12/public/compatibility/tflite-simd.js
create mode 100644 src/2.7.12/public/compatibility/tflite.js
create mode 100644 src/2.7.12/public/fonts/BbbIcons/bbb-icons.woff
create mode 100644 src/2.7.12/public/fonts/BbbIcons/bbb-icons.woff2
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/OFL.txt
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Bold.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-BoldItalic.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Italic.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Light.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-LightItalic.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Regular.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Semibold.woff
create mode 100644 src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff
create mode 100644 src/2.7.12/public/locales/af.json
create mode 100644 src/2.7.12/public/locales/ar.json
create mode 100644 src/2.7.12/public/locales/az.json
create mode 100644 src/2.7.12/public/locales/bg_BG.json
create mode 100644 src/2.7.12/public/locales/bn.json
create mode 100644 src/2.7.12/public/locales/ca.json
create mode 100644 src/2.7.12/public/locales/cs_CZ.json
create mode 100644 src/2.7.12/public/locales/da.json
create mode 100644 src/2.7.12/public/locales/de.json
create mode 100644 src/2.7.12/public/locales/dv.json
create mode 100644 src/2.7.12/public/locales/el_GR.json
create mode 100644 src/2.7.12/public/locales/en.json
create mode 100644 src/2.7.12/public/locales/eo.json
create mode 100644 src/2.7.12/public/locales/es.json
create mode 100644 src/2.7.12/public/locales/es_419.json
create mode 100644 src/2.7.12/public/locales/es_ES.json
create mode 100644 src/2.7.12/public/locales/es_MX.json
create mode 100644 src/2.7.12/public/locales/et.json
create mode 100644 src/2.7.12/public/locales/eu.json
create mode 100644 src/2.7.12/public/locales/fa_IR.json
create mode 100644 src/2.7.12/public/locales/fi.json
create mode 100644 src/2.7.12/public/locales/fr.json
create mode 100644 src/2.7.12/public/locales/gl.json
create mode 100644 src/2.7.12/public/locales/he.json
create mode 100644 src/2.7.12/public/locales/hi_IN.json
create mode 100644 src/2.7.12/public/locales/hr.json
create mode 100644 src/2.7.12/public/locales/hu_HU.json
create mode 100644 src/2.7.12/public/locales/hy.json
create mode 100644 src/2.7.12/public/locales/id.json
create mode 100644 src/2.7.12/public/locales/it_IT.json
create mode 100644 src/2.7.12/public/locales/ja.json
create mode 100644 src/2.7.12/public/locales/ka.json
create mode 100644 src/2.7.12/public/locales/km.json
create mode 100644 src/2.7.12/public/locales/kn.json
create mode 100644 src/2.7.12/public/locales/ko_KR.json
create mode 100644 src/2.7.12/public/locales/lo_LA.json
create mode 100644 src/2.7.12/public/locales/lt_LT.json
create mode 100644 src/2.7.12/public/locales/lv.json
create mode 100644 src/2.7.12/public/locales/ml.json
create mode 100644 src/2.7.12/public/locales/mn_MN.json
create mode 100644 src/2.7.12/public/locales/nb_NO.json
create mode 100644 src/2.7.12/public/locales/nl.json
create mode 100644 src/2.7.12/public/locales/oc.json
create mode 100644 src/2.7.12/public/locales/pl_PL.json
create mode 100644 src/2.7.12/public/locales/pt.json
create mode 100644 src/2.7.12/public/locales/pt_BR.json
create mode 100644 src/2.7.12/public/locales/ro_RO.json
create mode 100644 src/2.7.12/public/locales/ru.json
create mode 100644 src/2.7.12/public/locales/sk_SK.json
create mode 100644 src/2.7.12/public/locales/sl.json
create mode 100644 src/2.7.12/public/locales/sr.json
create mode 100644 src/2.7.12/public/locales/sv_SE.json
create mode 100644 src/2.7.12/public/locales/ta.json
create mode 100644 src/2.7.12/public/locales/te.json
create mode 100644 src/2.7.12/public/locales/th.json
create mode 100644 src/2.7.12/public/locales/tr.json
create mode 100644 src/2.7.12/public/locales/tr_TR.json
create mode 100644 src/2.7.12/public/locales/uk_UA.json
create mode 100644 src/2.7.12/public/locales/vi.json
create mode 100644 src/2.7.12/public/locales/vi_VN.json
create mode 100644 src/2.7.12/public/locales/zh_CN.json
create mode 100644 src/2.7.12/public/locales/zh_TW.json
create mode 100644 src/2.7.12/public/resources/images/allow-mic.png
create mode 100644 src/2.7.12/public/resources/images/avatar.png
create mode 100644 src/2.7.12/public/resources/images/layouts/custom.svg
create mode 100644 src/2.7.12/public/resources/images/layouts/presentationFocus.svg
create mode 100644 src/2.7.12/public/resources/images/layouts/smart.svg
create mode 100644 src/2.7.12/public/resources/images/layouts/videoFocus.svg
create mode 100644 src/2.7.12/public/resources/images/video-menu/icon-swap.svg
create mode 100644 src/2.7.12/public/resources/images/video-menu/icon-webcam-off.svg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/architecture.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/board.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/brickwall.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/coffeeshop.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/home.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/thumbnails/blur.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/thumbnails/board.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/thumbnails/coffeeshop.jpg
create mode 100644 src/2.7.12/public/resources/images/virtual-backgrounds/thumbnails/home.jpg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/ellipse.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/ellipse.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/line.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/line.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/pan-closed.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/pan-closed.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/pan.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/pan.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/pencil.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/pencil.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/square.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/square.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/text.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/text.svg
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/triangle.png
create mode 100644 src/2.7.12/public/resources/images/whiteboard-cursor/triangle.svg
create mode 100644 src/2.7.12/public/resources/sounds/CalmMusic.mp3
create mode 100644 src/2.7.12/public/resources/sounds/LeftCall.mp3
create mode 100644 src/2.7.12/public/resources/sounds/Poll.mp3
create mode 100644 src/2.7.12/public/resources/sounds/RelaxingMusic.mp3
create mode 100644 src/2.7.12/public/resources/sounds/ScreenshareOff.mp3
create mode 100644 src/2.7.12/public/resources/sounds/alarm.mp3
create mode 100644 src/2.7.12/public/resources/sounds/aristocratDrums.mp3
create mode 100644 src/2.7.12/public/resources/sounds/audioSample.mp3
create mode 100644 src/2.7.12/public/resources/sounds/bbb-handRaise.mp3
create mode 100644 src/2.7.12/public/resources/sounds/doorbell.mp3
create mode 100644 src/2.7.12/public/resources/sounds/notify.mp3
create mode 100644 src/2.7.12/public/resources/sounds/userJoin.mp3
create mode 100644 src/2.7.12/public/resources/tfmodels/segm_full_v679.tflite
create mode 100644 src/2.7.12/public/resources/tfmodels/segm_lite_v681.tflite
create mode 100644 src/2.7.12/public/svgs/bbb_audio_icon.svg
create mode 100644 src/2.7.12/public/svgs/bbb_headphones_icon.svg
create mode 100644 src/2.7.12/public/wasm/tflite-simd.wasm
create mode 100644 src/2.7.12/public/wasm/tflite.wasm
create mode 100644 src/2.7.12/run-dev.sh
create mode 100644 src/2.7.12/server/main.js
create mode 100644 src/2.7.12/test-html5.sh
diff --git a/src/2.7.12/.eslintignore b/src/2.7.12/.eslintignore
new file mode 100644
index 00000000..0912b790
--- /dev/null
+++ b/src/2.7.12/.eslintignore
@@ -0,0 +1,2 @@
+Gruntfile.js
+public/compatibility/*
diff --git a/src/2.7.12/.eslintrc.js b/src/2.7.12/.eslintrc.js
new file mode 100644
index 00000000..b93b3575
--- /dev/null
+++ b/src/2.7.12/.eslintrc.js
@@ -0,0 +1,32 @@
+module.exports = {
+ extends: 'airbnb',
+ parserOptions: {
+ ecmaVersion: 2020,
+ },
+ plugins: [
+ 'react',
+ 'jsx-a11y',
+ 'import',
+ ],
+ env: {
+ es6: true,
+ node: true,
+ browser: true,
+ meteor: true,
+ jasmine: true,
+ },
+ rules: {
+ 'no-underscore-dangle': 0,
+ 'import/extensions': [2, 'never'],
+ 'import/no-absolute-path': 0,
+ 'import/no-unresolved': 0,
+ 'import/no-extraneous-dependencies': 1,
+ 'react/prop-types': 1,
+ 'jsx-a11y/no-access-key': 0,
+ 'react/jsx-props-no-spreading': 'off',
+ 'max-classes-per-file': ['error', 2],
+ },
+ globals: {
+ browser: 'writable',
+ },
+};
diff --git a/src/2.7.12/.gitignore b/src/2.7.12/.gitignore
new file mode 100644
index 00000000..bbb197c9
--- /dev/null
+++ b/src/2.7.12/.gitignore
@@ -0,0 +1,9 @@
+.dbshell
+npm-debug.log
+node_modules/
+.meteor/dev_bundle
+public/locales/de_DE.json
+public/locales/ja_JP.json
+public/files
+
+
diff --git a/src/2.7.12/.meteor/.finished-upgraders b/src/2.7.12/.meteor/.finished-upgraders
new file mode 100644
index 00000000..c07b6ff7
--- /dev/null
+++ b/src/2.7.12/.meteor/.finished-upgraders
@@ -0,0 +1,19 @@
+# This file contains information which helps Meteor properly upgrade your
+# app when you run 'meteor update'. You should check it into version control
+# with your project.
+
+notices-for-0.9.0
+notices-for-0.9.1
+0.9.4-platform-file
+notices-for-facebook-graph-api-2
+1.2.0-standard-minifiers-package
+1.2.0-meteor-platform-split
+1.2.0-cordova-changes
+1.2.0-breaking-changes
+1.3.0-split-minifiers-package
+1.4.0-remove-old-dev-bundle-link
+1.4.1-add-shell-server-package
+1.4.3-split-account-service-packages
+1.5-add-dynamic-import-package
+1.7-split-underscore-from-meteor-base
+1.8.3-split-jquery-from-blaze
diff --git a/src/2.7.12/.meteor/.gitignore b/src/2.7.12/.meteor/.gitignore
new file mode 100644
index 00000000..501f92e4
--- /dev/null
+++ b/src/2.7.12/.meteor/.gitignore
@@ -0,0 +1,2 @@
+dev_bundle
+local
diff --git a/src/2.7.12/.meteor/.id b/src/2.7.12/.meteor/.id
new file mode 100644
index 00000000..350c3adf
--- /dev/null
+++ b/src/2.7.12/.meteor/.id
@@ -0,0 +1,7 @@
+# This file contains a token that is unique to your project.
+# Check it into your repository along with the rest of this directory.
+# It can be used for purposes such as:
+# - ensuring you don't accidentally deploy one app on top of another
+# - providing package authors with aggregated statistics
+
+jrnkwdjvicqgy6gtl8
diff --git a/src/2.7.12/.meteor/cordova-plugins b/src/2.7.12/.meteor/cordova-plugins
new file mode 100644
index 00000000..e69de29b
diff --git a/src/2.7.12/.meteor/packages b/src/2.7.12/.meteor/packages
new file mode 100644
index 00000000..ecee23ab
--- /dev/null
+++ b/src/2.7.12/.meteor/packages
@@ -0,0 +1,25 @@
+# Meteor packages used by this project, one per line.
+#
+# 'meteor add' and 'meteor remove' will edit this file for you,
+# but you can also edit it by hand.
+
+meteor-base@1.5.1
+mobile-experience@1.1.0
+mongo@1.16.7
+reactive-var@1.0.12
+
+standard-minifier-css@1.9.2
+standard-minifier-js@2.8.1
+es5-shim@4.8.0
+ecmascript@0.16.7
+shell-server@0.5.0
+
+static-html@1.3.2
+react-meteor-data
+session@1.2.1
+tracker@1.3.2
+check@1.3.2
+
+rocketchat:streamer
+meteortesting:mocha
+lmieulet:meteor-coverage
diff --git a/src/2.7.12/.meteor/platforms b/src/2.7.12/.meteor/platforms
new file mode 100644
index 00000000..efeba1b5
--- /dev/null
+++ b/src/2.7.12/.meteor/platforms
@@ -0,0 +1,2 @@
+server
+browser
diff --git a/src/2.7.12/.meteor/release b/src/2.7.12/.meteor/release
new file mode 100644
index 00000000..7c40889d
--- /dev/null
+++ b/src/2.7.12/.meteor/release
@@ -0,0 +1 @@
+METEOR@2.13
diff --git a/src/2.7.12/.meteor/versions b/src/2.7.12/.meteor/versions
new file mode 100644
index 00000000..4cefbe6d
--- /dev/null
+++ b/src/2.7.12/.meteor/versions
@@ -0,0 +1,78 @@
+allow-deny@1.1.1
+autoupdate@1.8.0
+babel-compiler@7.10.4
+babel-runtime@1.5.1
+base64@1.0.12
+binary-heap@1.0.11
+blaze-tools@1.1.3
+boilerplate-generator@1.7.1
+caching-compiler@1.2.2
+caching-html-compiler@1.2.1
+callback-hook@1.5.1
+check@1.3.2
+ddp@1.4.1
+ddp-client@2.6.1
+ddp-common@1.4.0
+ddp-server@2.6.2
+diff-sequence@1.1.2
+dynamic-import@0.7.3
+ecmascript@0.16.7
+ecmascript-runtime@0.8.1
+ecmascript-runtime-client@0.12.1
+ecmascript-runtime-server@0.11.0
+ejson@1.1.3
+es5-shim@4.8.0
+fetch@0.1.3
+geojson-utils@1.0.11
+hot-code-push@1.0.4
+html-tools@1.1.3
+htmljs@1.1.1
+http@2.0.0
+id-map@1.1.1
+inter-process-messaging@0.1.1
+launch-screen@1.3.0
+lmieulet:meteor-coverage@4.1.0
+logging@1.3.2
+meteor@1.11.3
+meteor-base@1.5.1
+meteortesting:browser-tests@1.3.5
+meteortesting:mocha@2.0.3
+meteortesting:mocha-core@8.1.2
+minifier-css@1.6.4
+minifier-js@2.7.5
+minimongo@1.9.3
+mobile-experience@1.1.0
+mobile-status-bar@1.1.0
+modern-browsers@0.1.9
+modules@0.19.0
+modules-runtime@0.13.1
+mongo@1.16.7
+mongo-decimal@0.1.3
+mongo-dev-server@1.1.0
+mongo-id@1.0.8
+npm-mongo@4.16.0
+ordered-dict@1.1.0
+promise@0.12.2
+random@1.2.1
+react-fast-refresh@0.2.7
+react-meteor-data@2.5.1
+reactive-dict@1.3.1
+reactive-var@1.0.12
+reload@1.3.1
+retry@1.1.0
+rocketchat:streamer@1.1.0
+routepolicy@1.1.1
+session@1.2.1
+shell-server@0.5.0
+socket-stream-client@0.5.1
+spacebars-compiler@1.3.1
+standard-minifier-css@1.9.2
+standard-minifier-js@2.8.1
+static-html@1.3.2
+templating-tools@1.2.2
+tracker@1.3.2
+typescript@4.9.4
+underscore@1.0.13
+url@1.3.2
+webapp@1.13.5
+webapp-hashing@1.1.1
diff --git a/src/2.7.12/client/collection-mirror-initializer.js b/src/2.7.12/client/collection-mirror-initializer.js
new file mode 100644
index 00000000..6d214f6c
--- /dev/null
+++ b/src/2.7.12/client/collection-mirror-initializer.js
@@ -0,0 +1,79 @@
+import AbstractCollection from '/imports/ui/services/LocalCollectionSynchronizer/LocalCollectionSynchronizer';
+
+// Collections
+import Presentations from '/imports/api/presentations';
+import PresentationPods from '/imports/api/presentation-pods';
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+import Screenshare from '/imports/api/screenshare';
+import UserInfos from '/imports/api/users-infos';
+import Polls, { CurrentPoll } from '/imports/api/polls';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import UserSettings from '/imports/api/users-settings';
+import VideoStreams from '/imports/api/video-streams';
+import VoiceUsers from '/imports/api/voice-users';
+import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user';
+import GroupChat from '/imports/api/group-chat';
+import ConnectionStatus from '/imports/api/connection-status';
+import Captions from '/imports/api/captions';
+import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
+import AuthTokenValidation from '/imports/api/auth-token-validation';
+import Annotations from '/imports/api/annotations';
+import Breakouts from '/imports/api/breakouts';
+import BreakoutsHistory from '/imports/api/breakouts-history';
+import guestUsers from '/imports/api/guest-users';
+import Meetings, { RecordMeetings, ExternalVideoMeetings, MeetingTimeRemaining, Notifications } from '/imports/api/meetings';
+import { UsersTyping } from '/imports/api/group-chat-msg';
+import Users, { CurrentUser } from '/imports/api/users';
+import { Slides, SlidePositions } from '/imports/api/slides';
+
+// Custom Publishers
+export const localCollectionRegistry = {
+ localCurrentPollSync: new AbstractCollection(CurrentPoll, CurrentPoll),
+ localCurrentUserSync: new AbstractCollection(CurrentUser, CurrentUser),
+ localSlidesSync: new AbstractCollection(Slides, Slides),
+ localSlidePositionsSync: new AbstractCollection(SlidePositions, SlidePositions),
+ localPollsSync: new AbstractCollection(Polls, Polls),
+ localPresentationsSync: new AbstractCollection(Presentations, Presentations),
+ localPresentationPodsSync: new AbstractCollection(PresentationPods, PresentationPods),
+ localPresentationUploadTokenSync: new AbstractCollection(
+ PresentationUploadToken,
+ PresentationUploadToken,
+ ),
+ localScreenshareSync: new AbstractCollection(Screenshare, Screenshare),
+ localUserInfosSync: new AbstractCollection(UserInfos, UserInfos),
+ localUsersPersistentDataSync: new AbstractCollection(UsersPersistentData, UsersPersistentData),
+ localUserSettingsSync: new AbstractCollection(UserSettings, UserSettings),
+ localVideoStreamsSync: new AbstractCollection(VideoStreams, VideoStreams),
+ localVoiceUsersSync: new AbstractCollection(VoiceUsers, VoiceUsers),
+ localWhiteboardMultiUserSync: new AbstractCollection(WhiteboardMultiUser, WhiteboardMultiUser),
+ localGroupChatSync: new AbstractCollection(GroupChat, GroupChat),
+ localConnectionStatusSync: new AbstractCollection(ConnectionStatus, ConnectionStatus),
+ localCaptionsSync: new AbstractCollection(Captions, Captions),
+ localPadsSync: new AbstractCollection(Pads, Pads),
+ localPadsSessionsSync: new AbstractCollection(PadsSessions, PadsSessions),
+ localPadsUpdatesSync: new AbstractCollection(PadsUpdates, PadsUpdates),
+ localAuthTokenValidationSync: new AbstractCollection(AuthTokenValidation, AuthTokenValidation),
+ localAnnotationsSync: new AbstractCollection(Annotations, Annotations),
+ localRecordMeetingsSync: new AbstractCollection(RecordMeetings, RecordMeetings),
+ localExternalVideoMeetingsSync: new AbstractCollection(
+ ExternalVideoMeetings,
+ ExternalVideoMeetings,
+ ),
+ localMeetingTimeRemainingSync: new AbstractCollection(MeetingTimeRemaining, MeetingTimeRemaining),
+ localUsersTypingSync: new AbstractCollection(UsersTyping, UsersTyping),
+ localBreakoutsSync: new AbstractCollection(Breakouts, Breakouts),
+ localBreakoutsHistorySync: new AbstractCollection(BreakoutsHistory, BreakoutsHistory),
+ localGuestUsersSync: new AbstractCollection(guestUsers, guestUsers),
+ localMeetingsSync: new AbstractCollection(Meetings, Meetings),
+ localUsersSync: new AbstractCollection(Users, Users),
+ localNotificationsSync: new AbstractCollection(Notifications, Notifications),
+};
+
+const collectionMirrorInitializer = () => {
+ Object.values(localCollectionRegistry).forEach((localCollection) => {
+ localCollection.setupListeners();
+ });
+};
+
+export default collectionMirrorInitializer;
+// const localUsersSync = new AbstractCollection(CurrentUser, CurrentUser);
diff --git a/src/2.7.12/client/legacy.jsx b/src/2.7.12/client/legacy.jsx
new file mode 100644
index 00000000..a583afff
--- /dev/null
+++ b/src/2.7.12/client/legacy.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Meteor } from 'meteor/meteor';
+import { render } from 'react-dom';
+import Legacy from '/imports/ui/components/legacy/component';
+
+// This class is the start of the content loaded on legacy (unsupported) browsers.
+// What is included here needs to be minimal and carefully considered because some
+// things can't be polyfilled.
+
+Meteor.startup(() => {
+ render(
+ ,
+ document.getElementById('app'),
+ );
+});
diff --git a/src/2.7.12/client/main.html b/src/2.7.12/client/main.html
new file mode 100644
index 00000000..ee60144b
--- /dev/null
+++ b/src/2.7.12/client/main.html
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/2.7.12/client/main.jsx b/src/2.7.12/client/main.jsx
new file mode 100644
index 00000000..09884033
--- /dev/null
+++ b/src/2.7.12/client/main.jsx
@@ -0,0 +1,100 @@
+/*
+ BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
+
+ Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
+
+ This program is free software; you can redistribute it and/or modify it under the
+ terms of the GNU Lesser General Public License as published by the Free Software
+ Foundation; either version 3.0 of the License, or (at your option) any later
+ version.
+
+ BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
+ PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+
+ You should have received a copy of the GNU Lesser General Public License along
+ with BigBlueButton; if not, see .
+*/
+/* eslint no-unused-vars: 0 */
+
+import React from 'react';
+import { Meteor } from 'meteor/meteor';
+import { render } from 'react-dom';
+import logger from '/imports/startup/client/logger';
+import '/imports/ui/services/mobile-app';
+import Base from '/imports/startup/client/base';
+import JoinHandler from '/imports/ui/components/join-handler/component';
+import AuthenticatedHandler from '/imports/ui/components/authenticated-handler/component';
+import Subscriptions from '/imports/ui/components/subscriptions/component';
+import IntlStartup from '/imports/startup/client/intl';
+import ContextProviders from '/imports/ui/components/context-providers/component';
+import ChatAdapter from '/imports/ui/components/components-data/chat-context/adapter';
+import UsersAdapter from '/imports/ui/components/components-data/users-context/adapter';
+import GroupChatAdapter from '/imports/ui/components/components-data/group-chat-context/adapter';
+import { liveDataEventBrokerInitializer } from '/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker';
+// The adapter import is "unused" as far as static code is concerned, but it
+// needs to here to override global prototypes. So: don't remove it - prlanzarin 25 Apr 2022
+import adapter from 'webrtc-adapter';
+
+import collectionMirrorInitializer from './collection-mirror-initializer';
+
+import('/imports/api/audio/client/bridge/bridge-whitelist').catch(() => {
+ // bridge loading
+});
+
+const { disableWebsocketFallback } = Meteor.settings.public.app;
+
+if (disableWebsocketFallback) {
+ Meteor.connection._stream._sockjsProtocolsWhitelist = function () { return ['websocket']; };
+
+ Meteor.disconnect();
+ Meteor.reconnect();
+}
+
+collectionMirrorInitializer();
+liveDataEventBrokerInitializer();
+
+Meteor.startup(() => {
+ // Logs all uncaught exceptions to the client logger
+ window.addEventListener('error', (e) => {
+ let message = e.message || e.error.toString();
+
+ // Chrome will add on "Uncaught" to the start of the message for some reason. This
+ // will strip that so the errors can hopefully be grouped better.
+ if (message) message = message.replace(/^Uncaught/, '').trim();
+
+ let { stack } = e.error;
+
+ // Checks if stack includes the message, if not add the two together.
+ if (!stack.includes(message)) {
+ stack = `${message}\n${stack}`;
+ }
+ logger.error({
+ logCode: 'startup_error',
+ extraInfo: {
+ stackTrace: stack,
+ },
+ }, message);
+ });
+
+ // TODO make this a Promise
+ render(
+
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ,
+ document.getElementById('app'),
+ );
+});
diff --git a/src/2.7.12/client/stylesheets/bbb-icons.css b/src/2.7.12/client/stylesheets/bbb-icons.css
new file mode 100644
index 00000000..6c9a8b5a
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/bbb-icons.css
@@ -0,0 +1,356 @@
+[class^="icon-bbb-"], [class*=" icon-bbb-"] {
+ /* use !important to prevent issues with browser extensions that change fonts */
+ font-family: 'bbb-icons' !important;
+ speak: none;
+ position: relative;
+ /*top: 1px;*/
+ display: inline-block;
+ font-style: normal;
+ font-weight: 400;
+ line-height: 1;
+ -webkit-font-smoothing: antialiased;
+ width: 1em;
+ text-align: center;
+ vertical-align: middle;
+
+ /* Better Font Rendering =========== */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-bbb-screenshare-fullscreen:before {
+ content: "\e92a";
+}
+.icon-bbb-screenshare-close-fullscreen:before {
+ content: "\e935";
+}
+.icon-bbb-alert:before {
+ content: "\e958";
+}
+.icon-bbb-mute:before {
+ content: "\e932";
+}
+.icon-bbb-unmute:before {
+ content: "\e931";
+}
+.icon-bbb-file:before {
+ content: "\e92e";
+}
+.icon-bbb-upload:before {
+ content: "\e92f";
+}
+.icon-bbb-fullscreen:before {
+ content: "\e92a";
+}
+.icon-bbb-exit_fullscreen:before {
+ content: "\e935";
+}
+.icon-bbb-settings:before {
+ content: "\e92b";
+}
+.icon-bbb-fit_to_screen:before {
+ content: "\e929";
+}
+.icon-bbb-line_tool:before {
+ content: "\e91c";
+}
+.icon-bbb-circle_tool:before {
+ content: "\e91d";
+}
+.icon-bbb-triangle_tool:before {
+ content: "\e91e";
+}
+.icon-bbb-rectangle_tool:before {
+ content: "\e91f";
+}
+.icon-bbb-text_tool:before {
+ content: "\e920";
+}
+.icon-bbb-plus:before {
+ content: "\e921";
+}
+.icon-bbb-fit_to_width:before {
+ content: "\e922";
+}
+.icon-bbb-undo:before {
+ content: "\e924";
+}
+.icon-bbb-pen_tool:before {
+ content: "\e925";
+}
+.icon-bbb-lock:before {
+ content: "\e926";
+}
+.icon-bbb-polling:before {
+ content: "\e927";
+}
+.icon-bbb-desktop:before {
+ content: "\e928";
+}
+.icon-bbb-logout:before {
+ content: "\e900";
+}
+.icon-bbb-video:before {
+ content: "\e930";
+}
+.icon-bbb-elipsis:before {
+ content: "\e902";
+}
+.icon-bbb-more:before {
+ content: "\e902";
+ display: inline-block;
+ transform: rotate(90deg);
+}
+.icon-bbb-promote:before {
+ content: "\e903";
+}
+.icon-bbb-application:before {
+ content: "\e901";
+}
+.icon-bbb-video_off:before {
+ content: "\e904";
+}
+.icon-bbb-user:before {
+ content: "\e905";
+}
+.icon-bbb-up_arrow:before {
+ content: "\e906";
+}
+.icon-bbb-right_arrow:before {
+ content: "\e90a";
+}
+.icon-bbb-presentation:before {
+ content: "\e90b";
+}
+.icon-bbb-listen:before {
+ content: "\e90c";
+}
+.icon-bbb-left_arrow:before {
+ content: "\e90d";
+}
+.icon-bbb-group_chat:before {
+ content: "\e910";
+}
+.icon-bbb-close:before {
+ content: "\e912";
+}
+.icon-bbb-clear_status:before {
+ content: "\e913";
+}
+.icon-bbb-circle:before {
+ content: "\e914";
+}
+.icon-bbb-substract:before {
+ content: "\e915";
+}
+.icon-bbb-circle_close:before {
+ content: "\e916";
+}
+.icon-bbb-add:before {
+ content: "\e917";
+}
+.icon-bbb-check:before {
+ content: "\e918";
+}
+.icon-bbb-chat:before {
+ content: "\e919";
+}
+.icon-bbb-audio_on:before {
+ content: "\e91a";
+}
+.icon-bbb-audio_off:before {
+ content: "\e91b";
+}
+
+.icon-bbb-volume_down:before {
+ content: "\e947";
+}
+
+.icon-bbb-volume_mute:before {
+ content: "\e947";
+}
+
+.icon-bbb-volume_off:before {
+ content: "\e95f";
+}
+
+.icon-bbb-volume_up:before {
+ content: "\e947";
+}
+
+.icon-bbb-volume_level_1:before {
+ content: "\e960";
+}
+
+.icon-bbb-volume_level_2:before {
+ content: "\e961";
+}
+
+.icon-bbb-volume_level_3:before {
+ content: "\e962";
+}
+
+.icon-bbb-no_audio:before {
+ content: "\e963";
+}
+
+
+/* Aliases for emoji status */
+.icon-bbb-time:before {
+ content: "\e908";
+}
+.icon-bbb-hand:before {
+ content: "\e90f";
+}
+.icon-bbb-undecided:before {
+ content: "\e907";
+}
+.icon-bbb-happy:before {
+ content: "\e90e";
+}
+.icon-bbb-sad:before {
+ content: "\e909";
+}
+.icon-bbb-confused:before {
+ content: "\e911";
+}
+.icon-bbb-applause:before {
+ content: "\e923";
+}
+.icon-bbb-thumbs_up:before {
+ content: "\e92d";
+}
+.icon-bbb-thumbs_down:before {
+ content: "\e92c";
+}
+.icon-bbb-send:before {
+ content: "\e934";
+}
+.icon-bbb-about:before {
+ content: "\e933";
+}
+
+.icon-bbb-delete:before {
+ content: "\e936";
+}
+.icon-bbb-unmute_filled:before {
+ content: "\e937";
+}
+.icon-bbb-mute_filled:before {
+ content: "\e938";
+}
+.icon-bbb-listen_filled:before {
+ content: "\e939";
+}
+.icon-bbb-template_upload:before {
+ content: "\e93a";
+}
+.icon-bbb-template_download:before {
+ content: "\e93b";
+}
+.icon-bbb-download:before {
+ content: "\e93c";
+}
+.icon-bbb-multi_whiteboard:before {
+ content: "\e93d";
+}
+.icon-bbb-whiteboard:before {
+ content: "\e93e";
+}
+.icon-bbb-rooms:before {
+ content: "\e93f";
+}
+.icon-bbb-unlock:before {
+ content: "\e940";
+}
+.icon-bbb-record:before {
+ content: "\e941";
+}
+.icon-bbb-network:before {
+ content: "\e942";
+}
+.icon-bbb-redo:before {
+ content: "\e943";
+}
+.icon-bbb-thumbs_down_filled:before {
+ content: "\e944";
+}
+.icon-bbb-thumbs_up_filled:before {
+ content: "\e945";
+}
+.icon-bbb-checkmark:before {
+ content: "\e946";
+}
+.icon-bbb-speak_louder:before {
+ content: "\e947";
+}
+.icon-bbb-help:before {
+ content: "\e948";
+}
+.icon-bbb-refresh:before {
+ content: "\e949";
+}
+.icon-bbb-copy:before {
+ content: "\e94a";
+}
+.icon-bbb-shortcuts:before {
+ content: "\e94b";
+}
+.icon-bbb-warning:before {
+ content: "\e94c";
+}
+.icon-bbb-pointer:before {
+ content: "\e950";
+}
+.icon-bbb-star_filled:before {
+ content: "\e952";
+}
+.icon-bbb-desktop_off:before {
+ content: "\e953";
+}
+.icon-bbb-minus:before {
+ content: "\e954";
+}
+.icon-bbb-download-off:before {
+ content: "\e955";
+}
+.icon-bbb-popout_window:before {
+ content: "\e956";
+}
+.icon-bbb-closed_caption:before {
+ content: "\e957";
+}
+/*\e958 -> icon-bbb-alert*/
+.icon-bbb-palm_rejection:before {
+ content: "\e959";
+}
+.icon-bbb-no_palm_rejection:before {
+ content: "\e95a";
+}
+.icon-bbb-device_list_selector:before {
+ content: "\e95b";
+}
+.icon-bbb-presentation_off:before {
+ content: "\e95c";
+}
+.icon-bbb-external-video:before {
+ content: "\e95d";
+}
+.icon-bbb-external-video_off:before {
+ content: "\e95e";
+}
+.icon-bbb-pin-video_on:before {
+ content: "\e964";
+}
+.icon-bbb-pin-video_off:before {
+ content: "\e965";
+}
+.icon-bbb-closed_caption_stop:before {
+ content: "\e966";
+}
+.icon-bbb-link:before {
+ content: "\e967";
+}
+.icon-bbb-manage_layout:before {
+ content: "\e968";
+}
diff --git a/src/2.7.12/client/stylesheets/fontSizing.css b/src/2.7.12/client/stylesheets/fontSizing.css
new file mode 100644
index 00000000..baab39f9
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/fontSizing.css
@@ -0,0 +1,19 @@
+.extraSmallFont {
+ font-size: 0.5rem;
+}
+
+.smallFont {
+ font-size: 1.0rem;
+}
+
+.mediumFont {
+ font-size: 1.5rem;
+}
+
+.largeFont {
+ font-size: 2.0rem;
+}
+
+.extraLargeFont {
+ font-size: 3.0rem;
+}
diff --git a/src/2.7.12/client/stylesheets/fonts.css b/src/2.7.12/client/stylesheets/fonts.css
new file mode 100644
index 00000000..11f9bce5
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/fonts.css
@@ -0,0 +1,216 @@
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 300;
+ src: local('Source Sans Pro Light'), local('SourceSansPro-Light'),
+ url('fonts/SourceSansPro/SourceSansPro-Light.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 300;
+ src: local('Source Sans Pro Light'), local('SourceSansPro-Light'),
+ url('fonts/SourceSansPro/SourceSansPro-Light.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 300;
+ src: local('Source Sans Pro Light'), local('SourceSansPro-Light'),
+ url('fonts/SourceSansPro/SourceSansPro-Light.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Source Sans Pro'), local('SourceSansPro-Regular'),
+ url('fonts/SourceSansPro/SourceSansPro-Regular.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Source Sans Pro'), local('SourceSansPro-Regular'),
+ url('fonts/SourceSansPro/SourceSansPro-Regular.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Source Sans Pro'), local('SourceSansPro-Regular'),
+ url('fonts/SourceSansPro/SourceSansPro-Regular.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 600;
+ src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'),
+ url('fonts/SourceSansPro/SourceSansPro-Semibold.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 600;
+ src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'),
+ url('fonts/SourceSansPro/SourceSansPro-Semibold.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 600;
+ src: local('Source Sans Pro Semibold'), local('SourceSansPro-Semibold'),
+ url('fonts/SourceSansPro/SourceSansPro-Semibold.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 700;
+ src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'),
+ url('fonts/SourceSansPro/SourceSansPro-Bold.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 700;
+ src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'),
+ url('fonts/SourceSansPro/SourceSansPro-Bold.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: normal;
+ font-weight: 700;
+ src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'),
+ url('fonts/SourceSansPro/SourceSansPro-Bold.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 300;
+ src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightIt'),
+ url('fonts/SourceSansPro/SourceSansPro-LightItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 300;
+ src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightIt'),
+ url('fonts/SourceSansPro/SourceSansPro-LightItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 300;
+ src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightIt'),
+ url('fonts/SourceSansPro/SourceSansPro-LightItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 400;
+ src: local('Source Sans Pro Italic'), local('SourceSansPro-It'),
+ url('fonts/SourceSansPro/SourceSansPro-Italic.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 400;
+ src: local('Source Sans Pro Italic'), local('SourceSansPro-It'),
+ url('fonts/SourceSansPro/SourceSansPro-Italic.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 400;
+ src: local('Source Sans Pro Italic'), local('SourceSansPro-It'),
+ url('fonts/SourceSansPro/SourceSansPro-Italic.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 600;
+ src: local('Source Sans Pro Semibold Italic'), local('SourceSansPro-SemiboldIt'),
+ url('fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 600;
+ src: local('Source Sans Pro Semibold Italic'), local('SourceSansPro-SemiboldIt'),
+ url('fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 600;
+ src: local('Source Sans Pro Semibold Italic'), local('SourceSansPro-SemiboldIt'),
+ url('fonts/SourceSansPro/SourceSansPro-SemiboldItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 700;
+ src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldIt'),
+ url('fonts/SourceSansPro/SourceSansPro-BoldItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0102-0103, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 700;
+ src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldIt'),
+ url('fonts/SourceSansPro/SourceSansPro-BoldItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Source Sans Pro';
+ font-style: italic;
+ font-weight: 700;
+ src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldIt'),
+ url('fonts/SourceSansPro/SourceSansPro-BoldItalic.woff?v=VERSION') format('woff');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
diff --git a/src/2.7.12/client/stylesheets/modals.css b/src/2.7.12/client/stylesheets/modals.css
new file mode 100644
index 00000000..7c072ec4
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/modals.css
@@ -0,0 +1,76 @@
+.ReactModal__Overlay {
+ -webkit-perspective: 600;
+ perspective: 600;
+ opacity: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ background-color: rgba(0, 0, 0, 0.5);
+}
+
+.ReactModal__Overlay--after-open {
+ opacity: 1;
+ transition: opacity calc(var(--enableAnimation) * 150ms) ease-out;
+}
+
+.ReactModal__Content {
+ -webkit-transform: scale(0.5) rotateX(-30deg);
+ transform: scale(0.5) rotateX(-30deg);
+ width: 600px;
+}
+
+.ReactModal__Content--after-open {
+ -webkit-transform: scale(1) rotateX(0deg);
+ transform: scale(1) rotateX(0deg);
+ transition: all calc(var(--enableAnimation) * 150ms) ease-in;
+}
+
+.ReactModal__Overlay--before-close {
+ opacity: 0;
+}
+
+.ReactModal__Content--before-close {
+ -webkit-transform: scale(0.5) rotateX(30deg);
+ transform: scale(0.5) rotateX(30deg);
+ transition: all calc(var(--enableAnimation) * 150ms) ease-in;
+}
+
+.ReactModal__Content.modal-dialog {
+ border: none;
+ background-color: transparent;
+}
+
+/* Prevents that an element within app shows over a modal */
+#app {
+ position: inherit;
+ z-index: 1000 !important;
+}
+
+.modal-low {
+ z-index: 1001;
+}
+
+.modal-medium {
+ z-index: 1002;
+}
+
+.modal-high {
+ z-index: 1003;
+}
+
+/* Within a same priority, hide all but first (FIFO) */
+.modal-low ~ .modal-low,
+.modal-medium ~ .modal-medium,
+.modal-high ~ .modal-high {
+ display: none;
+}
+
+/* Hide all low priority modals when a medium or high priority modals are displayed */
+#modals-container:has(.modal-medium) .modal-low,
+#modals-container:has(.modal-high) .modal-low {
+ display: none;
+}
+
+/* Hide all medium priority modals when a high priority modal is displayed */
+#modals-container:has(.modal-high) .modal-medium {
+ display: none;
+}
diff --git a/src/2.7.12/client/stylesheets/normalize.css b/src/2.7.12/client/stylesheets/normalize.css
new file mode 100644
index 00000000..e70ffc56
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/normalize.css
@@ -0,0 +1,420 @@
+/*! normalize.css v4.1.1 | MIT License | github.com/necolas/normalize.css */
+
+/**
+ * 1. Change the default font family in all browsers (opinionated).
+ * 2. Prevent adjustments of font size after orientation changes in IE and iOS.
+ */
+
+html {
+ font-family: sans-serif; /* 1 */
+ -ms-text-size-adjust: 100%; /* 2 */
+ -webkit-text-size-adjust: 100%; /* 2 */
+}
+
+/**
+ * Remove the margin in all browsers (opinionated).
+ */
+
+body {
+ margin: 0;
+}
+
+/* HTML5 display definitions
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 9-.
+ * 1. Add the correct display in Edge, IE, and Firefox.
+ * 2. Add the correct display in IE.
+ */
+
+article,
+aside,
+details, /* 1 */
+figcaption,
+figure,
+footer,
+header,
+main, /* 2 */
+menu,
+nav,
+section,
+summary { /* 1 */
+ display: block;
+}
+
+/**
+ * Add the correct display in IE 9-.
+ */
+
+audio,
+canvas,
+progress,
+video {
+ display: inline-block;
+}
+
+/**
+ * Add the correct display in iOS 4-7.
+ */
+
+audio:not([controls]) {
+ display: none;
+ height: 0;
+}
+
+/**
+ * Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ vertical-align: baseline;
+}
+
+/**
+ * Add the correct display in IE 10-.
+ * 1. Add the correct display in IE.
+ */
+
+template, /* 1 */
+[hidden] {
+ display: none;
+}
+
+/* Links
+ ========================================================================== */
+
+/**
+ * 1. Remove the gray background on active links in IE 10.
+ * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
+ */
+
+a {
+ background-color: transparent; /* 1 */
+ -webkit-text-decoration-skip: objects; /* 2 */
+}
+
+/**
+ * Remove the outline on focused links when they are also active or hovered
+ * in all browsers (opinionated).
+ */
+
+a:active,
+a:hover {
+ outline-width: 0;
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * 1. Remove the bottom border in Firefox 39-.
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none; /* 1 */
+ text-decoration: underline; /* 2 */
+ text-decoration: underline dotted; /* 2 */
+}
+
+/**
+ * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
+ */
+
+b,
+strong {
+ font-weight: inherit;
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * Add the correct font style in Android 4.3-.
+ */
+
+dfn {
+ font-style: italic;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/**
+ * Add the correct background and color in IE 9-.
+ */
+
+mark {
+ background-color: #ff0;
+ color: #000;
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove the border on images inside links in IE 10-.
+ */
+
+img {
+ border-style: none;
+}
+
+/**
+ * Hide the overflow in IE.
+ */
+
+svg:not(:root) {
+ overflow: hidden;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+pre,
+samp {
+ font-family: monospace, monospace; /* 1 */
+ font-size: 1em; /* 2 */
+}
+
+/**
+ * Add the correct margin in IE 8.
+ */
+
+figure {
+ margin: 1em 40px;
+}
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box; /* 1 */
+ height: 0; /* 1 */
+ overflow: visible; /* 2 */
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change font properties to `inherit` in all browsers (opinionated).
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font: inherit; /* 1 */
+ margin: 0; /* 2 */
+}
+
+/**
+ * Restore the font weight unset by the previous rule.
+ */
+
+optgroup {
+ font-weight: bold;
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input { /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select { /* 1 */
+ text-transform: none;
+}
+
+/**
+ * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
+ * controls in Android 4.
+ * 2. Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+html [type="button"], /* 1 */
+[type="reset"],
+[type="submit"] {
+ -webkit-appearance: button; /* 2 */
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type="button"]:-moz-focusring,
+[type="reset"]:-moz-focusring,
+[type="submit"]:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Change the border, margin, and padding in all browsers (opinionated).
+ */
+
+fieldset {
+ border: 1px solid #c0c0c0;
+ margin: 0 2px;
+ padding: 0.35em 0.625em 0.75em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box; /* 1 */
+ color: inherit; /* 2 */
+ display: table; /* 1 */
+ max-width: 100%; /* 1 */
+ padding: 0; /* 3 */
+ white-space: normal; /* 1 */
+}
+
+/**
+ * Remove the default vertical scrollbar in IE.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10-.
+ * 2. Remove the padding in IE 10-.
+ */
+
+[type="checkbox"],
+[type="radio"] {
+ box-sizing: border-box; /* 1 */
+ padding: 0; /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type="search"] {
+ -webkit-appearance: textfield; /* 1 */
+ outline-offset: -2px; /* 2 */
+}
+
+/**
+ * Remove the inner padding and cancel buttons in Chrome and Safari on OS X.
+ */
+
+[type="search"]::-webkit-search-cancel-button,
+[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * Correct the text style of placeholders in Chrome, Edge, and Safari.
+ */
+
+::-webkit-input-placeholder {
+ color: inherit;
+ opacity: 0.54;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button; /* 1 */
+ font: inherit; /* 2 */
+}
diff --git a/src/2.7.12/client/stylesheets/toastify.css b/src/2.7.12/client/stylesheets/toastify.css
new file mode 100644
index 00000000..05ee7747
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/toastify.css
@@ -0,0 +1,120 @@
+/* TODO: We can remove this file after we update react animation package */
+@keyframes toastify-bounceInRight {
+ from, 60%, 75%, 90%, to {
+ animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
+ from {
+ opacity: 0;
+ transform: translate3d(3000px, 0, 0); }
+ 60% {
+ opacity: 1;
+ transform: translate3d(-25px, 0, 0); }
+ 75% {
+ transform: translate3d(10px, 0, 0); }
+ 90% {
+ transform: translate3d(-5px, 0, 0); }
+ to {
+ transform: none; } }
+.toastify-bounceInRight, .toast-enter--top-right, .toast-enter--bottom-right {
+ animation-name: toastify-bounceInRight; }
+
+@keyframes toastify-bounceOutRight {
+ 20% {
+ opacity: 1;
+ transform: translate3d(-20px, 0, 0); }
+ to {
+ opacity: 0;
+ transform: translate3d(2000px, 0, 0); } }
+.toastify-bounceOutRight, .toast-exit--top-right, .toast-exit--bottom-right {
+ animation-name: toastify-bounceOutRight; }
+
+@keyframes toastify-bounceInLeft {
+ from, 60%, 75%, 90%, to {
+ animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
+ 0% {
+ opacity: 0;
+ transform: translate3d(-3000px, 0, 0); }
+ 60% {
+ opacity: 1;
+ transform: translate3d(25px, 0, 0); }
+ 75% {
+ transform: translate3d(-10px, 0, 0); }
+ 90% {
+ transform: translate3d(5px, 0, 0); }
+ to {
+ transform: none; } }
+.toastify-bounceInLeft, .toast-enter--top-left, .toast-enter--bottom-left {
+ animation-name: toastify-bounceInLeft; }
+
+@keyframes toastify-bounceOutLeft {
+ 20% {
+ opacity: 1;
+ transform: translate3d(20px, 0, 0); }
+ to {
+ opacity: 0;
+ transform: translate3d(-2000px, 0, 0); } }
+.toastify-bounceOutLeft, .toast-exit--top-left, .toast-exit--bottom-left {
+ animation-name: toastify-bounceOutLeft; }
+
+@keyframes toastify-bounceInUp {
+ from, 60%, 75%, 90%, to {
+ animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
+ from {
+ opacity: 0;
+ transform: translate3d(0, 3000px, 0); }
+ 60% {
+ opacity: 1;
+ transform: translate3d(0, -20px, 0); }
+ 75% {
+ transform: translate3d(0, 10px, 0); }
+ 90% {
+ transform: translate3d(0, -5px, 0); }
+ to {
+ transform: translate3d(0, 0, 0); } }
+.toastify-bounceInUp, .toast-enter--bottom-center {
+ animation-name: toastify-bounceInUp; }
+
+@keyframes toastify-bounceOutUp {
+ 20% {
+ transform: translate3d(0, -10px, 0); }
+ 40%, 45% {
+ opacity: 1;
+ transform: translate3d(0, 20px, 0); }
+ to {
+ opacity: 0;
+ transform: translate3d(0, -2000px, 0); } }
+.toastify-bounceOutUp, .toast-exit--top-center {
+ animation-name: toastify-bounceOutUp; }
+
+@keyframes toastify-bounceInDown {
+ from, 60%, 75%, 90%, to {
+ animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); }
+ 0% {
+ opacity: 0;
+ transform: translate3d(0, -3000px, 0); }
+ 60% {
+ opacity: 1;
+ transform: translate3d(0, 25px, 0); }
+ 75% {
+ transform: translate3d(0, -10px, 0); }
+ 90% {
+ transform: translate3d(0, 5px, 0); }
+ to {
+ transform: none; } }
+.toastify-bounceInDown, .toast-enter--top-center {
+ animation-name: toastify-bounceInDown; }
+
+@keyframes toastify-bounceOutDown {
+ 20% {
+ transform: translate3d(0, 10px, 0); }
+ 40%, 45% {
+ opacity: 1;
+ transform: translate3d(0, -20px, 0); }
+ to {
+ opacity: 0;
+ transform: translate3d(0, 2000px, 0); } }
+.toastify-bounceOutDown, .toast-exit--bottom-center {
+ animation-name: toastify-bounceOutDown; }
+
+.toastify-animated {
+ animation-duration: 0.75s;
+ animation-fill-mode: both; }
diff --git a/src/2.7.12/client/stylesheets/toggleSwitch.css b/src/2.7.12/client/stylesheets/toggleSwitch.css
new file mode 100644
index 00000000..db9c7fd1
--- /dev/null
+++ b/src/2.7.12/client/stylesheets/toggleSwitch.css
@@ -0,0 +1,123 @@
+.react-toggle {
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ -webkit-tap-highlight-color: transparent;
+}
+
+.react-toggle-screenreader-only {
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+}
+
+.react-toggle--disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ transition: opacity calc(var(--enableAnimation) * 0.25s);
+}
+
+.react-toggle-track {
+ overflow: hidden;
+ width: 3.5rem;
+ height: 1.5rem;
+ padding: 0;
+ border-radius: 2rem;
+ background-color: var(--color-danger);
+ transition: all calc(var(--enableAnimation) * 0.2s) ease;
+}
+
+.react-toggle-track:dir(rtl) {
+ width: 4rem;
+}
+
+.react-toggle--checked .react-toggle-track {
+ background-color: var(--color-success);
+}
+
+.react-toggle-track-check {
+ position: absolute;
+ color: white;
+ width: 1rem;
+ line-height: 1.5rem;
+ font-size: 0.8rem;
+ left: 0.5rem;
+ opacity: 0;
+ transition: opacity calc(var(--enableAnimation) * 0.25s) ease;
+}
+
+.react-toggle-track-check:dir(rtl) {
+ left: 0.8rem;
+}
+
+.react-toggle--checked .react-toggle-track-check {
+ opacity: 1;
+ transition: opacity calc(var(--enableAnimation) * 0.25s) ease;
+}
+
+.react-toggle-track-x {
+ position: absolute;
+ color: white;
+ width: 1rem;
+ line-height: 1.5rem;
+ font-size: 0.8rem;
+ left: 1.7rem;
+ opacity: 1;
+ transition: opacity calc(var(--enableAnimation) * 0.25s) ease;
+}
+
+.react-toggle-track-x:dir(rtl) {
+ left: 2.2rem;
+}
+
+.react-toggle--checked .react-toggle-track-x {
+ opacity: 0;
+}
+
+.react-toggle-thumb {
+ transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+ position: absolute;
+ top: 1px;
+ left: 1px;
+ width: 1.35rem;
+ height: 1.35rem;
+ border-radius: 50%;
+ background-color: #FAFAFA;
+ box-sizing: border-box;
+ transition: opacity calc(var(--enableAnimation) * 0.25s) ease;
+ box-shadow: 2px 0px 10px -1px rgba(0,0,0,0.4);
+}
+
+.react-toggle--checked .react-toggle-thumb {
+ left: 2.1rem;
+ box-shadow: -2px 0px 10px -1px rgba(0,0,0,0.4);
+}
+
+.react-toggle--checked:dir(rtl) .react-toggle-thumb:dir(rtl) {
+ left: 2.6rem;
+}
+
+.react-toggle--focus .react-toggle-thumb {
+ box-shadow: 0px 0px 2px 3px var(--color-primary);
+}
+
+.react-toggle:active:not(.react-toggle--disabled) .react-toggle-thumb {
+ box-shadow: 0px 0px 5px 5px var(--color-primary);
+}
diff --git a/src/2.7.12/deploy_to_usr_share.sh b/src/2.7.12/deploy_to_usr_share.sh
new file mode 100644
index 00000000..f5d21e1c
--- /dev/null
+++ b/src/2.7.12/deploy_to_usr_share.sh
@@ -0,0 +1,106 @@
+#!/bin/sh -ex
+cd "$(dirname "$0")"
+
+# Please check bigbluebutton/bigbluebutton-html5/dev_local_deployment/README.md
+
+UPPER_DESTINATION_DIR=/usr/share/meteor
+DESTINATION_DIR=$UPPER_DESTINATION_DIR/bundle
+
+SERVICE_FILES_DIR=/usr/lib/systemd/system
+LOCAL_PACKAGING_DIR="$(pwd)/../build/packages-template/bbb-html5"
+
+if [ ! -d "$LOCAL_PACKAGING_DIR" ]; then
+ echo "Did not find LOCAL_PACKAGING_DIR=$LOCAL_PACKAGING_DIR"
+ exit
+fi
+
+sudo rm -rf "$UPPER_DESTINATION_DIR"
+sudo mkdir -p "$UPPER_DESTINATION_DIR"
+sudo chown -R meteor:meteor "$UPPER_DESTINATION_DIR"
+
+# the next 5 lines may be temporarily commented out if you are sure you are not tweaking the required node_modules after first use of the script. This will save a minute or two during the run of the script
+if [ -d "node_modules" ]; then
+ rm -r node_modules/
+fi
+meteor reset
+meteor npm ci --production
+
+sudo chmod 777 /usr/share/meteor
+METEOR_DISABLE_OPTIMISTIC_CACHING=1 meteor build $UPPER_DESTINATION_DIR --architecture os.linux.x86_64 --allow-superuser --directory
+
+sudo chown -R meteor:meteor "$UPPER_DESTINATION_DIR"/
+echo 'stage3'
+
+
+cd "$DESTINATION_DIR"/programs/server/ || exit
+sudo chmod -R 777 .
+meteor npm i
+
+echo "deployed to $DESTINATION_DIR/programs/server\n\n\n"
+
+echo "writing $DESTINATION_DIR/mongod_start_pre.sh"
+sudo cp $LOCAL_PACKAGING_DIR/mongod_start_pre.sh "$DESTINATION_DIR"/mongod_start_pre.sh
+
+echo "writing $DESTINATION_DIR/mongo-ramdisk.conf"
+sudo cp $LOCAL_PACKAGING_DIR/mongo-ramdisk.conf "$DESTINATION_DIR"/mongo-ramdisk.conf
+
+echo "writing $DESTINATION_DIR/bbb-html5-with-roles.conf"
+sudo tee "$DESTINATION_DIR/bbb-html5-with-roles.conf" >/dev/null < {
+ AnnotationsStreamer(meetingId).emit('removed', { meetingId, whiteboardId, shapeId });
+ await removeAnnotation(meetingId, whiteboardId, shapeId);
+ }));
+ return result;
+}
diff --git a/src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js
new file mode 100644
index 00000000..d4de6f20
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardSend.js
@@ -0,0 +1,58 @@
+import { check } from 'meteor/check';
+import AnnotationsStreamer from '/imports/api/annotations/server/streamer';
+import addAnnotation from '../modifiers/addAnnotation';
+import Metrics from '/imports/startup/server/metrics';
+
+const { queueMetrics } = Meteor.settings.private.redis.metrics;
+
+const {
+ annotationsQueueProcessInterval: ANNOTATION_PROCESS_INTERVAL,
+} = Meteor.settings.public.whiteboard;
+
+let annotationsQueue = {};
+let annotationsRecieverIsRunning = false;
+
+const process = () => {
+ if (!Object.keys(annotationsQueue).length) {
+ annotationsRecieverIsRunning = false;
+ return;
+ }
+ annotationsRecieverIsRunning = true;
+ Object.keys(annotationsQueue).forEach((meetingId) => {
+ AnnotationsStreamer(meetingId).emit('added', { meetingId, annotations: annotationsQueue[meetingId] });
+ if (queueMetrics) {
+ Metrics.setAnnotationQueueLength(meetingId, 0);
+ }
+ });
+ annotationsQueue = {};
+
+ Meteor.setTimeout(process, ANNOTATION_PROCESS_INTERVAL);
+};
+
+export default async function handleWhiteboardSend({ envelope, header, body }, meetingId) {
+ const userId = header.userId;
+ const whiteboardId = body.whiteboardId;
+ const annotations = body.annotations;
+ const instanceIdFromMessage = parseInt(envelope.routing.html5InstanceId, 10) || 1;
+ const myInstanceId = parseInt(body.myInstanceId, 10) || 1;
+
+ check(userId, String);
+ check(whiteboardId, String);
+ check(annotations, Array);
+
+ if (!annotationsQueue.hasOwnProperty(meetingId)) {
+ annotationsQueue[meetingId] = [];
+ }
+ // we use a for loop here instead of a map because we need to guarantee the order of the annotations.
+ for (const annotation of annotations) {
+ annotationsQueue[meetingId].push({ meetingId, whiteboardId, userId: annotation.userId, annotation });
+ if (instanceIdFromMessage === myInstanceId) {
+ await addAnnotation(meetingId, whiteboardId, annotation.userId, annotation);
+ }
+ }
+
+ if (queueMetrics) {
+ Metrics.setAnnotationQueueLength(meetingId, annotationsQueue[meetingId].length);
+ }
+ if (!annotationsRecieverIsRunning) process();
+}
diff --git a/src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js
new file mode 100644
index 00000000..87a1d8a0
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/handlers/whiteboardUndo.js
@@ -0,0 +1,16 @@
+import { check } from 'meteor/check';
+
+import AnnotationsStreamer from '/imports/api/annotations/server/streamer';
+import removeAnnotation from '../modifiers/removeAnnotation';
+
+export default async function handleWhiteboardUndo({ body }, meetingId) {
+ const { whiteboardId } = body;
+ const shapeId = body.annotationId;
+
+ check(whiteboardId, String);
+ check(shapeId, String);
+
+ AnnotationsStreamer(meetingId).emit('removed', { meetingId, whiteboardId, shapeId });
+ const result = await removeAnnotation(meetingId, whiteboardId, shapeId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/annotations/server/index.js b/src/2.7.12/imports/api/annotations/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/annotations/server/methods.js b/src/2.7.12/imports/api/annotations/server/methods.js
new file mode 100644
index 00000000..6a42fbf0
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/methods.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import clearWhiteboard from './methods/clearWhiteboard';
+import sendAnnotations from './methods/sendAnnotations';
+import sendBulkAnnotations from './methods/sendBulkAnnotations';
+import deleteAnnotations from './methods/deleteAnnotations';
+
+Meteor.methods({
+ clearWhiteboard,
+ sendAnnotations,
+ sendBulkAnnotations,
+ deleteAnnotations,
+});
diff --git a/src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js b/src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js
new file mode 100644
index 00000000..a95d775d
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/methods/clearWhiteboard.js
@@ -0,0 +1,27 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function clearWhiteboard(whiteboardId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ClearWhiteboardPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(whiteboardId, String);
+
+ const payload = {
+ whiteboardId,
+ };
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method clearWhiteboard ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js b/src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js
new file mode 100644
index 00000000..d58fa405
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/methods/deleteAnnotations.js
@@ -0,0 +1,29 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function deleteAnnotations(annotations, whiteboardId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'DeleteWhiteboardAnnotationsPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(whiteboardId, String);
+ check(annotations, Array);
+
+ const payload = {
+ whiteboardId,
+ annotationsIds: annotations,
+ };
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method deleteAnnotation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js
new file mode 100644
index 00000000..3b39236e
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotationHelper.js
@@ -0,0 +1,34 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function sendAnnotationHelper(annotations, meetingId, requesterUserId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SendWhiteboardAnnotationsPubMsg';
+
+ try {
+ check(annotations, Array);
+ // TODO see if really necessary, don't know if it's possible
+ // to have annotations from different pages
+ // group annotations by same whiteboardId
+ const groupedAnnotations = annotations.reduce((r, v, i, a, k = v.wbId) => ((r[k] || (r[k] = [])).push(v), r), {}) //groupBy wbId
+
+ Object.entries(groupedAnnotations).forEach(([_, whiteboardAnnotations]) => {
+ const whiteboardId = whiteboardAnnotations[0].wbId;
+ check(whiteboardId, String);
+
+ const payload = {
+ whiteboardId,
+ annotations: whiteboardAnnotations,
+ html5InstanceId: parseInt(process.env.INSTANCE_ID, 10) || 1,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ });
+
+ } catch (err) {
+ Logger.error(`Exception while invoking method sendAnnotationHelper ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js
new file mode 100644
index 00000000..530515f5
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/methods/sendAnnotations.js
@@ -0,0 +1,17 @@
+import { check } from 'meteor/check';
+import sendAnnotationHelper from './sendAnnotationHelper';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function sendAnnotations(annotations) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ sendAnnotationHelper(annotations, meetingId, requesterUserId);
+ } catch (err) {
+ Logger.error(`Exception while invoking method sendAnnotation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js b/src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js
new file mode 100644
index 00000000..09fcfeff
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/methods/sendBulkAnnotations.js
@@ -0,0 +1,20 @@
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import sendAnnotationHelper from './sendAnnotationHelper';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function sendBulkAnnotations(payload) {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ try {
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ sendAnnotationHelper(payload, meetingId, requesterUserId);
+ //payload.forEach((annotation) => sendAnnotationHelper(annotation, meetingId, requesterUserId));
+ return true;
+ } catch (err) {
+ Logger.error(`Exception while invoking method sendBulkAnnotations ${err.stack}`);
+ return false;
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js b/src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js
new file mode 100644
index 00000000..ff3d83f2
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/modifiers/addAnnotation.js
@@ -0,0 +1,22 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Annotations from '/imports/api/annotations';
+import addAnnotationQuery from '/imports/api/annotations/addAnnotation';
+
+export default async function addAnnotation(meetingId, whiteboardId, userId, annotation) {
+ check(meetingId, String);
+ check(whiteboardId, String);
+ check(annotation, Object);
+
+ const query = await addAnnotationQuery(meetingId, whiteboardId, userId, annotation, Annotations);
+
+ try {
+ const { insertedId } = await Annotations.upsertAsync(query.selector, query.modifier);
+
+ if (insertedId) {
+ Logger.info(`Added annotation id=${annotation.id} whiteboard=${whiteboardId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding annotation to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js b/src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js
new file mode 100644
index 00000000..b73cb71d
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/modifiers/clearAnnotations.js
@@ -0,0 +1,43 @@
+import Annotations from '/imports/api/annotations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearAnnotations(meetingId, whiteboardId, userId) {
+ const selector = {};
+
+ if (meetingId) {
+ selector.meetingId = meetingId;
+ }
+
+ if (whiteboardId) {
+ selector.whiteboardId = whiteboardId;
+ }
+
+ if (userId) {
+ selector.userId = userId;
+ }
+
+ try {
+ const numberAffected = await Annotations.removeAsync(selector);
+
+ if (numberAffected) {
+ if (userId) {
+ Logger.info(`Cleared Annotations for userId=${userId} where whiteboard=${whiteboardId}`);
+ return;
+ }
+
+ if (whiteboardId) {
+ Logger.info(`Cleared Annotations for whiteboard=${whiteboardId}`);
+ return;
+ }
+
+ if (meetingId) {
+ Logger.info(`Cleared Annotations (${meetingId})`);
+ return;
+ }
+
+ Logger.info('Cleared Annotations (all)');
+ }
+ } catch (err) {
+ Logger.error(`Removing Annotations from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js b/src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js
new file mode 100644
index 00000000..7b1586d9
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/modifiers/removeAnnotation.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import Annotations from '/imports/api/annotations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function removeAnnotation(meetingId, whiteboardId, shapeId) {
+ check(meetingId, String);
+ check(whiteboardId, String);
+ check(shapeId, String);
+
+ const selector = {
+ meetingId,
+ whiteboardId,
+ id: shapeId,
+ };
+
+ try {
+ const numberAffected = await Annotations.removeAsync(selector);
+
+ if (numberAffected) {
+ Logger.info(`Removed annotation id=${shapeId} whiteboard=${whiteboardId}`);
+ }
+ } catch (err) {
+ Logger.error(`Removing annotation from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/annotations/server/publishers.js b/src/2.7.12/imports/api/annotations/server/publishers.js
new file mode 100644
index 00000000..73017db1
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/publishers.js
@@ -0,0 +1,26 @@
+import Annotations from '/imports/api/annotations';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+function annotations() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Annotations was requested by unauth connection ${this.connection.id}`);
+ return Annotations.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing Annotations', { meetingId, userId });
+
+ return Annotations.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundAnnotations = annotations.bind(this);
+ return boundAnnotations(...args);
+}
+
+Meteor.publish('annotations', publish);
diff --git a/src/2.7.12/imports/api/annotations/server/streamer.js b/src/2.7.12/imports/api/annotations/server/streamer.js
new file mode 100644
index 00000000..d283da07
--- /dev/null
+++ b/src/2.7.12/imports/api/annotations/server/streamer.js
@@ -0,0 +1,24 @@
+import Logger from '/imports/startup/server/logger';
+
+export function removeAnnotationsStreamer(meetingId) {
+ Logger.info(`Removing Annotations streamer object for meeting ${meetingId}`);
+ delete Meteor.StreamerCentral.instances[`annotations-${meetingId}`];
+}
+
+export function addAnnotationsStreamer(meetingId) {
+ const streamer = new Meteor.Streamer(`annotations-${meetingId}`, { retransmit: false });
+
+ streamer.allowRead(function allowRead() {
+ if (!this.userId) return false;
+
+ return this.userId && this.userId.includes(meetingId);
+ });
+
+ streamer.allowWrite(function allowWrite() {
+ return false;
+ });
+}
+
+export default function get(meetingId) {
+ return Meteor.StreamerCentral.instances[`annotations-${meetingId}`];
+}
diff --git a/src/2.7.12/imports/api/audio-captions/index.js b/src/2.7.12/imports/api/audio-captions/index.js
new file mode 100644
index 00000000..cf104702
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/index.js
@@ -0,0 +1,9 @@
+import { Meteor } from 'meteor/meteor';
+
+const AudioCaptions = new Mongo.Collection('audio-captions');
+
+if (Meteor.isServer) {
+ AudioCaptions.createIndexAsync({ meetingId: 1 });
+}
+
+export default AudioCaptions;
diff --git a/src/2.7.12/imports/api/audio-captions/server/eventHandlers.js b/src/2.7.12/imports/api/audio-captions/server/eventHandlers.js
new file mode 100644
index 00000000..1ac75830
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/eventHandlers.js
@@ -0,0 +1,6 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleTranscriptUpdated from '/imports/api/audio-captions/server/handlers/transcriptUpdated';
+import handleTranscriptionProviderError from '/imports/api/audio-captions/server/handlers/transcriptionProviderError';
+
+RedisPubSub.on('TranscriptUpdatedEvtMsg', handleTranscriptUpdated);
+RedisPubSub.on('TranscriptionProviderErrorEvtMsg', handleTranscriptionProviderError);
diff --git a/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js
new file mode 100644
index 00000000..6b1a4652
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptUpdated.js
@@ -0,0 +1,38 @@
+import setTranscript from '/imports/api/audio-captions/server/modifiers/setTranscript';
+import updateTranscriptPad from '/imports/api/pads/server/methods/updateTranscriptPad';
+import Users from '/imports/api/users';
+
+const TRANSCRIPTION_DEFAULT_PAD = Meteor.settings.public.captions.defaultPad;
+
+const formatDate = (dStr) => {
+ return ("00" + dStr).substr(-2,2);
+};
+
+export default async function transcriptUpdated({ header, body }) {
+ const {
+ meetingId,
+ userId,
+ } = header;
+
+ const {
+ transcriptId,
+ transcript,
+ locale,
+ result,
+ } = body;
+
+ if (result) {
+ const user = Users.findOne({ userId }, { fields: { name: 1 } });
+ const userName = user?.name || '??';
+
+ const dt = new Date(Date.now());
+ const hours = formatDate(dt.getHours()),
+ minutes = formatDate(dt.getMinutes()),
+ seconds = formatDate(dt.getSeconds());
+
+ const userSpoke = `\n ${userName} (${hours}:${minutes}:${seconds}): ${transcript}`;
+ updateTranscriptPad(meetingId, userId, TRANSCRIPTION_DEFAULT_PAD, userSpoke);
+ }
+
+ await setTranscript(userId, meetingId, transcriptId, transcript, locale);
+}
diff --git a/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js
new file mode 100644
index 00000000..ab5c5ae9
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/handlers/transcriptionProviderError.js
@@ -0,0 +1,38 @@
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+
+export default async function handleTranscriptionProviderError({ header, body }) {
+ const {
+ meetingId,
+ userId,
+ } = header;
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const {
+ errorCode,
+ errorMessage,
+ } = body;
+
+ const modifier = {
+ $set: {
+ transcriptionError: {
+ code: errorCode,
+ message: errorMessage,
+ },
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.error(`Transcription error errorCode=${errorCode} errorMessage=${errorMessage} meetingId=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Problem setting transcription error: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/audio-captions/server/index.js b/src/2.7.12/imports/api/audio-captions/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/audio-captions/server/methods.js b/src/2.7.12/imports/api/audio-captions/server/methods.js
new file mode 100644
index 00000000..e0b22b21
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/methods.js
@@ -0,0 +1,6 @@
+import { Meteor } from 'meteor/meteor';
+import updateTranscript from '/imports/api/audio-captions/server/methods/updateTranscript';
+
+Meteor.methods({
+ updateTranscript,
+});
diff --git a/src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js b/src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js
new file mode 100644
index 00000000..a00802a1
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/methods/updateTranscript.js
@@ -0,0 +1,41 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function updateTranscript(transcriptId, start, end, text, transcript, locale, isFinal) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UpdateTranscriptPubMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(transcriptId, String);
+ check(start, Number);
+ check(end, Number);
+ check(text, String);
+ check(transcript, String);
+ check(locale, String);
+ check(isFinal, Boolean);
+
+ // Ignore irrelevant updates
+ if (start !== -1 && end !== -1) {
+ const payload = {
+ transcriptId,
+ start,
+ end,
+ text,
+ transcript,
+ locale,
+ result: isFinal,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method upadteTranscript ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js b/src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js
new file mode 100644
index 00000000..cd97c33f
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/modifiers/clearAudioCaptions.js
@@ -0,0 +1,26 @@
+import AudioCaptions from '/imports/api/audio-captions';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearAudioCaptions(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await AudioCaptions.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared AudioCaptions (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing audio captions (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await AudioCaptions.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared AudioCaptions (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing audio captions (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js b/src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js
new file mode 100644
index 00000000..c21583b7
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/modifiers/setTranscript.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import AudioCaptions from '/imports/api/audio-captions';
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setTranscript(userId, meetingId, transcriptId, transcript, locale) {
+ try {
+ check(meetingId, String);
+ check(transcriptId, String);
+ check(transcript, String);
+
+ const selector = { meetingId, transcriptId };
+
+ const modifier = {
+ $set: {
+ transcript,
+ lastUpdated: Math.floor(new Date().getTime()/1000),
+ locale,
+ },
+ };
+
+ const numberAffected = await AudioCaptions.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug(`Set transcriptId=${transcriptId} transcript=${transcript} meeting=${meetingId} locale=${locale}`);
+ } else {
+ Logger.debug(`Upserted transcriptId=${transcriptId} transcript=${transcript} meeting=${meetingId} locale=${locale}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting audio captions transcript to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/audio-captions/server/publishers.js b/src/2.7.12/imports/api/audio-captions/server/publishers.js
new file mode 100644
index 00000000..17d4632f
--- /dev/null
+++ b/src/2.7.12/imports/api/audio-captions/server/publishers.js
@@ -0,0 +1,26 @@
+import AudioCaptions from '/imports/api/audio-captions';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function audioCaptions() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing AudioCaptions was requested by unauth connection ${this.connection.id}`);
+ return AudioCaptions.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+ Logger.debug('Publishing AudioCaptions', { meetingId, requestedBy: userId });
+
+ return AudioCaptions.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundAudioCaptions = audioCaptions.bind(this);
+ return boundAudioCaptions(...args);
+}
+
+Meteor.publish('audio-captions', publish);
diff --git a/src/2.7.12/imports/api/audio/client/bridge/base.js b/src/2.7.12/imports/api/audio/client/bridge/base.js
new file mode 100644
index 00000000..742dcf96
--- /dev/null
+++ b/src/2.7.12/imports/api/audio/client/bridge/base.js
@@ -0,0 +1,177 @@
+import { Tracker } from 'meteor/tracker';
+import VoiceCallStates from '/imports/api/voice-call-states';
+import CallStateOptions from '/imports/api/voice-call-states/utils/callStates';
+import logger from '/imports/startup/client/logger';
+import Auth from '/imports/ui/services/auth';
+import {
+ getAudioConstraints,
+ doGUM,
+} from '/imports/api/audio/client/bridge/service';
+
+const MEDIA = Meteor.settings.public.media;
+const BASE_BRIDGE_NAME = 'base';
+const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
+const TRANSFER_TONE = '1';
+
+export default class BaseAudioBridge {
+ constructor(userData) {
+ this.userData = userData;
+
+ this.baseErrorCodes = {
+ INVALID_TARGET: 'INVALID_TARGET',
+ CONNECTION_ERROR: 'CONNECTION_ERROR',
+ REQUEST_TIMEOUT: 'REQUEST_TIMEOUT',
+ GENERIC_ERROR: 'GENERIC_ERROR',
+ MEDIA_ERROR: 'MEDIA_ERROR',
+ WEBRTC_NOT_SUPPORTED: 'WEBRTC_NOT_SUPPORTED',
+ ICE_NEGOTIATION_FAILED: 'ICE_NEGOTIATION_FAILED',
+ };
+
+ this.baseCallStates = {
+ started: 'started',
+ ended: 'ended',
+ failed: 'failed',
+ reconnecting: 'reconnecting',
+ autoplayBlocked: 'autoplayBlocked',
+ };
+
+ this.bridgeName = BASE_BRIDGE_NAME;
+ }
+
+ getPeerConnection() {
+ console.error('The Bridge must implement getPeerConnection');
+ }
+
+ exitAudio() {
+ console.error('The Bridge must implement exitAudio');
+ }
+
+ joinAudio() {
+ console.error('The Bridge must implement joinAudio');
+ }
+
+ changeInputDevice() {
+ console.error('The Bridge must implement changeInputDevice');
+ }
+
+ setInputStream() {
+ console.error('The Bridge must implement setInputStream');
+ }
+
+ sendDtmf() {
+ console.error('The Bridge must implement sendDtmf');
+ }
+
+ set inputDeviceId (deviceId) {
+ this._inputDeviceId = deviceId;
+ }
+
+ get inputDeviceId () {
+ return this._inputDeviceId;
+
+ }
+
+ /**
+ * Change the input device with the given deviceId, without renegotiating
+ * peer.
+ * A new MediaStream object is created for the given deviceId. This object
+ * is returned by the resolved promise.
+ * @param {String} deviceId The id of the device to be set as input
+ * @return {Promise} A promise that is resolved with the MediaStream
+ * object after changing the input device.
+ */
+ async liveChangeInputDevice(deviceId) {
+ let newStream;
+ let backupStream;
+
+ try {
+ const constraints = {
+ audio: getAudioConstraints({ deviceId }),
+ };
+
+ // Backup stream (current one) in case the switch fails
+ if (this.inputStream && this.inputStream.active) {
+ backupStream = this.inputStream ? this.inputStream.clone() : null;
+ this.inputStream.getAudioTracks().forEach((track) => track.stop());
+ }
+
+ newStream = await doGUM(constraints);
+ await this.setInputStream(newStream);
+ if (backupStream && backupStream.active) {
+ backupStream.getAudioTracks().forEach((track) => track.stop());
+ backupStream = null;
+ }
+
+ return newStream;
+ } catch (error) {
+ // Device change failed. Clean up the tentative new stream to avoid lingering
+ // stuff, then try to rollback to the previous input stream.
+ if (newStream && typeof newStream.getAudioTracks === 'function') {
+ newStream.getAudioTracks().forEach((t) => t.stop());
+ newStream = null;
+ }
+
+ // Rollback to backup stream
+ if (backupStream && backupStream.active) {
+ this.setInputStream(backupStream).catch((rollbackError) => {
+ logger.error({
+ logCode: 'audio_changeinputdevice_rollback_failure',
+ extraInfo: {
+ bridgeName: this.bridgeName,
+ deviceId,
+ errorName: rollbackError.name,
+ errorMessage: rollbackError.message,
+ },
+ }, 'Microphone device change rollback failed - the device may become silent');
+
+ backupStream.getAudioTracks().forEach((track) => track.stop());
+ backupStream = null;
+ });
+ }
+
+ throw error;
+ }
+ }
+
+ trackTransferState(transferCallback) {
+ return new Promise((resolve, reject) => {
+ let trackerControl = null;
+
+ const timeout = setTimeout(() => {
+ trackerControl.stop();
+ logger.warn({ logCode: 'audio_transfer_timed_out' },
+ 'Timeout on transferring from echo test to conference');
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1008,
+ bridgeError: 'Timeout on call transfer',
+ bridge: this.bridgeName,
+ });
+
+ this.exitAudio();
+
+ reject(this.baseErrorCodes.REQUEST_TIMEOUT);
+ }, CALL_TRANSFER_TIMEOUT);
+
+ this.sendDtmf(TRANSFER_TONE);
+
+ Tracker.autorun((c) => {
+ trackerControl = c;
+ const selector = { meetingId: Auth.meetingID, userId: Auth.userID };
+ const query = VoiceCallStates.find(selector);
+
+ query.observeChanges({
+ changed: (id, fields) => {
+ if (fields.callState === CallStateOptions.IN_CONFERENCE) {
+ clearTimeout(timeout);
+ transferCallback();
+
+ c.stop();
+ resolve();
+ }
+ },
+ });
+ });
+ });
+ }
+}
diff --git a/src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js b/src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js
new file mode 100644
index 00000000..1f2cd0a9
--- /dev/null
+++ b/src/2.7.12/imports/api/audio/client/bridge/bridge-whitelist.js
@@ -0,0 +1,17 @@
+/**
+ * Bridge whitelist, needed for dynamically importing bridges (as modules).
+ *
+ * The code is intentionally unreachable, but its trigger Meteor's static
+ * analysis, which makes bridge module available to build process.
+ *
+ * For new bridges, we must append an import statement here.
+ *
+ * More information here:
+ *https://docs.meteor.com/packages/dynamic-import.html
+ */
+
+throw new Error();
+
+/* eslint-disable no-unreachable */
+// BRIDGES LIST
+import('/imports/api/audio/client/bridge/FullAudioBridge'); // NOSONAR
diff --git a/src/2.7.12/imports/api/audio/client/bridge/service.js b/src/2.7.12/imports/api/audio/client/bridge/service.js
new file mode 100644
index 00000000..d795db43
--- /dev/null
+++ b/src/2.7.12/imports/api/audio/client/bridge/service.js
@@ -0,0 +1,134 @@
+import Settings from '/imports/ui/services/settings';
+import logger from '/imports/startup/client/logger';
+import BBBStorage from '/imports/ui/services/storage';
+
+const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
+const DEFAULT_INPUT_DEVICE_ID = '';
+const DEFAULT_OUTPUT_DEVICE_ID = '';
+const INPUT_DEVICE_ID_KEY = 'audioInputDeviceId';
+const OUTPUT_DEVICE_ID_KEY = 'audioOutputDeviceId';
+const AUDIO_MICROPHONE_CONSTRAINTS = Meteor.settings.public.app.defaultSettings
+ .application.microphoneConstraints;
+const MEDIA_TAG = Meteor.settings.public.media.mediaTag;
+
+const getAudioSessionNumber = () => {
+ let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
+ if (!currItem) {
+ currItem = 0;
+ }
+
+ currItem += 1;
+ sessionStorage.setItem(AUDIO_SESSION_NUM_KEY, currItem);
+ return currItem;
+};
+
+const getCurrentAudioSessionNumber = () => sessionStorage.getItem(AUDIO_SESSION_NUM_KEY) || '0';
+
+const reloadAudioElement = (audioElement) => {
+ if (audioElement && (audioElement.readyState > 0)) {
+ audioElement.load();
+ return true;
+ }
+
+ return false;
+};
+
+const getCurrentAudioSinkId = () => {
+ const audioElement = document.querySelector(MEDIA_TAG);
+ return audioElement?.sinkId || DEFAULT_OUTPUT_DEVICE_ID;
+};
+
+const getStoredAudioInputDeviceId = () => BBBStorage.getItem(INPUT_DEVICE_ID_KEY);
+const getStoredAudioOutputDeviceId = () => BBBStorage.getItem(OUTPUT_DEVICE_ID_KEY);
+const storeAudioInputDeviceId = (deviceId) => BBBStorage.setItem(INPUT_DEVICE_ID_KEY, deviceId);
+const storeAudioOutputDeviceId = (deviceId) => BBBStorage.setItem(OUTPUT_DEVICE_ID_KEY, deviceId);
+
+/**
+ * Filter constraints set in audioDeviceConstraints, based on
+ * constants supported by browser. This avoids setting a constraint
+ * unsupported by browser. In currently safari version (13+), for example,
+ * setting an unsupported constraint crashes the audio.
+ * @param {Object} audioDeviceConstraints Constraints to be set
+ * see: https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
+ * @return {Object} A new Object of the same type as
+ * input, containing only the supported constraints.
+ */
+const filterSupportedConstraints = (audioDeviceConstraints) => {
+ try {
+ const matchConstraints = {};
+ const supportedConstraints = navigator
+ .mediaDevices.getSupportedConstraints() || {};
+ Object.entries(audioDeviceConstraints).forEach(
+ ([constraintName, constraintValue]) => {
+ if (supportedConstraints[constraintName]) {
+ matchConstraints[constraintName] = constraintValue;
+ }
+ },
+ );
+
+ return matchConstraints;
+ } catch (error) {
+ logger.error({
+ logCode: 'audio_unsupported_constraint_error',
+ }, 'Unsupported audio constraints');
+ return {};
+ }
+};
+
+const getAudioConstraints = (constraintFields = {}) => {
+ const { deviceId = '' } = constraintFields;
+ const userSettingsConstraints = Settings.application.microphoneConstraints;
+ const audioDeviceConstraints = userSettingsConstraints
+ || AUDIO_MICROPHONE_CONSTRAINTS || {};
+
+ const matchConstraints = filterSupportedConstraints(
+ audioDeviceConstraints,
+ );
+
+ if (deviceId) {
+ matchConstraints.deviceId = { exact: deviceId };
+ }
+
+ return matchConstraints;
+};
+
+const doGUM = async (constraints, retryOnFailure = false) => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia(constraints);
+ return stream;
+ } catch (error) {
+ // This is probably a deviceId mistmatch. Retry with base constraints
+ // without an exact deviceId.
+ if (error.name === 'OverconstrainedError' && retryOnFailure) {
+ logger.warn({
+ logCode: 'audio_overconstrainederror_rollback',
+ extraInfo: {
+ constraints,
+ },
+ }, 'Audio getUserMedia returned OverconstrainedError, rollback');
+
+ return navigator.mediaDevices.getUserMedia({ audio: getAudioConstraints() });
+ }
+
+ // Not OverconstrainedError - bubble up the error.
+ throw error;
+ }
+};
+
+export {
+ DEFAULT_INPUT_DEVICE_ID,
+ DEFAULT_OUTPUT_DEVICE_ID,
+ INPUT_DEVICE_ID_KEY,
+ OUTPUT_DEVICE_ID_KEY,
+ getAudioSessionNumber,
+ getCurrentAudioSessionNumber,
+ reloadAudioElement,
+ filterSupportedConstraints,
+ getAudioConstraints,
+ getCurrentAudioSinkId,
+ getStoredAudioInputDeviceId,
+ storeAudioInputDeviceId,
+ getStoredAudioOutputDeviceId,
+ storeAudioOutputDeviceId,
+ doGUM,
+};
diff --git a/src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js b/src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js
new file mode 100644
index 00000000..e26fec57
--- /dev/null
+++ b/src/2.7.12/imports/api/audio/client/bridge/sfu-audio-bridge.js
@@ -0,0 +1,483 @@
+import BaseAudioBridge from './base';
+import Auth from '/imports/ui/services/auth';
+import logger from '/imports/startup/client/logger';
+import AudioBroker from '/imports/ui/services/bbb-webrtc-sfu/audio-broker';
+import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play';
+import {
+ fetchWebRTCMappedStunTurnServers,
+ getMappedFallbackStun,
+} from '/imports/utils/fetchStunTurnServers';
+import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import browserInfo from '/imports/utils/browserInfo';
+import {
+ getAudioSessionNumber,
+ getAudioConstraints,
+ filterSupportedConstraints,
+ doGUM,
+} from '/imports/api/audio/client/bridge/service';
+import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
+
+const SFU_URL = Meteor.settings.public.kurento.wsUrl;
+const DEFAULT_LISTENONLY_MEDIA_SERVER = Meteor.settings.public.kurento.listenOnlyMediaServer;
+const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
+const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
+const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
+const MEDIA = Meteor.settings.public.media;
+const DEFAULT_FULLAUDIO_MEDIA_SERVER = MEDIA.audio.fullAudioMediaServer;
+const RETRY_THROUGH_RELAY = MEDIA.audio.retryThroughRelay || false;
+const LISTEN_ONLY_OFFERING = MEDIA.listenOnlyOffering;
+const FULLAUDIO_OFFERING = MEDIA.fullAudioOffering;
+const TRANSPARENT_LISTEN_ONLY = MEDIA.transparentListenOnly;
+const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, '');
+const CONNECTION_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000;
+const { audio: NETWORK_PRIORITY } = MEDIA.networkPriorities || {};
+const SENDRECV_ROLE = 'sendrecv';
+const RECV_ROLE = 'recv';
+const BRIDGE_NAME = 'fullaudio';
+const IS_CHROME = browserInfo.isChrome;
+
+// SFU's base broker has distinct error codes so that it can be reused by different
+// modules. Errors that have a valid, localized counterpart in audio manager are
+// mapped so that the user gets a localized error message.
+// The ones that haven't (ie SFU's servers-side errors), aren't mapped.
+const errorCodeMap = {
+ 1301: 1001,
+ 1302: 1002,
+ 1305: 1005,
+ 1307: 1007,
+};
+
+// Error codes that are prone to a retry according to RETRY_THROUGH_RELAY
+const RETRYABLE_ERRORS = [1007, 1010];
+
+const mapErrorCode = (error) => {
+ const { errorCode } = error;
+ const mappedErrorCode = errorCodeMap[errorCode];
+ if (errorCode == null || mappedErrorCode == null) return error;
+ // eslint-disable-next-line no-param-reassign
+ error.errorCode = mappedErrorCode;
+ return error;
+};
+
+const getMediaServerAdapter = (listenOnly = false) => {
+ if (listenOnly) {
+ return getFromMeetingSettings(
+ 'media-server-listenonly',
+ DEFAULT_LISTENONLY_MEDIA_SERVER,
+ );
+ }
+
+ return getFromMeetingSettings(
+ 'media-server-fullaudio',
+ DEFAULT_FULLAUDIO_MEDIA_SERVER,
+ );
+};
+
+const isTransparentListenOnlyEnabled = () => getFromUserSettings(
+ 'bbb_transparent_listen_only',
+ TRANSPARENT_LISTEN_ONLY,
+);
+
+export default class SFUAudioBridge extends BaseAudioBridge {
+ static getOfferingRole(isListenOnly) {
+ return isListenOnly
+ ? LISTEN_ONLY_OFFERING
+ : (!isTransparentListenOnlyEnabled() && FULLAUDIO_OFFERING);
+ }
+
+ constructor(userData) {
+ super();
+ this.userId = userData.userId;
+ this.name = userData.username;
+ this.sessionToken = userData.sessionToken;
+ this.broker = null;
+ this.reconnecting = false;
+ this.iceServers = [];
+ this.bridgeName = BRIDGE_NAME;
+
+ this.handleTermination = this.handleTermination.bind(this);
+ }
+
+ get inputStream() {
+ if (this.broker) {
+ return this.broker.getLocalStream();
+ }
+
+ return null;
+ }
+
+ get role() {
+ return this.broker?.role;
+ }
+
+ setInputStream(stream) {
+ if (this.broker == null) return null;
+
+ return this.broker.setLocalStream(stream);
+ }
+
+ getPeerConnection() {
+ if (!this.broker) return null;
+
+ const { webRtcPeer } = this.broker;
+ if (webRtcPeer) return webRtcPeer.peerConnection;
+ return null;
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ mediaStreamFactory(constraints) {
+ return doGUM(constraints, true);
+ }
+
+ setConnectionTimeout() {
+ if (this.connectionTimeout) this.clearConnectionTimeout();
+
+ this.connectionTimeout = setTimeout(() => {
+ const error = new Error(`ICE negotiation timeout after ${CONNECTION_TIMEOUT_MS / 1000}s`);
+ error.errorCode = 1010;
+ // Duplicating key-vals because I can'decide settle on an error pattern - prlanzarin again
+ error.errorCause = error.message;
+ error.errorMessage = error.message;
+ this.handleBrokerFailure(error);
+ }, CONNECTION_TIMEOUT_MS);
+ }
+
+ clearConnectionTimeout() {
+ if (this.connectionTimeout) {
+ clearTimeout(this.connectionTimeout);
+ this.connectionTimeout = null;
+ }
+ }
+
+ dispatchAutoplayHandlingEvent(mediaElement) {
+ const tagFailedEvent = new CustomEvent('audioPlayFailed', {
+ detail: { mediaElement },
+ });
+ window.dispatchEvent(tagFailedEvent);
+ this.callback({ status: this.baseCallStates.autoplayBlocked, bridge: this.bridgeName });
+ }
+
+ reconnect(options = {}) {
+ // If broker has already started, fire the reconnecting callback so the user
+ // knows what's going on
+ if (this.broker.started) {
+ this.callback({ status: this.baseCallStates.reconnecting, bridge: this.bridgeName });
+ } else {
+ // Otherwise: override termination handler so the ended callback doesn't get
+ // triggered - this is a retry attempt and the user shouldn't be notified
+ // yet.
+ this.broker.onended = () => {};
+ }
+
+ this.broker.stop();
+ this.reconnecting = true;
+ this._startBroker({ isListenOnly: this.isListenOnly, ...options })
+ .catch((error) => {
+ // Error handling is a no-op because it will be "handled" in handleBrokerFailure
+ logger.debug({
+ logCode: 'sfuaudio_reconnect_failed',
+ extraInfo: {
+ errorMessage: error.errorMessage,
+ reconnecting: this.reconnecting,
+ bridge: this.bridgeName,
+ role: this.role,
+ },
+ }, 'SFU audio reconnect failed');
+ });
+ }
+
+ handleBrokerFailure(error) {
+ return new Promise((resolve, reject) => {
+ this.clearConnectionTimeout();
+ mapErrorCode(error);
+ const { errorMessage, errorCause, errorCode } = error;
+
+ if (!this.reconnecting) {
+ if (this.broker.started) {
+ logger.error({
+ logCode: 'sfuaudio_error_try_to_reconnect',
+ extraInfo: {
+ errorMessage,
+ errorCode,
+ errorCause,
+ bridge: this.bridgeName,
+ role: this.role,
+ },
+ }, 'SFU audio failed, try to reconnect');
+ this.reconnect();
+ return resolve();
+ }
+
+ if (RETRYABLE_ERRORS.includes(errorCode) && RETRY_THROUGH_RELAY) {
+ logger.error({
+ logCode: 'sfuaudio_error_retry_through_relay',
+ extraInfo: {
+ errorMessage,
+ errorCode,
+ errorCause,
+ bridge: this.bridgeName,
+ role: this.role,
+ },
+ }, 'SFU audio failed to connect, retry through relay');
+ this.reconnect({ forceRelay: true });
+ return resolve();
+ }
+ }
+
+ // Already tried reconnecting once OR the user handn't succesfully
+ // connected firsthand and retrying isn't an option. Finish the session
+ // and reject with the error
+ logger.error({
+ logCode: 'sfuaudio_error',
+ extraInfo: {
+ errorMessage,
+ errorCode,
+ errorCause,
+ reconnecting: this.reconnecting,
+ bridge: this.bridgeName,
+ role: this.role,
+ },
+ }, 'SFU audio failed');
+ this.clearConnectionTimeout();
+ this.broker.stop();
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: errorCode,
+ bridgeError: errorMessage,
+ bridge: this.bridgeName,
+ });
+ return reject(error);
+ });
+ }
+
+ handleTermination() {
+ this.clearConnectionTimeout();
+ return this.callback({ status: this.baseCallStates.ended, bridge: this.bridgeName });
+ }
+
+ handleStart() {
+ const stream = this.broker.webRtcPeer.getRemoteStream();
+ const mediaElement = document.getElementById(MEDIA_TAG);
+
+ return loadAndPlayMediaStream(stream, mediaElement, false).then(() => {
+ this.callback({
+ status: this.baseCallStates.started,
+ bridge: this.bridgeName,
+ });
+ this.clearConnectionTimeout();
+ this.reconnecting = false;
+ }).catch((error) => {
+ // NotAllowedError equals autoplay issues, fire autoplay handling event.
+ // This will be handled in audio-manager.
+ if (error.name === 'NotAllowedError') {
+ logger.error({
+ logCode: 'sfuaudio_error_autoplay',
+ extraInfo: {
+ errorName: error.name,
+ bridge: this.bridgeName,
+ role: this.role,
+ },
+ }, 'SFU audio media play failed due to autoplay error');
+ this.dispatchAutoplayHandlingEvent(mediaElement);
+ // For connection purposes, this worked - the autoplay thing is a client
+ // side soft issue to be handled at the UI/UX level, not WebRTC/negotiation
+ // So: clear the connection timer
+ this.clearConnectionTimeout();
+ this.reconnecting = false;
+ } else {
+ const normalizedError = {
+ errorCode: 1004,
+ errorMessage: error.message || 'AUDIO_PLAY_FAILED',
+ };
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: normalizedError.errorCode,
+ bridgeError: normalizedError.errorMessage,
+ bridge: this.bridgeName,
+ });
+ throw normalizedError;
+ }
+ });
+ }
+
+ async _startBroker(options) {
+ try {
+ this.iceServers = await fetchWebRTCMappedStunTurnServers(this.sessionToken);
+ } catch (error) {
+ logger.error({ logCode: 'sfuaudio_stun-turn_fetch_failed' },
+ 'SFU audio bridge failed to fetch STUN/TURN info, using default servers');
+ this.iceServers = getMappedFallbackStun();
+ }
+
+ return new Promise((resolve, reject) => {
+ const {
+ isListenOnly,
+ extension,
+ inputStream,
+ forceRelay: _forceRelay = false,
+ } = options;
+
+ const handleInitError = (_error) => {
+ mapErrorCode(_error);
+ if (RETRYABLE_ERRORS.includes(_error?.errorCode)
+ || !RETRY_THROUGH_RELAY
+ || this.reconnecting) {
+ reject(_error);
+ }
+ };
+
+ try {
+ this.inEchoTest = !!extension;
+ this.isListenOnly = isListenOnly;
+
+ const brokerOptions = {
+ clientSessionNumber: getAudioSessionNumber(),
+ extension,
+ iceServers: this.iceServers,
+ mediaServer: getMediaServerAdapter(isListenOnly),
+ constraints: getAudioConstraints({ deviceId: this.inputDeviceId }),
+ forceRelay: _forceRelay || shouldForceRelay(),
+ stream: (inputStream && inputStream.active) ? inputStream : undefined,
+ offering: SFUAudioBridge.getOfferingRole(this.isListenOnly),
+ signalCandidates: SIGNAL_CANDIDATES,
+ traceLogs: TRACE_LOGS,
+ networkPriority: NETWORK_PRIORITY,
+ mediaStreamFactory: this.mediaStreamFactory,
+ gatheringTimeout: GATHERING_TIMEOUT,
+ transparentListenOnly: isTransparentListenOnlyEnabled(),
+ };
+
+ this.broker = new AudioBroker(
+ Auth.authenticateURL(SFU_URL),
+ isListenOnly ? RECV_ROLE : SENDRECV_ROLE,
+ brokerOptions,
+ );
+
+ this.broker.onended = this.handleTermination.bind(this);
+ this.broker.onerror = (error) => {
+ this.handleBrokerFailure(error).catch(reject);
+ };
+ this.broker.onstart = () => {
+ this.handleStart().then(resolve).catch(reject);
+ };
+
+ // Set up a connectionTimeout in case the server or network are botching
+ // negotiation or conn checks.
+ this.setConnectionTimeout();
+ this.broker.joinAudio().catch(handleInitError);
+ } catch (error) {
+ handleInitError(error);
+ }
+ });
+ }
+
+ async joinAudio(options, callback) {
+ this.callback = callback;
+ this.reconnecting = false;
+
+ return this._startBroker(options);
+ }
+
+ sendDtmf(tones) {
+ if (this.broker) {
+ this.broker.dtmf(tones);
+ }
+ }
+
+ transferCall(onTransferSuccess) {
+ this.inEchoTest = false;
+ return this.trackTransferState(onTransferSuccess);
+ }
+
+ async updateAudioConstraints(constraints) {
+ try {
+ if (typeof constraints !== 'object') return;
+
+ const matchConstraints = filterSupportedConstraints(constraints);
+
+ if (IS_CHROME) {
+ matchConstraints.deviceId = this.inputDeviceId;
+ const stream = await doGUM({ audio: matchConstraints });
+ await this.setInputStream(stream);
+ } else {
+ this.inputStream.getAudioTracks()
+ .forEach((track) => track.applyConstraints(matchConstraints));
+ }
+ } catch (error) {
+ logger.error({
+ logCode: 'sfuaudio_audio_constraint_error',
+ extraInfo: {
+ errorCode: error.code,
+ errorMessage: error.message,
+ bridgeName: this.bridgeName,
+ role: this.role,
+ },
+ }, 'Failed to update audio constraint');
+ }
+ }
+
+ trickleIce() {
+ return new Promise((resolve, reject) => {
+ try {
+ fetchWebRTCMappedStunTurnServers(this.sessionToken)
+ .then((iceServers) => {
+ const options = {
+ clientSessionNumber: getAudioSessionNumber(),
+ iceServers,
+ offering: LISTEN_ONLY_OFFERING,
+ traceLogs: TRACE_LOGS,
+ gatheringTimeout: GATHERING_TIMEOUT,
+ };
+
+ this.broker = new AudioBroker(
+ Auth.authenticateURL(SFU_URL),
+ RECV_ROLE,
+ options,
+ );
+
+ this.broker.onstart = () => {
+ const { peerConnection } = this.broker.webRtcPeer;
+
+ if (!peerConnection) return resolve(null);
+
+ const selectedCandidatePair = peerConnection.getReceivers()[0]
+ .transport.iceTransport.getSelectedCandidatePair();
+
+ const validIceCandidate = [selectedCandidatePair.local];
+
+ this.broker.stop();
+ return resolve(validIceCandidate);
+ };
+
+ this.broker.joinAudio().catch(reject);
+ });
+ } catch (error) {
+ // Rollback
+ this.exitAudio();
+ reject(error);
+ }
+ });
+ }
+
+ exitAudio() {
+ const mediaElement = document.getElementById(MEDIA_TAG);
+
+ this.clearConnectionTimeout();
+ this.reconnecting = false;
+
+ if (this.broker) {
+ this.broker.stop();
+ this.broker = null;
+ }
+
+ if (mediaElement && typeof mediaElement.pause === 'function') {
+ mediaElement.pause();
+ mediaElement.srcObject = null;
+ }
+
+ return Promise.resolve();
+ }
+}
+
+module.exports = SFUAudioBridge;
diff --git a/src/2.7.12/imports/api/audio/client/bridge/sip.js b/src/2.7.12/imports/api/audio/client/bridge/sip.js
new file mode 100644
index 00000000..e7f013c9
--- /dev/null
+++ b/src/2.7.12/imports/api/audio/client/bridge/sip.js
@@ -0,0 +1,1331 @@
+import BaseAudioBridge from './base';
+import logger from '/imports/startup/client/logger';
+import {
+ fetchWebRTCMappedStunTurnServers,
+ getMappedFallbackStun,
+} from '/imports/utils/fetchStunTurnServers';
+import {
+ isUnifiedPlan,
+ toUnifiedPlan,
+ toPlanB,
+ stripMDnsCandidates,
+ filterValidIceCandidates,
+ analyzeSdp,
+ logSelectedCandidate,
+ forceDisableStereo,
+} from '/imports/utils/sdpUtils';
+import { Tracker } from 'meteor/tracker';
+import VoiceCallStates from '/imports/api/voice-call-states';
+import CallStateOptions from '/imports/api/voice-call-states/utils/callStates';
+import Auth from '/imports/ui/services/auth';
+import browserInfo from '/imports/utils/browserInfo';
+import {
+ getCurrentAudioSessionNumber,
+ getAudioSessionNumber,
+ getAudioConstraints,
+ filterSupportedConstraints,
+ doGUM,
+} from '/imports/api/audio/client/bridge/service';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+
+const MEDIA = Meteor.settings.public.media;
+const MEDIA_TAG = MEDIA.mediaTag;
+const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
+const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
+const SIPJS_HACK_VIA_WS = MEDIA.sipjsHackViaWs;
+const SIPJS_ALLOW_MDNS = MEDIA.sipjsAllowMdns || false;
+const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain;
+const CALL_CONNECT_TIMEOUT = 20000;
+const ICE_NEGOTIATION_TIMEOUT = 20000;
+const USER_AGENT_RECONNECTION_ATTEMPTS = MEDIA.audioReconnectionAttempts || 3;
+const USER_AGENT_RECONNECTION_DELAY_MS = MEDIA.audioReconnectionDelay || 5000;
+const USER_AGENT_CONNECTION_TIMEOUT_MS = MEDIA.audioConnectionTimeout || 5000;
+const ICE_GATHERING_TIMEOUT = MEDIA.iceGatheringTimeout || 5000;
+const BRIDGE_NAME = 'sip';
+const WEBSOCKET_KEEP_ALIVE_INTERVAL = MEDIA.websocketKeepAliveInterval || 0;
+const WEBSOCKET_KEEP_ALIVE_DEBOUNCE = MEDIA.websocketKeepAliveDebounce || 10;
+const TRACE_SIP = MEDIA.traceSip || false;
+const SDP_SEMANTICS = MEDIA.sdpSemantics;
+const FORCE_RELAY = MEDIA.forceRelay;
+
+const UA_SERVER_VERSION = Meteor.settings.public.app.bbbServerVersion;
+const UA_CLIENT_VERSION = Meteor.settings.public.app.html5ClientBuild;
+
+/**
+ * Get error code from SIP.js websocket messages.
+ */
+const getErrorCode = (error) => {
+ try {
+ if (!error) return error;
+
+ const match = error.message.match(/code: \d+/g);
+
+ const _codeArray = match[0].split(':');
+
+ return parseInt(_codeArray[1].trim(), 10);
+ } catch (e) {
+ return 0;
+ }
+};
+
+class SIPSession {
+ constructor(user, userData, protocol, hostname,
+ baseCallStates, baseErrorCodes, reconnectAttempt) {
+ this.user = user;
+ this.userData = userData;
+ this.protocol = protocol;
+ this.hostname = hostname;
+ this.baseCallStates = baseCallStates;
+ this.baseErrorCodes = baseErrorCodes;
+ this.reconnectAttempt = reconnectAttempt;
+ this.currentSession = null;
+ this.remoteStream = null;
+ this.bridgeName = BRIDGE_NAME;
+ this._inputDeviceId = null;
+ this._outputDeviceId = null;
+ this._hangupFlag = false;
+ this._reconnecting = false;
+ this._currentSessionState = null;
+ this._ignoreCallState = false;
+
+ this.mediaStreamFactory = this.mediaStreamFactory.bind(this)
+ }
+
+ get inputStream() {
+ if (this.currentSession && this.currentSession.sessionDescriptionHandler) {
+ return this.currentSession.sessionDescriptionHandler.localMediaStream;
+ }
+ return null;
+ }
+
+ /**
+ * Set the input stream for the peer that represents the current session.
+ * Internally, this will call the sender's replaceTrack function.
+ * @param {MediaStream} stream The MediaStream object to be used as input
+ * stream
+ * @return {Promise} A Promise that is resolved with the
+ * MediaStream object that was set.
+ */
+ setInputStream(stream) {
+ if (!this.currentSession?.sessionDescriptionHandler) return null;
+
+ return this.currentSession.sessionDescriptionHandler.setLocalMediaStream(stream);
+ }
+
+ get inputDeviceId() {
+ if (!this._inputDeviceId) {
+ const stream = this.inputStream;
+
+ if (stream) {
+ const track = stream.getAudioTracks().find(
+ (t) => t.getSettings().deviceId,
+ );
+
+ if (track && (typeof track.getSettings === 'function')) {
+ const { deviceId } = track.getSettings();
+ this._inputDeviceId = deviceId;
+ }
+ }
+ }
+
+ return this._inputDeviceId;
+ }
+
+ set inputDeviceId(deviceId) {
+ this._inputDeviceId = deviceId;
+ }
+
+ get outputDeviceId() {
+ if (!this._outputDeviceId) {
+ const audioElement = document.querySelector(MEDIA_TAG);
+ if (audioElement) {
+ this._outputDeviceId = audioElement.sinkId;
+ }
+ }
+
+ return this._outputDeviceId;
+ }
+
+ set outputDeviceId(deviceId) {
+ this._outputDeviceId = deviceId;
+ }
+
+ /**
+ * This _ignoreCallState flag is set to true when we want to ignore SIP's
+ * call state retrieved directly from FreeSWITCH ESL, when doing some checks
+ * (for example , when checking if call stopped).
+ * We need to ignore this , for example, when moderator is in
+ * breakout audio transfer ("Join Audio" button in breakout panel): in this
+ * case , we will monitor moderator's lifecycle in audio conference by
+ * using the SIP state taken from SIP.js only (ignoring the ESL's call state).
+ * @param {boolean} value true to ignore call state, false otherwise.
+ */
+ set ignoreCallState(value) {
+ this._ignoreCallState = value;
+ }
+
+ get ignoreCallState() {
+ return this._ignoreCallState;
+ }
+
+ joinAudio({
+ isListenOnly,
+ extension,
+ inputDeviceId,
+ outputDeviceId,
+ validIceCandidates,
+ inputStream,
+ }, managerCallback) {
+ return new Promise((resolve, reject) => {
+ const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge;
+
+ this.ignoreCallState = false;
+
+ const callback = (message) => {
+ // There will sometimes we erroneous errors put out like timeouts and improper shutdowns,
+ // but only the first error ever matters
+ if (this.alreadyErrored) {
+ logger.info({
+ logCode: 'sip_js_absorbing_callback_message',
+ extraInfo: { message },
+ }, 'Absorbing a redundant callback message.');
+ return;
+ }
+
+ if (message.status === this.baseCallStates.failed) {
+ this.alreadyErrored = true;
+ }
+
+ managerCallback(message).then(resolve);
+ };
+
+ this.callback = callback;
+
+ // If there's an extension passed it means that we're joining the echo test first
+ this.inEchoTest = !!extension;
+
+ this.validIceCandidates = validIceCandidates;
+ return this.doCall({
+ callExtension,
+ isListenOnly,
+ inputDeviceId,
+ outputDeviceId,
+ inputStream,
+ }).catch((reason) => {
+ reject(reason);
+ });
+ });
+ }
+
+ async getIceServers(sessionToken) {
+ try {
+ const iceServers = await fetchWebRTCMappedStunTurnServers(sessionToken);
+ return iceServers;
+ } catch (error) {
+ logger.error({
+ logCode: 'sip_js_fetchstunturninfo_error',
+ extraInfo: {
+ errorCode: error.code,
+ errorMessage: error.message,
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'Full audio bridge failed to fetch STUN/TURN info');
+ return getMappedFallbackStun();
+ }
+ }
+
+ doCall(options) {
+ const {
+ isListenOnly,
+ inputDeviceId,
+ outputDeviceId,
+ inputStream,
+ } = options;
+
+ this.inputDeviceId = inputDeviceId;
+ this.outputDeviceId = outputDeviceId;
+ // If a valid MediaStream was provided it means it was preloaded somewhere
+ // else - let's use it so we don't call gUM needlessly
+ if (inputStream && inputStream.active) this.preloadedInputStream = inputStream;
+
+ const {
+ userId,
+ name,
+ sessionToken,
+ } = this.user;
+
+ const callerIdName = [
+ `${userId}_${getAudioSessionNumber()}`,
+ 'bbbID',
+ isListenOnly ? `LISTENONLY-${name}` : name,
+ ].join('-').replace(/"/g, "'");
+
+ this.user.callerIdName = callerIdName;
+ this.callOptions = options;
+
+ return this.getIceServers(sessionToken)
+ .then(this.createUserAgent.bind(this))
+ .then(this.inviteUserAgent.bind(this));
+ }
+
+ /**
+ *
+ * sessionSupportRTPPayloadDtmf
+ * tells if browser support RFC4733 DTMF.
+ * Safari 13 doens't support it yet
+ */
+ sessionSupportRTPPayloadDtmf(session) {
+ try {
+ const sessionDescriptionHandler = session
+ ? session.sessionDescriptionHandler
+ : this.currentSession.sessionDescriptionHandler;
+
+ const senders = sessionDescriptionHandler.peerConnection.getSenders();
+ return !!(senders[0].dtmf);
+ } catch (error) {
+ return false;
+ }
+ }
+
+ /**
+ * sendDtmf - send DTMF Tones using INFO message
+ *
+ * same as SimpleUser's dtmf
+ */
+ sendDtmf(tone) {
+ const dtmf = tone;
+ const duration = 2000;
+ const body = {
+ contentDisposition: 'render',
+ contentType: 'application/dtmf-relay',
+ content: `Signal=${dtmf}\r\nDuration=${duration}`,
+ };
+ const requestOptions = { body };
+ return this.currentSession.info({ requestOptions });
+ }
+
+ exitAudio() {
+ return new Promise((resolve, reject) => {
+ let hangupRetries = 0;
+ this._hangupFlag = false;
+
+ this.userRequestedHangup = true;
+
+ const tryHangup = () => {
+ if (this._hangupFlag) {
+ resolve();
+ }
+
+ if ((this.currentSession
+ && (this.currentSession.state === SIP.SessionState.Terminated))
+ || (this.userAgent && (!this.userAgent.isConnected()))) {
+ this._hangupFlag = true;
+ return resolve();
+ }
+
+ if (this.currentSession
+ && ((this.currentSession.state === SIP.SessionState.Establishing))) {
+ this.currentSession.cancel().then(() => {
+ this._hangupFlag = true;
+ return resolve();
+ });
+ }
+
+ if (this.currentSession
+ && ((this.currentSession.state === SIP.SessionState.Established))) {
+ this.currentSession.bye().then(() => {
+ this._hangupFlag = true;
+ return resolve();
+ });
+ }
+
+ if (this.userAgent && this.userAgent.isConnected()) {
+ this.userAgent.stop();
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ }
+
+
+ hangupRetries += 1;
+
+ setTimeout(() => {
+ if (hangupRetries > CALL_HANGUP_MAX_RETRIES) {
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1006,
+ bridgeError: 'Timeout on call hangup',
+ bridge: this.bridgeName,
+ });
+ return reject(this.baseErrorCodes.REQUEST_TIMEOUT);
+ }
+
+ if (!this._hangupFlag) return tryHangup();
+ return resolve();
+ }, CALL_HANGUP_TIMEOUT);
+ };
+
+ return tryHangup();
+ });
+ }
+
+ stopUserAgent() {
+ if (this.userAgent && (typeof this.userAgent.stop === 'function')) {
+ return this.userAgent.stop();
+ }
+ return Promise.resolve();
+ }
+
+ onBeforeUnload() {
+ this.userRequestedHangup = true;
+ return this.stopUserAgent();
+ }
+
+ mediaStreamFactory(constraints) {
+ if (this.preloadedInputStream && this.preloadedInputStream.active) {
+ return Promise.resolve(this.preloadedInputStream);
+ }
+ // The rest of this mimicks the default factory behavior.
+ if (!constraints.audio && !constraints.video) {
+ return Promise.resolve(new MediaStream());
+ }
+
+ return doGUM(constraints, true);
+ }
+
+ createUserAgent(iceServers) {
+ return new Promise((resolve, reject) => {
+ if (this.userRequestedHangup === true) reject();
+
+ const {
+ hostname,
+ protocol,
+ } = this;
+
+ const {
+ callerIdName,
+ sessionToken,
+ } = this.user;
+
+ logger.debug({ logCode: 'sip_js_creating_user_agent', extraInfo: { callerIdName } }, 'Creating the user agent');
+
+ if (this.userAgent && this.userAgent.isConnected()) {
+ if (this.userAgent.configuration.hostPortParams === this.hostname) {
+ logger.debug({ logCode: 'sip_js_reusing_user_agent', extraInfo: { callerIdName } }, 'Reusing the user agent');
+ resolve(this.userAgent);
+ return;
+ }
+ logger.debug({ logCode: 'sip_js_different_host_name', extraInfo: { callerIdName } }, 'Different host name. need to kill');
+ }
+
+ const localSdpCallback = (sdp) => {
+ // For now we just need to call the utils function to parse and log the different pieces.
+ // In the future we're going to want to be tracking whether there were TURN candidates
+ // and IPv4 candidates to make informed decisions about what to do on fallbacks/reconnects.
+ analyzeSdp(sdp);
+ };
+
+ const remoteSdpCallback = (sdp) => {
+ // We have have to find the candidate that FS sends back to us to determine if the client
+ // is connecting with IPv4 or IPv6
+ const sdpInfo = analyzeSdp(sdp, false);
+ this.protocolIsIpv6 = sdpInfo.v6Info.found;
+ };
+
+ let userAgentConnected = false;
+ const token = `sessionToken=${sessionToken}`;
+
+ // Create session description handler factory
+ const customSDHFactory = SIP.Web.defaultSessionDescriptionHandlerFactory(this.mediaStreamFactory);
+
+ this.userAgent = new SIP.UserAgent({
+ uri: SIP.UserAgent.makeURI(`sip:${encodeURIComponent(callerIdName)}@${hostname}`),
+ transportOptions: {
+ server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
+ connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS,
+ keepAliveInterval: WEBSOCKET_KEEP_ALIVE_INTERVAL,
+ keepAliveDebounce: WEBSOCKET_KEEP_ALIVE_DEBOUNCE,
+ traceSip: TRACE_SIP,
+ },
+ sessionDescriptionHandlerFactory: customSDHFactory,
+ sessionDescriptionHandlerFactoryOptions: {
+ peerConnectionConfiguration: {
+ iceServers,
+ sdpSemantics: SDP_SEMANTICS,
+ iceTransportPolicy: FORCE_RELAY ? 'relay' : undefined,
+ },
+ },
+ displayName: callerIdName,
+ register: false,
+ userAgentString: `BigBlueButton/${UA_SERVER_VERSION} (HTML5, rv:${UA_CLIENT_VERSION}) ${window.navigator.userAgent}`,
+ hackViaWs: SIPJS_HACK_VIA_WS,
+ });
+
+ const handleUserAgentConnection = () => {
+ if (!userAgentConnected) {
+ userAgentConnected = true;
+ resolve(this.userAgent);
+ }
+ };
+
+ const handleUserAgentDisconnection = () => {
+ if (this.userAgent) {
+ if (this.userRequestedHangup) {
+ userAgentConnected = false;
+ return;
+ }
+
+ let error;
+ let bridgeError;
+
+ if (!this._reconnecting) {
+ logger.info({
+ logCode: 'sip_js_session_ua_disconnected',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent disconnected: trying to reconnect...'
+ + ` (userHangup = ${!!this.userRequestedHangup})`);
+
+ logger.info({
+ logCode: 'sip_js_session_ua_reconnecting',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent disconnected, reconnecting');
+
+ this.reconnect().then(() => {
+ logger.info({
+ logCode: 'sip_js_session_ua_reconnected',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent succesfully reconnected');
+ }).catch(() => {
+ if (userAgentConnected) {
+ error = 1001;
+ bridgeError = 'Websocket disconnected';
+ } else {
+ error = 1002;
+ bridgeError = 'Websocket failed to connect';
+ }
+
+ this.stopUserAgent();
+
+ this.callback({
+ status: this.baseCallStates.failed,
+ error,
+ bridgeError,
+ bridge: this.bridgeName,
+ });
+ reject(this.baseErrorCodes.CONNECTION_ERROR);
+ });
+ }
+ }
+ };
+
+ this.userAgent.transport.onConnect = handleUserAgentConnection;
+ this.userAgent.transport.onDisconnect = handleUserAgentDisconnection;
+
+ const preturn = this.userAgent.start().then(() => {
+ logger.info({
+ logCode: 'sip_js_session_ua_connected',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent succesfully connected');
+
+ window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
+
+ resolve();
+ }).catch((error) => {
+ logger.info({
+ logCode: 'sip_js_session_ua_reconnecting',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent failed to connect, reconnecting');
+
+ const code = getErrorCode(error);
+
+ // Websocket's 1006 is currently mapped to BBB's 1002
+ if (code === 1006) {
+ this.stopUserAgent();
+
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1002,
+ bridgeError: 'Websocket failed to connect',
+ bridge: this.bridgeName,
+ });
+ return reject({
+ type: this.baseErrorCodes.CONNECTION_ERROR,
+ });
+ }
+
+ this.reconnect().then(() => {
+ logger.info({
+ logCode: 'sip_js_session_ua_reconnected',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent succesfully reconnected');
+
+ resolve();
+ }).catch(() => {
+ this.stopUserAgent();
+
+ logger.info({
+ logCode: 'sip_js_session_ua_disconnected',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'User agent failed to reconnect after'
+ + ` ${USER_AGENT_RECONNECTION_ATTEMPTS} attemps`);
+
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1002,
+ bridgeError: 'Websocket failed to connect',
+ bridge: this.bridgeName,
+ });
+
+ reject({
+ type: this.baseErrorCodes.CONNECTION_ERROR,
+ });
+ });
+ });
+
+ return preturn;
+ });
+ }
+
+ reconnect(attempts = 1) {
+ return new Promise((resolve, reject) => {
+ if (this._reconnecting) {
+ return resolve();
+ }
+
+ if (attempts > USER_AGENT_RECONNECTION_ATTEMPTS) {
+ return reject({
+ type: this.baseErrorCodes.CONNECTION_ERROR,
+ });
+ }
+
+ this._reconnecting = true;
+
+ logger.info({
+ logCode: 'sip_js_session_ua_reconnection_attempt',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, `User agent reconnection attempt ${attempts}`);
+
+ this.userAgent.reconnect().then(() => {
+ this._reconnecting = false;
+ resolve();
+ }).catch(() => {
+ setTimeout(() => {
+ this._reconnecting = false;
+ this.reconnect(++attempts).then(() => {
+ resolve();
+ }).catch((error) => {
+ reject(error);
+ });
+ }, USER_AGENT_RECONNECTION_DELAY_MS);
+ });
+ });
+ }
+
+ isValidIceCandidate(event) {
+ return event.candidate
+ && this.validIceCandidates
+ && this.validIceCandidates.find((validCandidate) => (
+ (validCandidate.address === event.candidate.address)
+ || (validCandidate.relatedAddress === event.candidate.address))
+ && (validCandidate.protocol === event.candidate.protocol));
+ }
+
+ onIceGatheringStateChange(event) {
+ const iceGatheringState = event.target
+ ? event.target.iceGatheringState
+ : null;
+
+ if ((iceGatheringState === 'gathering') && (!this._iceGatheringStartTime)) {
+ this._iceGatheringStartTime = new Date();
+ }
+
+ if (iceGatheringState === 'complete') {
+ const secondsToGatherIce = (new Date()
+ - (this._iceGatheringStartTime || this._sessionStartTime)) / 1000;
+
+ logger.info({
+ logCode: 'sip_js_ice_gathering_time',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ secondsToGatherIce,
+ },
+ }, `ICE gathering candidates took (s): ${secondsToGatherIce}`);
+ }
+ }
+
+ onIceCandidate(sessionDescriptionHandler, event) {
+ if (this.isValidIceCandidate(event)) {
+ logger.info({
+ logCode: 'sip_js_found_valid_candidate_from_trickle_ice',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'Found a valid candidate from trickle ICE, finishing gathering');
+
+ if (sessionDescriptionHandler.iceGatheringCompleteResolve) {
+ sessionDescriptionHandler.iceGatheringCompleteResolve();
+ }
+ }
+ }
+
+ initSessionDescriptionHandler(sessionDescriptionHandler) {
+ /* eslint-disable no-param-reassign */
+ sessionDescriptionHandler.peerConnectionDelegate = {
+ onicecandidate:
+ this.onIceCandidate.bind(this, sessionDescriptionHandler),
+ onicegatheringstatechange:
+ this.onIceGatheringStateChange.bind(this),
+ };
+ /* eslint-enable no-param-reassign */
+ }
+
+ inviteUserAgent(userAgent) {
+ return new Promise((resolve, reject) => {
+ if (this.userRequestedHangup === true) reject();
+ const {
+ hostname,
+ } = this;
+
+ const {
+ callExtension,
+ isListenOnly,
+ } = this.callOptions;
+
+ this._sessionStartTime = new Date();
+
+ const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`);
+
+ const matchConstraints = getAudioConstraints({ deviceId: this.inputDeviceId });
+ const sessionDescriptionHandlerModifiers = [];
+ const iceModifiers = [
+ filterValidIceCandidates.bind(this, this.validIceCandidates),
+ ];
+
+ if (!SIPJS_ALLOW_MDNS) iceModifiers.push(stripMDnsCandidates);
+
+ // The current Vosk provider does not support stereo when transcribing
+ // microphone streams, so we need to make sure it is forcefully disabled
+ // via SDP munging. Having it disabled on server side FS _does not suffice_
+ // because the stereo parameter is client-mandated (ie replicated in the
+ // answer)
+ if (SpeechService.stereoUnsupported()) {
+ logger.debug({
+ logCode: 'sipjs_transcription_disable_stereo',
+ }, 'Transcription provider does not support stereo, forcing stereo=0');
+ sessionDescriptionHandlerModifiers.push(forceDisableStereo);
+ }
+
+ const inviterOptions = {
+ sessionDescriptionHandlerOptions: {
+ constraints: {
+ audio: isListenOnly
+ ? false
+ : matchConstraints,
+ video: false,
+ },
+ iceGatheringTimeout: ICE_GATHERING_TIMEOUT,
+ },
+ sessionDescriptionHandlerModifiers,
+ sessionDescriptionHandlerModifiersPostICEGathering: iceModifiers,
+ delegate: {
+ onSessionDescriptionHandler:
+ this.initSessionDescriptionHandler.bind(this),
+ },
+ };
+
+ if (isListenOnly) {
+ inviterOptions.sessionDescriptionHandlerOptions.offerOptions = {
+ offerToReceiveAudio: true,
+ };
+ }
+
+ const inviter = new SIP.Inviter(userAgent, target, inviterOptions);
+ this.currentSession = inviter;
+
+ this.setupEventHandlers(inviter).then(() => {
+ inviter.invite().then(() => {
+ resolve();
+ }).catch(e => reject(e));
+ });
+ });
+ }
+
+ setupEventHandlers(currentSession) {
+ return new Promise((resolve, reject) => {
+ if (this.userRequestedHangup === true) reject();
+
+ let iceCompleted = false;
+ let fsReady = false;
+ let sessionTerminated = false;
+
+ const setupRemoteMedia = () => {
+ const mediaElement = document.querySelector(MEDIA_TAG);
+ const { sdp } = this.currentSession.sessionDescriptionHandler
+ .peerConnection.remoteDescription;
+
+ logger.info({
+ logCode: 'sip_js_session_setup_remote_media',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ sdp,
+ },
+ }, 'Audio call - setup remote media');
+
+ this.remoteStream = new MediaStream();
+ this.currentSession.sessionDescriptionHandler
+ .peerConnection.getReceivers().forEach((receiver) => {
+ if (receiver.track) {
+ this.remoteStream.addTrack(receiver.track);
+ }
+ });
+
+ logger.info({
+ logCode: 'sip_js_session_playing_remote_media',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'Audio call - playing remote media');
+
+ mediaElement.srcObject = this.remoteStream;
+ mediaElement.play();
+ };
+
+ const checkIfCallReady = () => {
+ if (this.userRequestedHangup === true) {
+ this.exitAudio();
+ resolve();
+ }
+
+ logger.info({
+ logCode: 'sip_js_session_check_if_call_ready',
+ extraInfo: {
+ iceCompleted,
+ fsReady,
+ },
+ }, 'Audio call - check if ICE is finished and FreeSWITCH is ready');
+
+ if (iceCompleted) {
+ this.webrtcConnected = true;
+ setupRemoteMedia();
+ }
+
+ if (fsReady) {
+ this.callback({ status: this.baseCallStates.started, bridge: this.bridgeName });
+
+ resolve();
+ }
+ };
+
+ // Sometimes FreeSWITCH just won't respond with anything and hangs. This timeout is to
+ // avoid that issue
+ const callTimeout = setTimeout(() => {
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1006,
+ bridgeError: `Call timed out on start after ${CALL_CONNECT_TIMEOUT / 1000}s`,
+ bridge: this.bridgeName,
+ });
+
+ this.exitAudio();
+ }, CALL_CONNECT_TIMEOUT);
+
+ let iceNegotiationTimeout;
+
+ const handleSessionAccepted = () => {
+ logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted');
+ clearTimeout(callTimeout);
+
+ // If ICE isn't connected yet then start timeout waiting for ICE to finish
+ if (!iceCompleted) {
+ iceNegotiationTimeout = setTimeout(() => {
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1010,
+ bridgeError: 'ICE negotiation timeout after '
+ + `${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
+ bridge: this.bridgeName,
+ });
+
+ this.exitAudio();
+
+ reject({
+ type: this.baseErrorCodes.CONNECTION_ERROR,
+ });
+ }, ICE_NEGOTIATION_TIMEOUT);
+ }
+ checkIfCallReady();
+ };
+
+ const handleIceNegotiationFailed = (peer) => {
+ if (iceCompleted) {
+ logger.warn({
+ logCode: 'sipjs_ice_failed_after',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'ICE connection failed after success');
+ } else {
+ logger.warn({
+ logCode: 'sipjs_ice_failed_before',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'ICE connection failed before success');
+ }
+ clearTimeout(callTimeout);
+ clearTimeout(iceNegotiationTimeout);
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1007,
+ bridgeError: 'ICE negotiation failed. Current state '
+ + `- ${peer.iceConnectionState}`,
+ bridge: this.bridgeName,
+ });
+ };
+
+ const handleIceConnectionTerminated = (peer) => {
+ if (!this.userRequestedHangup) {
+ logger.warn({
+ logCode: 'sipjs_ice_closed',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'ICE connection closed');
+ } else return;
+
+ this.callback({
+ status: this.baseCallStates.failed,
+ error: 1012,
+ bridgeError: 'ICE connection closed. Current state -'
+ + `${peer.iceConnectionState}`,
+ bridge: this.bridgeName,
+ });
+ };
+
+ const handleSessionProgress = (update) => {
+ logger.info({
+ logCode: 'sip_js_session_progress',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ update,
+ },
+ }, 'Audio call session progress update');
+
+ this.currentSession.sessionDescriptionHandler.peerConnectionDelegate
+ .onconnectionstatechange = (event) => {
+ const peer = event.target;
+
+ logger.info({
+ logCode: 'sip_js_connection_state_change',
+ extraInfo: {
+ connectionStateChange: peer.connectionState,
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'ICE connection state change - Current connection state - '
+ + `${peer.connectionState}`);
+
+ switch (peer.connectionState) {
+ case 'failed':
+ // Chrome triggers 'failed' for connectionState event, only
+ handleIceNegotiationFailed(peer);
+ break;
+ default:
+ break;
+ }
+ };
+
+ this.currentSession.sessionDescriptionHandler.peerConnectionDelegate
+ .oniceconnectionstatechange = (event) => {
+ const peer = event.target;
+
+ switch (peer.iceConnectionState) {
+ case 'completed':
+ case 'connected':
+ if (iceCompleted) {
+ logger.info({
+ logCode: 'sip_js_ice_connection_success_after_success',
+ extraInfo: {
+ currentState: peer.connectionState,
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'ICE connection success, but user is already connected, '
+ + 'ignoring it...'
+ + `${peer.iceConnectionState}`);
+
+ return;
+ }
+
+ logger.info({
+ logCode: 'sip_js_ice_connection_success',
+ extraInfo: {
+ currentState: peer.connectionState,
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'ICE connection success. Current ICE Connection state - '
+ + `${peer.iceConnectionState}`);
+
+ clearTimeout(callTimeout);
+ clearTimeout(iceNegotiationTimeout);
+
+ iceCompleted = true;
+
+ logSelectedCandidate(peer, this.protocolIsIpv6);
+
+ checkIfCallReady();
+ break;
+ case 'failed':
+ handleIceNegotiationFailed(peer);
+ break;
+
+ case 'closed':
+ handleIceConnectionTerminated(peer);
+ break;
+ default:
+ break;
+ }
+ };
+ };
+
+ const checkIfCallStopped = (message) => {
+ if ((!this.ignoreCallState && fsReady) || !sessionTerminated) {
+ return null;
+ }
+
+ if (!message && !!this.userRequestedHangup) {
+ return this.callback({
+ status: this.baseCallStates.ended,
+ bridge: this.bridgeName,
+ });
+ }
+
+ // if session hasn't even started, we let audio-modal to handle
+ // any possile errors
+ if (!this._currentSessionState) return false;
+
+
+ let mappedCause;
+ let cause;
+ if (!iceCompleted) {
+ mappedCause = '1004';
+ cause = 'ICE error';
+ } else {
+ cause = 'Audio Conference Error';
+ mappedCause = '1005';
+ }
+
+ logger.warn({
+ logCode: 'sip_js_call_terminated',
+ extraInfo: { cause, callerIdName: this.user.callerIdName },
+ }, `Audio call terminated. cause=${cause}`);
+
+ return this.callback({
+ status: this.baseCallStates.failed,
+ error: mappedCause,
+ bridgeError: cause,
+ bridge: this.bridgeName,
+ });
+ }
+
+ const handleSessionTerminated = (message) => {
+ logger.info({
+ logCode: 'sip_js_session_terminated',
+ extraInfo: { callerIdName: this.user.callerIdName },
+ }, 'SIP.js session terminated');
+
+ clearTimeout(callTimeout);
+ clearTimeout(iceNegotiationTimeout);
+
+ sessionTerminated = true;
+ checkIfCallStopped();
+ };
+
+ currentSession.stateChange.addListener((state) => {
+ switch (state) {
+ case SIP.SessionState.Initial:
+ break;
+ case SIP.SessionState.Establishing:
+ handleSessionProgress();
+ break;
+ case SIP.SessionState.Established:
+ handleSessionAccepted();
+ break;
+ case SIP.SessionState.Terminating:
+ break;
+ case SIP.SessionState.Terminated:
+ handleSessionTerminated();
+ break;
+ default:
+ logger.warn({
+ logCode: 'sipjs_ice_session_unknown_state',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'SIP.js unknown session state');
+ break;
+ }
+ this._currentSessionState = state;
+ });
+
+ Tracker.autorun((c) => {
+ const selector = {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ clientSession: getCurrentAudioSessionNumber(),
+ };
+
+ const query = VoiceCallStates.find(selector);
+ const callback = (id, fields) => {
+ if (!fsReady && ((this.inEchoTest && fields.callState === CallStateOptions.IN_ECHO_TEST)
+ || (!this.inEchoTest && fields.callState === CallStateOptions.IN_CONFERENCE))) {
+ fsReady = true;
+ checkIfCallReady();
+ }
+
+ if (fields.callState === CallStateOptions.CALL_ENDED) {
+ fsReady = false;
+ c.stop();
+ checkIfCallStopped();
+ }
+ };
+
+ query.observeChanges({
+ added: (id, fields) => callback(id, fields),
+ changed: (id, fields) => callback(id, fields),
+ });
+ });
+
+ resolve();
+ });
+ }
+
+ /**
+ * Update audio constraints for current local MediaStream (microphone)
+ * @param {Object} constraints MediaTrackConstraints object. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
+ * @return {Promise} A Promise for this process
+ */
+ async updateAudioConstraints(constraints) {
+ try {
+ if (typeof constraints !== 'object') return;
+
+ logger.info({
+ logCode: 'sipjs_update_audio_constraint',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'SIP.js updating audio constraint');
+
+ const matchConstraints = filterSupportedConstraints(constraints);
+
+ //Chromium bug - see: https://bugs.chromium.org/p/chromium/issues/detail?id=796964&q=applyConstraints&can=2
+ const { isChrome } = browserInfo;
+
+ if (isChrome) {
+ matchConstraints.deviceId = this.inputDeviceId;
+
+ const stream = await doGUM({ audio: matchConstraints });
+
+ this.currentSession.sessionDescriptionHandler
+ .setLocalMediaStream(stream);
+ } else {
+ const { localMediaStream } = this.currentSession
+ .sessionDescriptionHandler;
+
+ localMediaStream.getAudioTracks().forEach(
+ track => track.applyConstraints(matchConstraints),
+ );
+ }
+ } catch (error) {
+ logger.error({
+ logCode: 'sipjs_audio_constraint_error',
+ extraInfo: {
+ callerIdName: this.user.callerIdName,
+ },
+ }, 'SIP.js failed to update audio constraint');
+ }
+ }
+}
+
+export default class SIPBridge extends BaseAudioBridge {
+ constructor(userData) {
+ super(userData);
+
+ const {
+ userId,
+ username,
+ sessionToken,
+ } = userData;
+
+ this.user = {
+ userId,
+ sessionToken,
+ name: username,
+ };
+
+ this.protocol = window.document.location.protocol;
+ if (MEDIA['sip_ws_host'] != null && MEDIA['sip_ws_host'] != '') {
+ this.hostname = MEDIA.sip_ws_host;
+ } else {
+ this.hostname = window.document.location.hostname;
+ }
+
+ this.bridgeName = BRIDGE_NAME;
+
+ // SDP conversion utilitary methods to be used inside SIP.js
+ window.isUnifiedPlan = isUnifiedPlan;
+ window.toUnifiedPlan = toUnifiedPlan;
+ window.toPlanB = toPlanB;
+ window.stripMDnsCandidates = stripMDnsCandidates;
+
+ // No easy way to expose the client logger to sip.js code so we need to attach it globally
+ window.clientLogger = logger;
+ }
+
+ get inputStream() {
+ return this.activeSession ? this.activeSession.inputStream : null;
+ }
+
+ /**
+ * Wrapper for SIPSession's ignoreCallState flag
+ * @param {boolean} value
+ */
+ set ignoreCallState(value) {
+ if (this.activeSession) {
+ this.activeSession.ignoreCallState = value;
+ }
+ }
+
+ get ignoreCallState() {
+ return this.activeSession ? this.activeSession.ignoreCallState : false;
+ }
+
+ joinAudio({
+ isListenOnly,
+ extension,
+ validIceCandidates,
+ inputStream,
+ }, managerCallback) {
+ const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== '';
+
+ return new Promise((resolve, reject) => {
+ let { hostname } = this;
+
+ this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
+ hostname, this.baseCallStates, this.baseErrorCodes, false);
+
+ const callback = (message) => {
+ if (message.status === this.baseCallStates.failed) {
+ let shouldTryReconnect = false;
+
+ // Try and get the call to clean up and end on an error
+ this.activeSession.exitAudio().catch(() => { });
+
+ if (this.activeSession.webrtcConnected) {
+ // webrtc was able to connect so just try again
+ message.silenceNotifications = true;
+ callback({ status: this.baseCallStates.reconnecting, bridge: this.bridgeName, });
+ shouldTryReconnect = true;
+ } else if (hasFallbackDomain === true && hostname !== IPV4_FALLBACK_DOMAIN) {
+ message.silenceNotifications = true;
+ logger.info({ logCode: 'sip_js_attempt_ipv4_fallback', extraInfo: { callerIdName: this.user.callerIdName } }, 'Attempting to fallback to IPv4 domain for audio');
+ hostname = IPV4_FALLBACK_DOMAIN;
+ shouldTryReconnect = true;
+ }
+
+ if (shouldTryReconnect) {
+ const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined;
+ this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
+ hostname, this.baseCallStates, this.baseErrorCodes, true);
+ const { inputDeviceId, outputDeviceId } = this;
+ this.activeSession.joinAudio({
+ isListenOnly,
+ extension: fallbackExtension,
+ inputDeviceId,
+ outputDeviceId,
+ validIceCandidates,
+ inputStream,
+ }, callback)
+ .then((value) => {
+ resolve(value);
+ }).catch((reason) => {
+ reject(reason);
+ });
+ }
+ }
+
+ return managerCallback(message);
+ };
+
+ const { inputDeviceId, outputDeviceId } = this;
+ this.activeSession.joinAudio({
+ isListenOnly,
+ extension,
+ inputDeviceId,
+ outputDeviceId,
+ validIceCandidates,
+ inputStream,
+ }, callback)
+ .then((value) => {
+ resolve(value);
+ }).catch((reason) => {
+ reject(reason);
+ });
+ });
+ }
+
+ transferCall(onTransferSuccess) {
+ this.activeSession.inEchoTest = false;
+ logger.debug({
+ logCode: 'sip_js_rtp_payload_send_dtmf',
+ extraInfo: {
+ callerIdName: this.activeSession.user.callerIdName,
+ },
+ }, 'Sending DTMF INFO to transfer user');
+
+ return this.trackTransferState(onTransferSuccess);
+ }
+
+ sendDtmf(tones) {
+ this.activeSession.sendDtmf(tones);
+ }
+
+ getPeerConnection() {
+ if (!this.activeSession) return null;
+
+ const { currentSession } = this.activeSession;
+ if (currentSession && currentSession.sessionDescriptionHandler) {
+ return currentSession.sessionDescriptionHandler.peerConnection;
+ }
+ return null;
+ }
+
+ exitAudio() {
+ if (this.activeSession == null) return Promise.resolve();
+
+ return this.activeSession.exitAudio();
+ }
+
+ setInputStream(stream) {
+ return this.activeSession.setInputStream(stream);
+ }
+
+ async updateAudioConstraints(constraints) {
+ return this.activeSession.updateAudioConstraints(constraints);
+ }
+}
+
+module.exports = SIPBridge;
diff --git a/src/2.7.12/imports/api/auth-token-validation/index.js b/src/2.7.12/imports/api/auth-token-validation/index.js
new file mode 100644
index 00000000..d3fd1e1e
--- /dev/null
+++ b/src/2.7.12/imports/api/auth-token-validation/index.js
@@ -0,0 +1,20 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const AuthTokenValidation = new Mongo.Collection('auth-token-validation', collectionOptions);
+
+if (Meteor.isServer) {
+ AuthTokenValidation.createIndexAsync({ connectionId: 1 });
+}
+
+export const ValidationStates = Object.freeze({
+ NOT_VALIDATED: 1,
+ VALIDATING: 2,
+ VALIDATED: 3,
+ INVALID: 4,
+});
+
+export default AuthTokenValidation;
diff --git a/src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js
new file mode 100644
index 00000000..5d03738f
--- /dev/null
+++ b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation.js
@@ -0,0 +1,15 @@
+import AuthTokenValidation from '/imports/api/auth-token-validation';
+import Logger from '/imports/startup/server/logger';
+import ClientConnections from '/imports/startup/server/ClientConnections';
+
+export default async function clearAuthTokenValidation(meetingId) {
+ try {
+ await AuthTokenValidation.removeAsync({ meetingId });
+ if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') {
+ ClientConnections.removeMeeting(meetingId);
+ }
+ Logger.info(`Cleared AuthTokenValidation (${meetingId})`);
+ } catch (error) {
+ Logger.info(`Error when removing auth-token-validation for meeting=${meetingId}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js
new file mode 100644
index 00000000..fa233b64
--- /dev/null
+++ b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/removeValidationState.js
@@ -0,0 +1,14 @@
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation from '/imports/api/auth-token-validation';
+
+export default async function removeValidationState(meetingId, userId, connectionId) {
+ const selector = {
+ meetingId, userId, connectionId,
+ };
+
+ try {
+ await AuthTokenValidation.removeAsync(selector);
+ } catch (error) {
+ Logger.error(`Could not remove from collection AuthTokenValidation: ${error}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js
new file mode 100644
index 00000000..e8a7b3e9
--- /dev/null
+++ b/src/2.7.12/imports/api/auth-token-validation/server/modifiers/upsertValidationState.js
@@ -0,0 +1,36 @@
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation from '/imports/api/auth-token-validation';
+
+export default async function upsertValidationState(
+ meetingId,
+ userId,
+ validationStatus,
+ connectionId,
+ reason = null,
+) {
+ const selector = {
+ meetingId, userId, connectionId,
+ };
+ const modifier = {
+ $set: {
+ meetingId,
+ userId,
+ connectionId,
+ validationStatus,
+ updatedAt: new Date().getTime(),
+ reason,
+ },
+ };
+
+ try {
+ await AuthTokenValidation
+ .removeAsync({ meetingId, userId, connectionId: { $ne: connectionId } });
+ const { numberAffected } = AuthTokenValidation.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Upserted ${JSON.stringify(selector)} ${validationStatus} in AuthTokenValidation`);
+ }
+ } catch (err) {
+ Logger.error(`Could not upsert to collection AuthTokenValidation: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts-history/index.js b/src/2.7.12/imports/api/breakouts-history/index.js
new file mode 100644
index 00000000..381eb4f1
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts-history/index.js
@@ -0,0 +1,13 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const BreakoutsHistory = new Mongo.Collection('breakouts-history', collectionOptions);
+
+if (Meteor.isServer) {
+ BreakoutsHistory.createIndexAsync({ meetingId: 1 });
+}
+
+export default BreakoutsHistory;
diff --git a/src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js b/src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js
new file mode 100644
index 00000000..4cbdc273
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts-history/server/eventHandlers.js
@@ -0,0 +1,6 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleBreakoutRoomsList from './handlers/breakoutRoomsList';
+import messageToAllSent from '/imports/api/breakouts-history/server/handlers/messageToAllSent';
+
+RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList);
+RedisPubSub.on('SendMessageToAllBreakoutRoomsEvtMsg', messageToAllSent);
diff --git a/src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js b/src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js
new file mode 100644
index 00000000..47943e00
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js
@@ -0,0 +1,35 @@
+import { check } from 'meteor/check';
+import BreakoutsHistory from '/imports/api/breakouts-history';
+import Logger from '/imports/startup/server/logger';
+
+export default async function handleBreakoutRoomsList({ body }) {
+ const {
+ meetingId,
+ rooms,
+ } = body;
+
+ check(meetingId, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ meetingId,
+ rooms,
+ },
+ };
+
+ try {
+ const { insertedId } = await BreakoutsHistory.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added rooms to breakout-history Data: meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted rooms to breakout-history Data: meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding rooms to the collection breakout-history: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js b/src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js
new file mode 100644
index 00000000..0eab16fb
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts-history/server/handlers/messageToAllSent.js
@@ -0,0 +1,42 @@
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import BreakoutsHistory from '/imports/api/breakouts-history';
+
+export default async function handleSendMessageToAllBreakoutRoomsEvtMsg({ body }, meetingId) {
+ const {
+ senderId,
+ msg,
+ totalOfRooms,
+ } = body;
+
+ check(meetingId, String);
+ check(senderId, String);
+ check(msg, String);
+ check(totalOfRooms, Number);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $push: {
+ broadcastMsgs: {
+ senderId,
+ msg,
+ totalOfRooms,
+ },
+ },
+ };
+
+ try {
+ const { insertedId } = await BreakoutsHistory.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added broadCastMsg to breakout-history Data: meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted broadCastMsg to breakout-history Data: meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding broadCastMsg to the collection breakout-history: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts-history/server/index.js b/src/2.7.12/imports/api/breakouts-history/server/index.js
new file mode 100644
index 00000000..f993f38e
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts-history/server/index.js
@@ -0,0 +1,2 @@
+import './eventHandlers';
+import './publishers';
diff --git a/src/2.7.12/imports/api/breakouts-history/server/publishers.js b/src/2.7.12/imports/api/breakouts-history/server/publishers.js
new file mode 100644
index 00000000..4026acee
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts-history/server/publishers.js
@@ -0,0 +1,58 @@
+import BreakoutsHistory from '/imports/api/breakouts-history';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+import Logger from '/imports/startup/server/logger';
+import Meetings from '/imports/api/meetings';
+import Users from '/imports/api/users';
+import { publicationSafeGuard } from '/imports/api/common/server/helpers';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+async function breakoutsHistory() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Meetings-history was requested by unauth connection ${this.connection.id}`);
+ return Meetings.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+ Logger.debug('Publishing Breakouts-History', { meetingId, userId });
+
+ const User = await Users.findOneAsync({ userId, meetingId }, { fields: { userId: 1, role: 1 } });
+ if (!User || User.role !== ROLE_MODERATOR) {
+ return BreakoutsHistory.find({ meetingId: '' });
+ }
+
+ check(meetingId, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ // Monitor this publication and stop it when user is not a moderator anymore
+ const comparisonFunc = async () => {
+ const user = await Users
+ .findOneAsync({ userId, meetingId }, { fields: { role: 1, userId: 1 } });
+ const condition = user.role === ROLE_MODERATOR;
+
+ if (!condition) {
+ Logger.info(`conditions aren't filled anymore in publication ${this._name}:
+ user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`);
+ }
+
+ return condition;
+ };
+ publicationSafeGuard(comparisonFunc, this);
+
+ return BreakoutsHistory.find(selector);
+}
+
+function publish(...args) {
+ const boundUsers = breakoutsHistory.bind(this);
+ return boundUsers(...args);
+}
+
+Meteor.publish('breakouts-history', publish);
diff --git a/src/2.7.12/imports/api/breakouts/index.js b/src/2.7.12/imports/api/breakouts/index.js
new file mode 100644
index 00000000..ce89c17b
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/index.js
@@ -0,0 +1,18 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Breakouts = new Mongo.Collection('breakouts', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the breakouts:
+ // 1. breakoutId ( handleJoinUrl, roomStarted, clearBreakouts )
+ // 2. parentMeetingId ( updateTimeRemaining )
+
+ Breakouts.createIndexAsync({ breakoutId: 1 });
+ Breakouts.createIndexAsync({ parentMeetingId: 1 });
+}
+
+export default Breakouts;
diff --git a/src/2.7.12/imports/api/breakouts/server/eventHandlers.js b/src/2.7.12/imports/api/breakouts/server/eventHandlers.js
new file mode 100644
index 00000000..db0dc7d1
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/eventHandlers.js
@@ -0,0 +1,14 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleBreakoutJoinURL from './handlers/breakoutJoinURL';
+import handleBreakoutRoomsList from './handlers/breakoutList';
+import handleUpdateTimeRemaining from './handlers/updateTimeRemaining';
+import handleBreakoutClosed from './handlers/breakoutClosed';
+import joinedUsersChanged from './handlers/joinedUsersChanged';
+import userBreakoutChanged from '/imports/api/breakouts/server/handlers/userBreakoutChanged';
+
+RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList);
+RedisPubSub.on('BreakoutRoomJoinURLEvtMsg', handleBreakoutJoinURL);
+RedisPubSub.on('BreakoutRoomsTimeRemainingUpdateEvtMsg', handleUpdateTimeRemaining);
+RedisPubSub.on('BreakoutRoomEndedEvtMsg', handleBreakoutClosed);
+RedisPubSub.on('UpdateBreakoutUsersEvtMsg', joinedUsersChanged);
+RedisPubSub.on('ChangeUserBreakoutEvtMsg', userBreakoutChanged);
diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js
new file mode 100644
index 00000000..5a85235d
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutClosed.js
@@ -0,0 +1,9 @@
+import { check } from 'meteor/check';
+import clearBreakouts from '../modifiers/clearBreakouts';
+
+export default async function handleBreakoutClosed({ body }) {
+ const { breakoutId } = body;
+ check(breakoutId, String);
+ const result = await clearBreakouts(breakoutId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js
new file mode 100644
index 00000000..091ad33b
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutJoinURL.js
@@ -0,0 +1,47 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Breakouts from '/imports/api/breakouts';
+
+export default async function handleBreakoutJoinURL({ body }) {
+ const {
+ redirectToHtml5JoinURL,
+ userId,
+ breakoutId,
+ } = body;
+
+ check(redirectToHtml5JoinURL, String);
+
+ const selector = {
+ breakoutId,
+ };
+
+ const modifier = {
+ $set: {
+ [`url_${userId}`]: {
+ redirectToHtml5JoinURL,
+ insertedTime: new Date().getTime(),
+ },
+ },
+ };
+
+ try {
+ const ATTEMPT_EVERY_MS = 1000;
+
+ let numberAffected = 0;
+ const updateBreakout = async () => {
+ numberAffected = await Breakouts.updateAsync(selector, modifier);
+ };
+
+ await new Promise((resolve) => {
+ const updateBreakoutInterval = setInterval(async () => {
+ await updateBreakout();
+ if (numberAffected) {
+ resolve(clearInterval(updateBreakoutInterval));
+ }
+ }, ATTEMPT_EVERY_MS);
+ });
+ Logger.info(`Upserted breakout id=${breakoutId}`);
+ } catch (err) {
+ Logger.error(`Adding breakout to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js
new file mode 100644
index 00000000..9ff5d5cd
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/handlers/breakoutList.js
@@ -0,0 +1,57 @@
+import Breakouts from '/imports/api/breakouts';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import flat from 'flat';
+import handleBreakoutRoomsListHist from '/imports/api/breakouts-history/server/handlers/breakoutRoomsList';
+
+export default async function handleBreakoutRoomsList({ body }, meetingId) {
+ // 0 seconds default breakout time, forces use of real expiration time
+ const DEFAULT_TIME_REMAINING = 0;
+
+ const {
+ meetingId: parentMeetingId,
+ rooms,
+ sendInviteToModerators,
+ } = body;
+
+ // set firstly the last seq, then client will know when receive all
+ await rooms.sort((a, b) => ((a.sequence < b.sequence) ? 1 : -1)).forEach(async (breakout) => {
+ const { breakoutId, html5JoinUrls, ...breakoutWithoutUrls } = breakout;
+
+ check(meetingId, String);
+
+ const selector = {
+ breakoutId,
+ };
+
+ const urls = {};
+ if (typeof html5JoinUrls === 'object' && Object.keys(html5JoinUrls).length > 0) {
+ Object.keys(html5JoinUrls).forEach((userId) => {
+ urls[`url_${userId}`] = {
+ redirectToHtml5JoinURL: html5JoinUrls[userId],
+ insertedTime: new Date().getTime(),
+ };
+ });
+ }
+
+ const modifier = {
+ $set: {
+ breakoutId,
+ joinedUsers: [],
+ timeRemaining: DEFAULT_TIME_REMAINING,
+ parentMeetingId,
+ sendInviteToModerators,
+ ...flat(breakoutWithoutUrls),
+ ...urls,
+ },
+ };
+ const numberAffected = await Breakouts.upsertAsync(selector, modifier);
+ if (numberAffected) {
+ Logger.info('Updated timeRemaining and externalMeetingId '
+ + `for breakout id=${breakoutId}`);
+ } else {
+ Logger.error(`updating breakout: ${numberAffected}`);
+ }
+ handleBreakoutRoomsListHist({ body });
+ });
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js b/src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js
new file mode 100644
index 00000000..029077a8
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/handlers/joinedUsersChanged.js
@@ -0,0 +1,53 @@
+import Breakouts from '/imports/api/breakouts';
+import updateUserBreakoutRoom from '/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import { lowercaseTrim } from '/imports/utils/string-utils';
+
+export default async function joinedUsersChanged({ body }) {
+ check(body, Object);
+
+ const {
+ parentId,
+ breakoutId,
+ users,
+ } = body;
+
+ check(parentId, String);
+ check(breakoutId, String);
+ check(users, Array);
+
+ const selector = {
+ parentMeetingId: parentId,
+ breakoutId,
+ };
+
+ const usersMapped = users
+ .map((user) => ({ userId: user.id, name: user.name, sortName: lowercaseTrim(user.name) }));
+ const modifier = {
+ $set: {
+ joinedUsers: usersMapped,
+ },
+ };
+
+ try {
+ const numberAffected = await Breakouts.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ await updateUserBreakoutRoom(parentId, breakoutId, users);
+
+ Logger.info(`Updated joined users in breakout id=${breakoutId}`);
+ }
+ } catch (err) {
+ Logger.error(`updating joined users in breakout: ${err}`);
+ }
+ // .then((res) => {
+ // if (res.numberAffected) {
+ // updateUserBreakoutRoom(parentId, breakoutId, users);
+
+ // Logger.info(`Updated joined users in breakout id=${breakoutId}`);
+ // }
+ // }).catch((err) => {
+ // Logger.error(`updating joined users in breakout: ${err}`);
+ // });
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js b/src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js
new file mode 100644
index 00000000..b6abcc5c
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/handlers/updateTimeRemaining.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Breakouts from '/imports/api/breakouts';
+
+export default async function handleUpdateTimeRemaining({ body }, meetingId) {
+ const {
+ timeRemaining,
+ } = body;
+
+ check(meetingId, String);
+ check(timeRemaining, Number);
+
+ const selector = {
+ parentMeetingId: meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ timeRemaining,
+ },
+ };
+
+ const options = {
+ multi: true,
+ };
+ const numberAffected = Breakouts.updateAsync(selector, modifier, options);
+
+ if (numberAffected) {
+ Logger.info(`Updated breakout time remaining for breakouts where parentMeetingId=${meetingId}`);
+ } else {
+ Logger.error(`Updating breakouts: ${numberAffected}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js b/src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js
new file mode 100644
index 00000000..63329de9
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/handlers/userBreakoutChanged.js
@@ -0,0 +1,65 @@
+import Breakouts from '/imports/api/breakouts';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+export default async function userBreakoutChanged({ body }) {
+ check(body, Object);
+
+ const {
+ meetingId,
+ userId,
+ fromBreakoutId,
+ toBreakoutId,
+ redirectToHtml5JoinURL,
+ } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(fromBreakoutId, String);
+ check(toBreakoutId, String);
+ check(redirectToHtml5JoinURL, String);
+
+ const oldBreakoutSelector = {
+ parentMeetingId: meetingId,
+ breakoutId: fromBreakoutId,
+ freeJoin: false,
+ };
+
+ const newBreakoutSelector = {
+ parentMeetingId: meetingId,
+ breakoutId: toBreakoutId,
+ };
+
+ const oldModifier = {
+ $unset: {
+ [`url_${userId}`]: '',
+ },
+ };
+
+ const newModifier = {
+ $set: {
+ [`url_${userId}`]: {
+ redirectToHtml5JoinURL,
+ insertedTime: new Date().getTime(),
+ },
+ },
+ };
+
+ try {
+ let numberAffectedRows = 0;
+
+ if (oldBreakoutSelector.breakoutId !== '') {
+ numberAffectedRows += await Breakouts.updateAsync(oldBreakoutSelector, oldModifier);
+ }
+
+ if (newBreakoutSelector.breakoutId !== '') {
+ numberAffectedRows += await Breakouts.updateAsync(newBreakoutSelector, newModifier);
+ }
+
+ if (numberAffectedRows > 0) {
+ Logger.info(`Updated user breakout for userId=${userId}`);
+ }
+ } catch (err) {
+ Logger.error(`Updating user breakout: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/index.js b/src/2.7.12/imports/api/breakouts/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/breakouts/server/methods.js b/src/2.7.12/imports/api/breakouts/server/methods.js
new file mode 100644
index 00000000..27b26c4d
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods.js
@@ -0,0 +1,16 @@
+import { Meteor } from 'meteor/meteor';
+import createBreakoutRoom from '/imports/api/breakouts/server/methods/createBreakout';
+import requestJoinURL from './methods/requestJoinURL';
+import endAllBreakouts from './methods/endAllBreakouts';
+import setBreakoutsTime from '/imports/api/breakouts/server/methods/setBreakoutsTime';
+import sendMessageToAllBreakouts from './methods/sendMessageToAllBreakouts';
+import moveUser from '/imports/api/breakouts/server/methods/moveUser';
+
+Meteor.methods({
+ requestJoinURL,
+ createBreakoutRoom,
+ endAllBreakouts,
+ setBreakoutsTime,
+ sendMessageToAllBreakouts,
+ moveUser,
+});
diff --git a/src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js b/src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js
new file mode 100644
index 00000000..2c3c80f9
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods/createBreakout.js
@@ -0,0 +1,39 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+export default function createBreakoutRoom(rooms, durationInMinutes, record = false, captureNotes = false, captureSlides = false, sendInviteToModerators = false) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit;
+ const MIN_BREAKOUT_ROOMS = 2;
+ const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
+ const EVENT_NAME = 'CreateBreakoutRoomsCmdMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ if (rooms.length > MAX_BREAKOUT_ROOMS) {
+ Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`);
+ return;
+ }
+ const payload = {
+ record,
+ captureNotes,
+ captureSlides,
+ durationInMinutes,
+ rooms,
+ meetingId,
+ sendInviteToModerators,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method createBreakoutRoom ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js b/src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js
new file mode 100644
index 00000000..fb0ae194
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods/endAllBreakouts.js
@@ -0,0 +1,22 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function endAllBreakouts() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'EndAllBreakoutRoomsMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, null);
+ } catch (err) {
+ Logger.error(`Exception while invoking method endAllBreakouts ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/methods/moveUser.js b/src/2.7.12/imports/api/breakouts/server/methods/moveUser.js
new file mode 100644
index 00000000..e017cee7
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods/moveUser.js
@@ -0,0 +1,32 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function moveUser(fromBreakoutId, toBreakoutId, userIdToMove) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserBreakoutReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const userId = userIdToMove || requesterUserId;
+
+ return RedisPubSub.publishUserMessage(
+ CHANNEL, EVENT_NAME, meetingId, requesterUserId,
+ {
+ meetingId,
+ fromBreakoutId,
+ toBreakoutId,
+ userId,
+ },
+ );
+ } catch (err) {
+ Logger.error(`Exception while invoking method moveUser ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js b/src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js
new file mode 100644
index 00000000..ce5e3b9d
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods/requestJoinURL.js
@@ -0,0 +1,31 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function requestJoinURL({ breakoutId, userId: userIdToInvite }) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'RequestBreakoutJoinURLReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const userId = userIdToInvite || requesterUserId;
+
+ return RedisPubSub.publishUserMessage(
+ CHANNEL, EVENT_NAME, meetingId, requesterUserId,
+ {
+ meetingId,
+ breakoutId,
+ userId,
+ },
+ );
+ } catch (err) {
+ Logger.error(`Exception while invoking method requestJoinURL ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js b/src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js
new file mode 100644
index 00000000..3aa9fe3a
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods/sendMessageToAllBreakouts.js
@@ -0,0 +1,28 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function sendMessageToAllBreakouts({ msg }) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SendMessageToAllBreakoutRoomsReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ RedisPubSub.publishUserMessage(
+ CHANNEL, EVENT_NAME, meetingId, requesterUserId,
+ {
+ meetingId,
+ msg,
+ },
+ );
+ } catch (err) {
+ Logger.error(`Exception while invoking method sendMessageToAllBreakouts ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js b/src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js
new file mode 100644
index 00000000..00016928
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/methods/setBreakoutsTime.js
@@ -0,0 +1,28 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function setBreakoutsTime({ timeInMinutes }) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UpdateBreakoutRoomsTimeReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ RedisPubSub.publishUserMessage(
+ CHANNEL, EVENT_NAME, meetingId, requesterUserId,
+ {
+ meetingId,
+ timeInMinutes,
+ },
+ );
+ } catch (err) {
+ Logger.error(`Exception while invoking method setBreakoutsTime ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js b/src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js
new file mode 100644
index 00000000..2ae0685d
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/modifiers/clearBreakouts.js
@@ -0,0 +1,29 @@
+import Logger from '/imports/startup/server/logger';
+import Breakouts from '/imports/api/breakouts';
+
+export default async function clearBreakouts(breakoutId) {
+ if (breakoutId) {
+ const selector = {
+ breakoutId,
+ };
+
+ try {
+ const numberAffected = await Breakouts.removeAsync(selector);
+
+ if (numberAffected) {
+ Logger.info(`Cleared Breakouts (${breakoutId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing Breakouts (${breakoutId})`);
+ }
+ } else {
+ try {
+ const numberAffected = await Breakouts.removeAsync({});
+ if (numberAffected) {
+ Logger.info('Cleared Breakouts (all)');
+ }
+ } catch (err) {
+ Logger.error('Error on clearing Breakouts (all)');
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/breakouts/server/publishers.js b/src/2.7.12/imports/api/breakouts/server/publishers.js
new file mode 100644
index 00000000..85605129
--- /dev/null
+++ b/src/2.7.12/imports/api/breakouts/server/publishers.js
@@ -0,0 +1,89 @@
+import { Meteor } from 'meteor/meteor';
+import Breakouts from '/imports/api/breakouts';
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+import { publicationSafeGuard } from '/imports/api/common/server/helpers';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+async function breakouts() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Breakouts was requested by unauth connection ${this.connection.id}`);
+ return Breakouts.find({ meetingId: '' });
+ }
+ const { meetingId, userId } = tokenValidation;
+
+ const User = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1 } });
+ Logger.debug('Publishing Breakouts', { meetingId, userId });
+
+ const fields = {
+ fields: {
+ [`url_${userId}`]: 1,
+ breakoutId: 1,
+ externalId: 1,
+ freeJoin: 1,
+ isDefaultName: 1,
+ joinedUsers: 1,
+ name: 1,
+ parentMeetingId: 1,
+ sequence: 1,
+ shortName: 1,
+ timeRemaining: 1,
+ captureNotes: 1,
+ captureSlides: 1,
+ sendInviteToModerators: 1,
+ },
+ };
+
+ if (!!User && User.role === ROLE_MODERATOR) {
+ const presenterSelector = {
+ $or: [
+ { parentMeetingId: meetingId },
+ { breakoutId: meetingId },
+ ],
+ };
+ // Monitor this publication and stop it when user is not a moderator anymore
+ const comparisonFunc = async () => {
+ const user = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1, userId: 1 } });
+ const condition = user.role === ROLE_MODERATOR;
+
+ if (!condition) {
+ Logger.info(`conditions aren't filled anymore in publication ${this._name}:
+ user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`);
+ }
+
+ return condition;
+ };
+ publicationSafeGuard(comparisonFunc, this);
+ return Breakouts.find(presenterSelector, fields);
+ }
+
+ const selector = {
+ $or: [
+ {
+ parentMeetingId: meetingId,
+ freeJoin: true,
+ },
+ {
+ parentMeetingId: meetingId,
+ [`url_${userId}`]: { $exists: true },
+ },
+ {
+ breakoutId: meetingId,
+ },
+ ],
+ };
+
+ return Breakouts.find(selector, fields);
+}
+
+function publish(...args) {
+ const boundBreakouts = breakouts.bind(this);
+ return boundBreakouts(...args);
+}
+
+Meteor.publish('breakouts', publish);
diff --git a/src/2.7.12/imports/api/captions/index.js b/src/2.7.12/imports/api/captions/index.js
new file mode 100644
index 00000000..9e1790b5
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/index.js
@@ -0,0 +1,13 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Captions = new Mongo.Collection('captions', collectionOptions);
+
+if (Meteor.isServer) {
+ Captions.createIndexAsync({ meetingId: 1, locale: 1 });
+}
+
+export default Captions;
diff --git a/src/2.7.12/imports/api/captions/server/eventHandlers.js b/src/2.7.12/imports/api/captions/server/eventHandlers.js
new file mode 100644
index 00000000..02297a9d
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/eventHandlers.js
@@ -0,0 +1,4 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import captionsOwnerUpdated from './handlers/captionsOwnerUpdated';
+
+RedisPubSub.on('UpdateCaptionOwnerEvtMsg', captionsOwnerUpdated);
diff --git a/src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js b/src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js
new file mode 100644
index 00000000..b9c56f1a
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/handlers/captionsOwnerUpdated.js
@@ -0,0 +1,11 @@
+import updateCaptionsOwner from '/imports/api/captions/server/modifiers/updateCaptionsOwner';
+
+export default async function captionsOwnerUpdated({ header, body }) {
+ const { meetingId } = header;
+ const {
+ locale,
+ ownerId,
+ } = body;
+
+ await updateCaptionsOwner(meetingId, locale, ownerId);
+}
diff --git a/src/2.7.12/imports/api/captions/server/helpers.js b/src/2.7.12/imports/api/captions/server/helpers.js
new file mode 100644
index 00000000..eef4c9ad
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/helpers.js
@@ -0,0 +1,35 @@
+import axios from 'axios';
+import { Meteor } from 'meteor/meteor';
+import createCaptions from '/imports/api/captions/server/modifiers/createCaptions';
+import Logger from '/imports/startup/server/logger';
+
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+const BASENAME = Meteor.settings.public.app.basename;
+const HOST = Meteor.settings.private.app.host;
+const LOCALES = Meteor.settings.private.app.localesUrl;
+const LOCALES_URL = `http://${HOST}:${process.env.PORT}${BASENAME}${LOCALES}`;
+
+const init = (meetingId) => {
+ axios({
+ method: 'get',
+ url: LOCALES_URL,
+ responseType: 'json',
+ }).then(async (response) => {
+ const { status } = response;
+ if (status !== 200) return;
+
+ const locales = response.data;
+ await Promise.all(locales.map(async (locale) => {
+ const caption = await createCaptions(meetingId, locale.locale, locale.name);
+ return caption;
+ }));
+ }).catch((error) => Logger.error(`Could not create captions for ${meetingId}: ${error}`));
+};
+
+const initCaptions = (meetingId) => {
+ if (CAPTIONS_CONFIG.enabled) init(meetingId);
+};
+
+export {
+ initCaptions,
+};
diff --git a/src/2.7.12/imports/api/captions/server/index.js b/src/2.7.12/imports/api/captions/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/captions/server/methods.js b/src/2.7.12/imports/api/captions/server/methods.js
new file mode 100644
index 00000000..c1cb1767
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/methods.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import updateCaptionsOwner from '/imports/api/captions/server/methods/updateCaptionsOwner';
+import startDictation from '/imports/api/captions/server/methods/startDictation';
+import stopDictation from '/imports/api/captions/server/methods/stopDictation';
+import pushSpeechTranscript from '/imports/api/captions/server/methods/pushSpeechTranscript';
+
+Meteor.methods({
+ updateCaptionsOwner,
+ startDictation,
+ stopDictation,
+ pushSpeechTranscript,
+});
diff --git a/src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js b/src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js
new file mode 100644
index 00000000..e8dab1a2
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/methods/pushSpeechTranscript.js
@@ -0,0 +1,36 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+import setTranscript from '/imports/api/captions/server/modifiers/setTranscript';
+import updatePad from '/imports/api/pads/server/methods/updatePad';
+
+export default async function pushSpeechTranscript(locale, transcript, type) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(locale, String);
+ check(transcript, String);
+ check(type, String);
+
+ const captions = await Captions.findOneAsync({
+ meetingId,
+ ownerId: requesterUserId,
+ locale,
+ dictating: true,
+ });
+
+ if (captions) {
+ if (type === 'final') {
+ const text = `\n${transcript}`;
+ updatePad(meetingId, requesterUserId, locale, text);
+ }
+
+ await setTranscript(meetingId, locale, transcript);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method pushSpeechTranscript ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/methods/startDictation.js b/src/2.7.12/imports/api/captions/server/methods/startDictation.js
new file mode 100644
index 00000000..5d744519
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/methods/startDictation.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+import setDictation from '/imports/api/captions/server/modifiers/setDictation';
+
+export default async function startDictation(locale) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(locale, String);
+
+ const captions = await Captions.findOneAsync({
+ meetingId,
+ ownerId: requesterUserId,
+ locale,
+ });
+
+ if (captions) await setDictation(meetingId, locale, true);
+ } catch (err) {
+ Logger.error(`Exception while invoking method startDictation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/methods/stopDictation.js b/src/2.7.12/imports/api/captions/server/methods/stopDictation.js
new file mode 100644
index 00000000..e96cd3c4
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/methods/stopDictation.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+import setDictation from '/imports/api/captions/server/modifiers/setDictation';
+
+export default async function stopDictation(locale) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(locale, String);
+
+ const captions = await Captions.findOne({
+ meetingId,
+ ownerId: requesterUserId,
+ locale,
+ });
+
+ if (captions) await setDictation(meetingId, locale, false);
+ } catch (err) {
+ Logger.error(`Exception while invoking method stopDictation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js b/src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js
new file mode 100644
index 00000000..cebda2b7
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/methods/updateCaptionsOwner.js
@@ -0,0 +1,30 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function updateCaptionsOwner(locale, name) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UpdateCaptionOwnerPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(locale, String);
+ check(name, String);
+
+ const payload = {
+ ownerId: requesterUserId,
+ locale,
+ name,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method updateCaptionsOwner ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js b/src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js
new file mode 100644
index 00000000..fc40b08a
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/modifiers/clearCaptions.js
@@ -0,0 +1,26 @@
+import Captions from '/imports/api/captions';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearCaptions(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await Captions.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Captions (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing captions (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await Captions.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared Captions (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing captions (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js b/src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js
new file mode 100644
index 00000000..180c598c
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/modifiers/createCaptions.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import Logger from '/imports/startup/server/logger';
+
+export default async function createCaptions(meetingId, locale, name) {
+ try {
+ check(meetingId, String);
+ check(locale, String);
+ check(name, String);
+
+ const selector = {
+ meetingId,
+ locale,
+ };
+
+ const modifier = {
+ meetingId,
+ locale,
+ name,
+ ownerId: '',
+ dictating: false,
+ transcript: '',
+ };
+
+ const { numberAffected } = await Captions.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.verbose(`Created captions=${locale} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Creating captions owner to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/modifiers/setDictation.js b/src/2.7.12/imports/api/captions/server/modifiers/setDictation.js
new file mode 100644
index 00000000..5a7e7645
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/modifiers/setDictation.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setDictation(meetingId, locale, dictating) {
+ try {
+ check(meetingId, String);
+ check(locale, String);
+ check(dictating, Boolean);
+
+ const selector = {
+ meetingId,
+ locale,
+ };
+
+ const modifier = {
+ $set: {
+ dictating,
+ transcript: '',
+ },
+ };
+
+ const { numberAffected } = Captions.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Set captions=${locale} dictating=${dictating} meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted captions=${locale} dictating=${dictating} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting captions dictation to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js b/src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js
new file mode 100644
index 00000000..8bcaf9b7
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/modifiers/setTranscript.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setTranscript(meetingId, locale, transcript) {
+ try {
+ check(meetingId, String);
+ check(locale, String);
+ check(transcript, String);
+
+ const selector = {
+ meetingId,
+ locale,
+ };
+
+ const modifier = {
+ $set: {
+ transcript,
+ },
+ };
+
+ const numberAffected = await Captions.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug(`Set captions=${locale} transcript=${transcript} meeting=${meetingId}`);
+ } else {
+ Logger.debug(`Upserted captions=${locale} transcript=${transcript} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting captions transcript to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js b/src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js
new file mode 100644
index 00000000..714d850e
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/modifiers/updateCaptionsOwner.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import Captions from '/imports/api/captions';
+import Logger from '/imports/startup/server/logger';
+
+export default async function updateCaptionsOwner(meetingId, locale, ownerId) {
+ try {
+ check(meetingId, String);
+ check(locale, String);
+ check(ownerId, String);
+
+ const selector = {
+ meetingId,
+ locale,
+ };
+
+ const modifier = {
+ $set: {
+ ownerId,
+ dictating: false, // Refresh dictation mode
+ },
+ };
+
+ const numberAffected = await Captions.upsert(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Added captions=${locale} owner=${ownerId} meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted captions=${locale} owner=${ownerId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding captions owner to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/captions/server/publishers.js b/src/2.7.12/imports/api/captions/server/publishers.js
new file mode 100644
index 00000000..e47dbedd
--- /dev/null
+++ b/src/2.7.12/imports/api/captions/server/publishers.js
@@ -0,0 +1,26 @@
+import Captions from '/imports/api/captions';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function captions() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Captions was requested by unauth connection ${this.connection.id}`);
+ return Captions.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+ Logger.debug('Publishing Captions', { meetingId, requestedBy: userId });
+
+ return Captions.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundCaptions = captions.bind(this);
+ return boundCaptions(...args);
+}
+
+Meteor.publish('captions', publish);
diff --git a/src/2.7.12/imports/api/common/server/helpers.js b/src/2.7.12/imports/api/common/server/helpers.js
new file mode 100644
index 00000000..56a41423
--- /dev/null
+++ b/src/2.7.12/imports/api/common/server/helpers.js
@@ -0,0 +1,94 @@
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+import RegexWebUrl from '/imports/utils/regex-weburl';
+
+const MSG_DIRECT_TYPE = 'DIRECT';
+const NODE_USER = 'nodeJSapp';
+
+const HTML_SAFE_MAP = {
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+
+export const parseMessage = (message) => {
+ let parsedMessage = message || '';
+ parsedMessage = parsedMessage.trim();
+
+ // Replace with \n\r
+ parsedMessage = parsedMessage.replace(/ /gi, '\n\r');
+
+ // Sanitize. See: http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/
+ parsedMessage = parsedMessage.replace(/[<>'"]/g, (c) => HTML_SAFE_MAP[c]);
+
+ // Replace flash links to flash valid ones
+ parsedMessage = parsedMessage.replace(RegexWebUrl, "$& ");
+
+ return parsedMessage;
+};
+
+
+export const spokeTimeoutHandles = {};
+export const clearSpokeTimeout = (meetingId, userId) => {
+ if (spokeTimeoutHandles[`${meetingId}-${userId}`]) {
+ Meteor.clearTimeout(spokeTimeoutHandles[`${meetingId}-${userId}`]);
+ delete spokeTimeoutHandles[`${meetingId}-${userId}`];
+ }
+};
+
+export const indexOf = [].indexOf || function (item) {
+ for (let i = 0, l = this.length; i < l; i += 1) {
+ if (i in this && this[i] === item) {
+ return i;
+ }
+ }
+
+ return -1;
+};
+
+export const processForHTML5ServerOnly = (fn) => async (message, ...args) => {
+ const { envelope } = message;
+ const { routing } = envelope;
+ const { msgType, meetingId, userId } = routing;
+
+ const selector = {
+ userId,
+ meetingId,
+ };
+
+ const user = await Users.findOneAsync(selector);
+
+ const shouldSkip = user && msgType === MSG_DIRECT_TYPE && userId !== NODE_USER && user.clientType !== 'HTML5';
+ if (shouldSkip) return () => { };
+ return fn(message, ...args);
+};
+
+export const extractCredentials = (credentials) => {
+ if (!credentials) return {};
+ const credentialsArray = credentials.split('--');
+ const meetingId = credentialsArray[0];
+ const requesterUserId = credentialsArray[1];
+ return { meetingId, requesterUserId };
+};
+
+// Creates a background job to periodically check the result of the provided function.
+// The provided function is publication-specific and must check the "survival condition" of the publication.
+export const publicationSafeGuard = function (fn, self) {
+ let stopped = false;
+ const periodicCheck = async function () {
+ if (stopped) return;
+ const result = await fn();
+ if (!result) {
+ self.added(self._name, 'publication-stop-marker', { id: 'publication-stop-marker', stopped: true });
+ self.stop();
+ } else Meteor.setTimeout(periodicCheck, 1000);
+ };
+
+ self.onStop(() => {
+ stopped = true;
+ Logger.info(`Publication ${self._name} has stopped in server side`);
+ });
+
+ periodicCheck();
+};
diff --git a/src/2.7.12/imports/api/connection-status/index.js b/src/2.7.12/imports/api/connection-status/index.js
new file mode 100644
index 00000000..4fd97fd3
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/index.js
@@ -0,0 +1,13 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const ConnectionStatus = new Mongo.Collection('connection-status', collectionOptions);
+
+if (Meteor.isServer) {
+ ConnectionStatus.createIndexAsync({ meetingId: 1, userId: 1 });
+}
+
+export default ConnectionStatus;
diff --git a/src/2.7.12/imports/api/connection-status/server/index.js b/src/2.7.12/imports/api/connection-status/server/index.js
new file mode 100644
index 00000000..cb2e6664
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/index.js
@@ -0,0 +1,2 @@
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/connection-status/server/methods.js b/src/2.7.12/imports/api/connection-status/server/methods.js
new file mode 100644
index 00000000..c5ae82db
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/methods.js
@@ -0,0 +1,8 @@
+import { Meteor } from 'meteor/meteor';
+import addConnectionStatus from './methods/addConnectionStatus';
+import voidConnection from './methods/voidConnection';
+
+Meteor.methods({
+ addConnectionStatus,
+ voidConnection,
+});
diff --git a/src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js b/src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js
new file mode 100644
index 00000000..59fb268c
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/methods/addConnectionStatus.js
@@ -0,0 +1,56 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import updateConnectionStatus from '/imports/api/connection-status/server/modifiers/updateConnectionStatus';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+const STATS = Meteor.settings.public.stats;
+
+const logConnectionStatus = (meetingId, userId, status, type, value) => {
+ switch (status) {
+ case 'normal':
+ Logger.info(`Connection status updated: meetingId=${meetingId} userId=${userId} status=${status} type=${type}`);
+ break;
+ case 'warning':
+ case 'danger':
+ case 'critical':
+ switch (type) {
+ case 'audio': {
+ const {
+ jitter,
+ loss,
+ } = value;
+ Logger.info(`Connection status updated: meetingId=${meetingId} userId=${userId} status=${status} type=${type} jitter=${jitter} loss=${loss}`);
+ break;
+ }
+ case 'socket': {
+ const { rtt } = value;
+ Logger.info(`Connection status updated: meetingId=${meetingId} userId=${userId} status=${status} type=${type} rtt=${rtt}`);
+ break;
+ }
+ default:
+ }
+ break;
+ default:
+ }
+};
+
+export default function addConnectionStatus(status, type, value) {
+ try {
+ check(status, String);
+ check(type, String);
+ check(value, Object);
+
+ if (!this.userId) return;
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ if (STATS.log) logConnectionStatus(meetingId, requesterUserId, status, type, value);
+
+ updateConnectionStatus(meetingId, requesterUserId, status);
+ } catch (err) {
+ Logger.error(`Exception while invoking method addConnectionStatus ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js b/src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js
new file mode 100644
index 00000000..7f369c5a
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/methods/voidConnection.js
@@ -0,0 +1,9 @@
+import { PrometheusAgent, METRIC_NAMES } from '/imports/startup/server/prom-metrics/index.js'
+
+// Round-trip time helper
+export default function voidConnection(previousRtt) {
+ if (previousRtt) {
+ PrometheusAgent.observe(METRIC_NAMES.METEOR_RTT, previousRtt/1000);
+ }
+ return 0;
+}
diff --git a/src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js b/src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js
new file mode 100644
index 00000000..0b0974ce
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/modifiers/clearConnectionStatus.js
@@ -0,0 +1,26 @@
+import ConnectionStatus from '/imports/api/connection-status';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearConnectionStatus(meetingId) {
+ const selector = {};
+
+ if (meetingId) {
+ selector.meetingId = meetingId;
+ }
+
+ try {
+ const numberAffected = await ConnectionStatus.removeAsync(selector);
+
+ if (numberAffected) {
+ if (meetingId) {
+ Logger.info(`Removed ConnectionStatus (${meetingId})`);
+ } else {
+ Logger.info('Removed ConnectionStatus (all)');
+ }
+ } else {
+ Logger.warn('Removing ConnectionStatus nonaffected');
+ }
+ } catch (err) {
+ Logger.error(`Removing ConnectionStatus: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js b/src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js
new file mode 100644
index 00000000..ada8f1aa
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/modifiers/updateConnectionStatus.js
@@ -0,0 +1,64 @@
+import ConnectionStatus from '/imports/api/connection-status';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import changeHasConnectionStatus from '/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus';
+
+const STATS = Meteor.settings.public.stats;
+const STATS_INTERVAL = STATS.interval;
+const STATS_CRITICAL_RTT = STATS.rtt[STATS.rtt.length - 1];
+
+export default async function updateConnectionStatus(meetingId, userId, status) {
+ check(meetingId, String);
+ check(userId, String);
+
+ const now = new Date().getTime();
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ meetingId,
+ userId,
+ connectionAliveAt: now,
+ clientNotResponding: false,
+ };
+
+ // Store last not-normal status
+ if (status !== 'normal') {
+ modifier.status = status;
+ modifier.statusUpdatedAt = now;
+ }
+
+ try {
+ const { numberAffected } = await ConnectionStatus.upsertAsync(selector, { $set: modifier });
+ if (numberAffected && status !== 'normal') {
+ await changeHasConnectionStatus(true, userId, meetingId);
+ Logger.verbose(`Updated connection status meetingId=${meetingId} userId=${userId} status=${status}`);
+ }
+
+ Meteor.setTimeout(async () => {
+ const connectionLossTimeThreshold = new Date()
+ .getTime() - (STATS_INTERVAL + STATS_CRITICAL_RTT);
+
+ const selectorNotResponding = {
+ meetingId,
+ userId,
+ connectionAliveAt: { $lte: connectionLossTimeThreshold },
+ clientNotResponding: false,
+ };
+
+ const numberAffectedNotResponding = await ConnectionStatus
+ .updateAsync(selectorNotResponding, {
+ $set: { clientNotResponding: true },
+ });
+
+ if (numberAffectedNotResponding) {
+ Logger.info(`Updated clientNotResponding=true meetingId=${meetingId} userId=${userId}`);
+ }
+ }, STATS_INTERVAL + STATS_CRITICAL_RTT);
+ } catch (err) {
+ Logger.error(`Updating connection status meetingId=${meetingId} userId=${userId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/connection-status/server/publishers.js b/src/2.7.12/imports/api/connection-status/server/publishers.js
new file mode 100644
index 00000000..c482de3f
--- /dev/null
+++ b/src/2.7.12/imports/api/connection-status/server/publishers.js
@@ -0,0 +1,62 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import ConnectionStatus from '/imports/api/connection-status';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+import Users from '/imports/api/users';
+import { publicationSafeGuard } from '/imports/api/common/server/helpers';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+async function connectionStatus() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing ConnectionStatus was requested by unauth connection ${this.connection.id}`);
+ return ConnectionStatus.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ check(meetingId, String);
+ check(userId, String);
+
+ const fields = {
+ meetingId: 1,
+ userId: 1,
+ status: 1,
+ statusUpdatedAt: 1,
+ clientNotResponding: 1,
+ };
+
+ const User = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1 } });
+ Logger.info(`Publishing connection status for ${meetingId} ${userId}`);
+
+ if (!!User && User.role === ROLE_MODERATOR) {
+ // Monitor this publication and stop it when user is not a moderator anymore
+ const comparisonFunc = async () => {
+ const user = await Users
+ .findOneAsync({ userId, meetingId }, { fields: { role: 1, userId: 1 } });
+ const condition = user.role === ROLE_MODERATOR;
+
+ if (!condition) {
+ Logger.info(`conditions aren't filled anymore in publication ${this._name}:
+ user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`);
+ }
+
+ return condition;
+ };
+ publicationSafeGuard(comparisonFunc, this);
+ return ConnectionStatus.find({ meetingId }, { fields });
+ }
+
+ return ConnectionStatus.find({ meetingId, userId }, { fields });
+}
+
+function publish(...args) {
+ const boundNote = connectionStatus.bind(this);
+ return boundNote(...args);
+}
+
+Meteor.publish('connection-status', publish);
diff --git a/src/2.7.12/imports/api/cursor/index.js b/src/2.7.12/imports/api/cursor/index.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/2.7.12/imports/api/cursor/server/eventHandlers.js b/src/2.7.12/imports/api/cursor/server/eventHandlers.js
new file mode 100644
index 00000000..4f4dca34
--- /dev/null
+++ b/src/2.7.12/imports/api/cursor/server/eventHandlers.js
@@ -0,0 +1,4 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleCursorUpdate from './handlers/cursorUpdate';
+
+RedisPubSub.on('SendCursorPositionEvtMsg', handleCursorUpdate);
diff --git a/src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js b/src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js
new file mode 100644
index 00000000..e9377f98
--- /dev/null
+++ b/src/2.7.12/imports/api/cursor/server/handlers/cursorUpdate.js
@@ -0,0 +1,45 @@
+import { check } from 'meteor/check';
+import CursorStreamer from '/imports/api/cursor/server/streamer';
+import Logger from '/imports/startup/server/logger';
+import { throttle } from '/imports/utils/throttle';
+
+const CURSOR_PROCCESS_INTERVAL = 30;
+
+const cursorQueue = {};
+
+const proccess = throttle(() => {
+ try {
+ Object.keys(cursorQueue).forEach((meetingId) => {
+ try {
+ const cursors = [];
+ for (let userId in cursorQueue[meetingId]){
+ cursorQueue[meetingId][userId].userId = userId;
+ cursors.push(cursorQueue[meetingId][userId]);
+ }
+ delete cursorQueue[meetingId];
+ CursorStreamer(meetingId).emit('message', { meetingId, cursors });
+ } catch (error) {
+ Logger.error(`Error while trying to send cursor streamer data for meeting ${meetingId}. ${error}`);
+ }
+ });
+ } catch (error) {
+ Logger.error(`Error while processing cursor queue. ${error}`);
+ }
+}, CURSOR_PROCCESS_INTERVAL);
+
+export default function handleCursorUpdate({ header, body }, meetingId) {
+ const { userId } = header;
+ check(body, Object);
+
+ check(meetingId, String);
+ check(userId, String);
+
+ if (!cursorQueue[meetingId]) {
+ cursorQueue[meetingId] = {};
+ }
+
+ // overwrite since we dont care about the other positions
+ cursorQueue[meetingId][userId] = body;
+
+ proccess();
+}
diff --git a/src/2.7.12/imports/api/cursor/server/index.js b/src/2.7.12/imports/api/cursor/server/index.js
new file mode 100644
index 00000000..9a7510e2
--- /dev/null
+++ b/src/2.7.12/imports/api/cursor/server/index.js
@@ -0,0 +1,2 @@
+import './eventHandlers';
+import './methods';
diff --git a/src/2.7.12/imports/api/cursor/server/methods.js b/src/2.7.12/imports/api/cursor/server/methods.js
new file mode 100644
index 00000000..f64fb967
--- /dev/null
+++ b/src/2.7.12/imports/api/cursor/server/methods.js
@@ -0,0 +1,6 @@
+import { Meteor } from 'meteor/meteor';
+import publishCursorUpdate from './methods/publishCursorUpdate';
+
+Meteor.methods({
+ publishCursorUpdate,
+});
diff --git a/src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js b/src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js
new file mode 100644
index 00000000..0ee0dbc4
--- /dev/null
+++ b/src/2.7.12/imports/api/cursor/server/methods/publishCursorUpdate.js
@@ -0,0 +1,10 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+
+export default function publishCursorUpdate(meetingId, requesterUserId, payload) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SendCursorPositionPubMsg';
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+}
diff --git a/src/2.7.12/imports/api/cursor/server/streamer.js b/src/2.7.12/imports/api/cursor/server/streamer.js
new file mode 100644
index 00000000..a61126e1
--- /dev/null
+++ b/src/2.7.12/imports/api/cursor/server/streamer.js
@@ -0,0 +1,37 @@
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import publishCursorUpdate from './methods/publishCursorUpdate';
+
+const { streamerLog } = Meteor.settings.private.serverLog;
+
+export function removeCursorStreamer(meetingId) {
+ Logger.info(`Removing Cursor streamer object for meeting ${meetingId}`);
+ delete Meteor.StreamerCentral.instances[`cursor-${meetingId}`];
+}
+
+export function addCursorStreamer(meetingId) {
+ const streamer = new Meteor.Streamer(`cursor-${meetingId}`, { retransmit: false });
+ if (streamerLog) {
+ Logger.debug('Cursor streamer created', { meetingId });
+ }
+
+ streamer.allowRead(function allowRead() {
+ if (streamerLog) {
+ Logger.debug('Cursor streamer called allowRead', { userId: this.userId, meetingId });
+ }
+ return this.userId && this.userId.includes(meetingId);
+ });
+
+ streamer.allowWrite(function allowWrite() {
+ return this.userId && this.userId.includes(meetingId);
+ });
+
+ streamer.on('publish', function (message) {
+ const { requesterUserId } = extractCredentials(this.userId);
+ publishCursorUpdate(meetingId, requesterUserId, message);
+ });
+}
+
+export default function get(meetingId) {
+ return Meteor.StreamerCentral.instances[`cursor-${meetingId}`];
+}
diff --git a/src/2.7.12/imports/api/external-videos/index.js b/src/2.7.12/imports/api/external-videos/index.js
new file mode 100644
index 00000000..64ffab0d
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/index.js
@@ -0,0 +1,11 @@
+import { Meteor } from 'meteor/meteor';
+
+let streamer = null;
+const getStreamer = (meetingID) => {
+ if (!streamer) {
+ streamer = new Meteor.Streamer(`external-videos-${meetingID}`);
+ }
+ return streamer;
+};
+
+export { getStreamer };
diff --git a/src/2.7.12/imports/api/external-videos/server/eventHandlers.js b/src/2.7.12/imports/api/external-videos/server/eventHandlers.js
new file mode 100644
index 00000000..2cd40778
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/eventHandlers.js
@@ -0,0 +1,8 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleStartExternalVideo from './handlers/startExternalVideo';
+import handleStopExternalVideo from './handlers/stopExternalVideo';
+import handleUpdateExternalVideo from './handlers/updateExternalVideo';
+
+RedisPubSub.on('StartExternalVideoEvtMsg', handleStartExternalVideo);
+RedisPubSub.on('StopExternalVideoEvtMsg', handleStopExternalVideo);
+RedisPubSub.on('UpdateExternalVideoEvtMsg', handleUpdateExternalVideo);
diff --git a/src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js
new file mode 100644
index 00000000..e8cd8f4e
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/handlers/startExternalVideo.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import startExternalVideo from '../modifiers/startExternalVideo';
+
+export default async function handleStartExternalVideo({ header, body }, meetingId) {
+ check(header, Object);
+ check(body, Object);
+ check(meetingId, String);
+
+ const { userId } = header;
+ const { externalVideoUrl } = body;
+
+ await startExternalVideo(meetingId, userId, externalVideoUrl);
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js
new file mode 100644
index 00000000..ac4a4017
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/handlers/stopExternalVideo.js
@@ -0,0 +1,11 @@
+import { check } from 'meteor/check';
+import stopExternalVideo from '../modifiers/stopExternalVideo';
+
+export default async function handleStopExternalVideo({ header }, meetingId) {
+ check(header, Object);
+ check(meetingId, String);
+
+ const { userId } = header;
+
+ await stopExternalVideo(userId, meetingId);
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js
new file mode 100644
index 00000000..9e061026
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/handlers/updateExternalVideo.js
@@ -0,0 +1,19 @@
+import { check } from 'meteor/check';
+import updateExternalVideo from '../modifiers/updateExternalVideo';
+
+export default async function handleUpdateExternalVideo({ header, body }, meetingId) {
+ check(header, Object);
+ check(body, Object);
+ check(meetingId, String);
+
+ const { userId } = header;
+
+ const {
+ status,
+ rate,
+ time,
+ state,
+ } = body;
+
+ await updateExternalVideo(meetingId, userId, status, rate, time, state);
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/index.js b/src/2.7.12/imports/api/external-videos/server/index.js
new file mode 100644
index 00000000..b0d5d329
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/index.js
@@ -0,0 +1,2 @@
+import './methods';
+import './eventHandlers';
diff --git a/src/2.7.12/imports/api/external-videos/server/methods.js b/src/2.7.12/imports/api/external-videos/server/methods.js
new file mode 100644
index 00000000..10c056a2
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/methods.js
@@ -0,0 +1,10 @@
+import { Meteor } from 'meteor/meteor';
+import startWatchingExternalVideo from './methods/startWatchingExternalVideo';
+import stopWatchingExternalVideo from './methods/stopWatchingExternalVideo';
+import emitExternalVideoEvent from './methods/emitExternalVideoEvent';
+
+Meteor.methods({
+ startWatchingExternalVideo,
+ stopWatchingExternalVideo,
+ emitExternalVideoEvent,
+});
diff --git a/src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js b/src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js
new file mode 100644
index 00000000..3169c967
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/methods/emitExternalVideoEvent.js
@@ -0,0 +1,40 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function emitExternalVideoEvent(options) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UpdateExternalVideoPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const { status, playerStatus } = options;
+
+ check(status, String);
+ check(playerStatus, {
+ rate: Match.Maybe(Number),
+ time: Match.Maybe(Number),
+ state: Match.Maybe(Number),
+ });
+
+ const state = playerStatus.state || 0;
+
+ const payload = {
+ status,
+ rate: playerStatus.rate || 0,
+ time: playerStatus.time || 0,
+ state,
+ };
+
+ Logger.debug(`User id=${requesterUserId} sending ${EVENT_NAME} event:${state} for meeting ${meetingId}`);
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method emitExternalVideoEvent ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js
new file mode 100644
index 00000000..cd579dab
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/methods/startWatchingExternalVideo.js
@@ -0,0 +1,26 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function startWatchingExternalVideo(externalVideoUrl) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StartExternalVideoPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(externalVideoUrl, String);
+
+ const payload = { externalVideoUrl };
+
+ Logger.info(`User ${requesterUserId} sharing an external video ${externalVideoUrl} for meeting ${meetingId}`);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (error) {
+ Logger.error(`Error on sharing an external video for meeting ${meetingId}: ${error}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js
new file mode 100644
index 00000000..59bc829d
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import RedisPubSub from '/imports/startup/server/redis';
+
+export default function stopWatchingExternalVideo() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StopExternalVideoPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {};
+
+ Logger.info(`User ${requesterUserId} stoping an external video for meeting ${meetingId}`);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (error) {
+ Logger.error(`Error on stoping an external video for meeting ${meetingId}: ${error}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js
new file mode 100644
index 00000000..335eb7ce
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/modifiers/startExternalVideo.js
@@ -0,0 +1,19 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { ExternalVideoMeetings } from '/imports/api/meetings';
+
+export default async function startExternalVideo(meetingId, userId, externalVideoUrl) {
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(externalVideoUrl, String);
+
+ const selector = { meetingId };
+ const modifier = { $set: { externalVideoUrl } };
+
+ Logger.info(`User id=${userId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`);
+ await ExternalVideoMeetings.updateAsync(selector, modifier);
+ } catch (err) {
+ Logger.error(`Error on setting shared external video start in Meetings collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js
new file mode 100644
index 00000000..cab986bd
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/modifiers/stopExternalVideo.js
@@ -0,0 +1,18 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { ExternalVideoMeetings } from '/imports/api/meetings';
+
+export default async function stopExternalVideo(userId, meetingId) {
+ try {
+ check(meetingId, String);
+ check(userId, String);
+
+ const selector = { meetingId };
+ const modifier = { $set: { externalVideoUrl: null } };
+
+ Logger.info(`External video stop sharing was initiated by:[${userId}] for meeting ${meetingId}`);
+ await ExternalVideoMeetings.updateAsync(selector, modifier);
+ } catch (err) {
+ Logger.error(`Error on setting shared external video stop in Meetings collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js b/src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js
new file mode 100644
index 00000000..25492955
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/modifiers/updateExternalVideo.js
@@ -0,0 +1,27 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import ExternalVideoStreamer from '/imports/api/external-videos/server/streamer';
+
+export default async function updateExternalVideo(meetingId, userId, status, rate, time, state) {
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(status, String);
+ check(rate, Number);
+ check(time, Number);
+ check(state, Number);
+
+ const modifier = {
+ meetingId,
+ userId,
+ rate,
+ time,
+ state,
+ };
+
+ Logger.debug(`UpdateExternalVideoEvtMsg received for user ${userId} and meeting ${meetingId} event:${status}`);
+ ExternalVideoStreamer(meetingId).emit(status, modifier);
+ } catch (err) {
+ Logger.error(`Error on setting shared external video update in Meetings collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/external-videos/server/streamer.js b/src/2.7.12/imports/api/external-videos/server/streamer.js
new file mode 100644
index 00000000..de5f2828
--- /dev/null
+++ b/src/2.7.12/imports/api/external-videos/server/streamer.js
@@ -0,0 +1,49 @@
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+
+const allowRecentMessages = (eventName, message) => {
+
+ const {
+ userId,
+ meetingId,
+ time,
+ rate,
+ state,
+ } = message;
+
+ Logger.debug(`ExternalVideo Streamer auth allowed userId: ${userId}, meetingId: ${meetingId}, event: ${eventName}, time: ${time} rate: ${rate}, state: ${state}`);
+ return true;
+};
+
+export function removeExternalVideoStreamer(meetingId) {
+ const streamName = `external-videos-${meetingId}`;
+
+ if (Meteor.StreamerCentral.instances[streamName]) {
+ Logger.info(`Destroying External Video streamer object for ${streamName}`);
+ delete Meteor.StreamerCentral.instances[streamName];
+ }
+}
+
+export function addExternalVideoStreamer(meetingId) {
+
+ const streamName = `external-videos-${meetingId}`;
+ if (!Meteor.StreamerCentral.instances[streamName]) {
+
+ const streamer = new Meteor.Streamer(streamName);
+ streamer.allowRead(function allowRead() {
+ if (!this.userId) return false;
+
+ return this.userId && this.userId.includes(meetingId);
+ });
+ streamer.allowWrite('none');
+ streamer.allowEmit(allowRecentMessages);
+ Logger.info(`Created External Video streamer for ${streamName}`);
+ } else {
+ Logger.debug(`External Video streamer is already created for ${streamName}`);
+ }
+}
+
+export default function get(meetingId) {
+ const streamName = `external-videos-${meetingId}`;
+ return Meteor.StreamerCentral.instances[streamName];
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/index.js b/src/2.7.12/imports/api/group-chat-msg/index.js
new file mode 100644
index 00000000..aaca255e
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/index.js
@@ -0,0 +1,20 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const GroupChatMsg = new Mongo.Collection('group-chat-msg');
+const UsersTyping = new Mongo.Collection('users-typing', collectionOptions);
+
+if (Meteor.isServer) {
+ GroupChatMsg.createIndexAsync({ meetingId: 1, chatId: 1 });
+ UsersTyping.createIndexAsync({ meetingId: 1, isTypingTo: 1 });
+}
+
+// As we store chat in context, skip adding to mini mongo
+if (Meteor.isClient) {
+ GroupChatMsg.onAdded = () => false;
+}
+
+export { GroupChatMsg, UsersTyping };
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js b/src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js
new file mode 100644
index 00000000..912afcac
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/eventHandlers.js
@@ -0,0 +1,12 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleGroupChatMsgBroadcast from './handlers/groupChatMsgBroadcast';
+import handleClearPublicGroupChat from './handlers/clearPublicGroupChat';
+import handleUserTyping from './handlers/userTyping';
+import handleSyncGroupChatMsg from './handlers/syncGroupsChat';
+import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers';
+
+RedisPubSub.on('GetGroupChatMsgsRespMsg', processForHTML5ServerOnly(handleSyncGroupChatMsg));
+RedisPubSub.on('GroupChatMessageBroadcastEvtMsg', handleGroupChatMsgBroadcast);
+RedisPubSub.on('ClearPublicChatHistoryEvtMsg', handleClearPublicGroupChat);
+RedisPubSub.on('SyncGetGroupChatMsgsRespMsg', handleSyncGroupChatMsg);
+RedisPubSub.on('UserTypingEvtMsg', handleUserTyping);
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js
new file mode 100644
index 00000000..7fe9749d
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/clearPublicGroupChat.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import clearGroupChatMsg from '../modifiers/clearGroupChatMsg';
+
+export default async function clearPublicChatHistory({ header, body }) {
+ const { meetingId } = header;
+ const { chatId } = body;
+
+ check(meetingId, String);
+ check(chatId, String);
+
+ const result = clearGroupChatMsg(meetingId, chatId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js
new file mode 100644
index 00000000..6c0a0dd6
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import { throttle } from '/imports/utils/throttle';
+import addGroupChatMsg from '../modifiers/addGroupChatMsg';
+import addBulkGroupChatMsgs from '../modifiers/addBulkGroupChatMsgs';
+
+const { bufferChatInsertsMs } = Meteor.settings.public.chat;
+
+const msgBuffer = [];
+
+const bulkFn = throttle(addBulkGroupChatMsgs, bufferChatInsertsMs);
+
+export default async function handleGroupChatMsgBroadcast({ body }, meetingId) {
+ const { chatId, msg } = body;
+
+ check(meetingId, String);
+ check(chatId, String);
+ check(msg, Object);
+
+ if (bufferChatInsertsMs) {
+ msgBuffer.push({ meetingId, chatId, msg });
+ bulkFn(msgBuffer);
+ } else {
+ await addGroupChatMsg(meetingId, chatId, msg);
+ }
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js
new file mode 100644
index 00000000..3db44bde
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js
@@ -0,0 +1,12 @@
+import { Match, check } from 'meteor/check';
+import syncMeetingChatMsgs from '../modifiers/syncMeetingChatMsgs';
+
+export default function handleSyncGroupChat({ body }, meetingId) {
+ const { chatId, msgs } = body;
+
+ check(meetingId, String);
+ check(chatId, String);
+ check(msgs, Match.Maybe(Array));
+
+ syncMeetingChatMsgs(meetingId, chatId, msgs);
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js b/src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js
new file mode 100644
index 00000000..893197e4
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/handlers/userTyping.js
@@ -0,0 +1,12 @@
+import { check } from 'meteor/check';
+import startTyping from '../modifiers/startTyping';
+
+export default async function handleUserTyping({ body }, meetingId) {
+ const { chatId, userId } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(chatId, String);
+
+ await startTyping(meetingId, userId, chatId);
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/index.js b/src/2.7.12/imports/api/group-chat-msg/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods.js b/src/2.7.12/imports/api/group-chat-msg/server/methods.js
new file mode 100644
index 00000000..5103e484
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods.js
@@ -0,0 +1,16 @@
+import { Meteor } from 'meteor/meteor';
+import sendGroupChatMsg from './methods/sendGroupChatMsg';
+import clearPublicChatHistory from './methods/clearPublicChatHistory';
+import startUserTyping from './methods/startUserTyping';
+import stopUserTyping from './methods/stopUserTyping';
+import chatMessageBeforeJoinCounter from './methods/chatMessageBeforeJoinCounter';
+import fetchMessagePerPage from './methods/fetchMessagePerPage';
+
+Meteor.methods({
+ fetchMessagePerPage,
+ chatMessageBeforeJoinCounter,
+ sendGroupChatMsg,
+ clearPublicChatHistory,
+ startUserTyping,
+ stopUserTyping,
+});
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js
new file mode 100644
index 00000000..435a0ec1
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/chatMessageBeforeJoinCounter.js
@@ -0,0 +1,45 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import GroupChat from '/imports/api/group-chat';
+import { GroupChatMsg } from '/imports/api/group-chat-msg';
+import Users from '/imports/api/users';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_TYPE = CHAT_CONFIG.type_public;
+
+export default async function chatMessageBeforeJoinCounter() {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const groupChats = GroupChat.find({
+ $or: [
+ { meetingId, access: PUBLIC_CHAT_TYPE },
+ { meetingId, users: { $all: [requesterUserId] } },
+ ],
+ }).fetch();
+
+ const User = await Users.findOneAsync({ userId: requesterUserId, meetingId });
+
+ const chatIdWithCounter = groupChats.map((groupChat) => {
+ const msgCount = GroupChatMsg.find({
+ meetingId,
+ chatId: groupChat.chatId,
+ timestamp: { $lt: User.authTokenValidatedTime },
+ }).count();
+ return {
+ chatId: groupChat.chatId,
+ count: msgCount,
+ };
+ }).filter((chat) => chat.count);
+ return chatIdWithCounter;
+ } catch (err) {
+ Logger.error(`Exception while invoking method chatMessageBeforeJoinCounter ${err.stack}`);
+ }
+ //True returned because the function requires a return.
+ return true;
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js
new file mode 100644
index 00000000..c09f9975
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/clearPublicChatHistory.js
@@ -0,0 +1,28 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function clearPublicChatHistory() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ClearPublicChatHistoryPubMsg';
+ const CHAT_CONFIG = Meteor.settings.public.chat;
+ const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ chatId: PUBLIC_GROUP_CHAT_ID,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method clearPublicChatHistory ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js
new file mode 100644
index 00000000..ffaf5abf
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/fetchMessagePerPage.js
@@ -0,0 +1,37 @@
+import { Meteor } from 'meteor/meteor';
+import { GroupChatMsg } from '/imports/api/group-chat-msg';
+import Users from '/imports/api/users';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const ITENS_PER_PAGE = CHAT_CONFIG.itemsPerPage;
+
+export default async function fetchMessagePerPage(chatId, page) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(chatId, String);
+ check(page, Number);
+
+ const User = await Users.findOneAsync({ userId: requesterUserId, meetingId });
+
+ const messages = await GroupChatMsg.find(
+ { chatId, meetingId, timestamp: { $lt: User.authTokenValidatedTime } },
+ {
+ sort: { timestamp: 1 },
+ skip: page > 0 ? ((page - 1) * ITENS_PER_PAGE) : 0,
+ limit: ITENS_PER_PAGE,
+ },
+ )
+ .fetchAsync();
+ return messages;
+ } catch (err) {
+ Logger.error(`Exception while invoking method fetchMessagePerPage ${err.stack}`);
+ }
+ //True returned because the function requires a return.
+ return true;
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js
new file mode 100644
index 00000000..f4caf78d
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/sendGroupChatMsg.js
@@ -0,0 +1,32 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+
+import { extractCredentials, parseMessage } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function sendGroupChatMsg(chatId, message) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SendGroupChatMessageMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(chatId, String);
+ check(message, Object);
+ const parsedMessage = parseMessage(message.message);
+ message.message = parsedMessage;
+
+ const payload = {
+ msg: message,
+ chatId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method sendGroupChatMsg ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js
new file mode 100644
index 00000000..10b97e6c
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/startUserTyping.js
@@ -0,0 +1,29 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function startUserTyping(chatId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserTypingPubMsg';
+ const CHAT_CONFIG = Meteor.settings.public.chat;
+ const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(chatId, String);
+
+ const payload = {
+ chatId: chatId || PUBLIC_GROUP_CHAT_ID,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method startUserTyping ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js b/src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js
new file mode 100644
index 00000000..98e19b8b
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/methods/stopUserTyping.js
@@ -0,0 +1,25 @@
+import { UsersTyping } from '/imports/api/group-chat-msg';
+import stopTyping from '../modifiers/stopTyping';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default async function stopUserTyping() {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const userTyping = await UsersTyping.findOneAsync({
+ meetingId,
+ userId: requesterUserId,
+ });
+
+ if (userTyping && meetingId && requesterUserId) {
+ stopTyping(meetingId, requesterUserId, true);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method stopUserTyping ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js
new file mode 100644
index 00000000..372bfa00
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js
@@ -0,0 +1,47 @@
+import { GroupChatMsg } from '/imports/api/group-chat-msg';
+import GroupChat from '/imports/api/group-chat';
+import Logger from '/imports/startup/server/logger';
+import flat from 'flat';
+import { parseMessage } from './addGroupChatMsg';
+
+export default async function addBulkGroupChatMsgs(msgs) {
+ if (!msgs.length) return;
+
+ const mappedMsgs = msgs
+ .map(({ chatId, meetingId, msg }) => {
+ const {
+ sender,
+ ...restMsg
+ } = msg;
+
+ return {
+ _id: new Mongo.ObjectID()._str,
+ ...restMsg,
+ meetingId,
+ chatId,
+ message: parseMessage(msg.message),
+ sender: sender.id,
+ senderName: sender.name,
+ senderRole: sender.role,
+ };
+ })
+ .map((el) => flat(el, { safe: true }))
+ .map((msg)=>{
+ const groupChat = GroupChat.findOne({ meetingId: msg.meetingId, chatId: msg.chatId });
+ return {
+ ...msg,
+ participants: [...groupChat.users],
+ };
+ });
+
+ try {
+ const { insertedCount } = await GroupChatMsg.rawCollection().insertMany(mappedMsgs);
+ msgs.length = 0;
+
+ if (insertedCount) {
+ Logger.info(`Inserted ${insertedCount} messages`);
+ }
+ } catch (err) {
+ Logger.error(`Error on bulk insert. ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js
new file mode 100644
index 00000000..8d78c33c
--- /dev/null
+++ b/src/2.7.12/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js
@@ -0,0 +1,61 @@
+import { Match, check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { GroupChatMsg } from '/imports/api/group-chat-msg';
+import { BREAK_LINE } from '/imports/utils/lineEndings';
+import changeHasMessages from '/imports/api/users-persistent-data/server/modifiers/changeHasMessages';
+import GroupChat from '/imports/api/group-chat';
+
+export function parseMessage(message) {
+ let parsedMessage = message || '';
+
+ // Replace \r and \n to
+ parsedMessage = parsedMessage.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, `$1${BREAK_LINE}$2`);
+
+ // Replace flash links to html valid ones
+ parsedMessage = parsedMessage.split(' `${s.substr(0, i)} target="_blank"${s.substr(i)}`;
+ const linkWithoutTarget = new RegExp(' ', 'g');
+
+ do {
+ linkWithoutTarget.test(welcomeMsg);
+
+ if (linkWithoutTarget.lastIndex > 0) {
+ welcomeMsg = insertBlankTarget(
+ welcomeMsg,
+ linkWithoutTarget.lastIndex - 1,
+ );
+ linkWithoutTarget.lastIndex -= 1;
+ }
+ } while (linkWithoutTarget.lastIndex > 0);
+
+ newMeeting.welcomeProp.welcomeMsg = welcomeMsg;
+
+ // note: as of July 2020 `modOnlyMessage` is not published to the client side.
+ // We are sanitizing this data simply to prevent future potential usage
+ // At the moment `modOnlyMessage` is obtained from client side as a response to Enter API
+ newMeeting.welcomeProp.modOnlyMessage = sanitizeTextInChat(newMeeting.welcomeProp.modOnlyMessage);
+
+ const { meetingLayout } = meeting.usersProp;
+
+ const modifier = {
+ $set: {
+ meetingId,
+ meetingEnded,
+ layout: LAYOUT_TYPE[meetingLayout] || 'smart',
+ publishedPoll: false,
+ guestLobbyMessage: '',
+ randomlySelectedUser: [],
+ ...flat(newMeeting, {
+ safe: true,
+ }),
+ },
+ };
+
+ if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') {
+ addAnnotationsStreamer(meetingId);
+ addCursorStreamer(meetingId);
+ addExternalVideoStreamer(meetingId);
+
+ // we don't want to fully process the create meeting message
+ // in frontend since it can lead to duplication of meetings in mongo.
+ if (process.env.BBB_HTML5_ROLE === 'frontend') {
+ return;
+ }
+ }
+
+ try {
+ const {
+ insertedId,
+ numberAffected,
+ } = await RecordMeetings.upsertAsync(selector, { meetingId, ...recordProp });
+
+ if (insertedId) {
+ Logger.info(`Added record prop id=${meetingId}`);
+ } else if (numberAffected) {
+ Logger.info(`Upserted record prop id=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding record prop to collection: ${err}`);
+ }
+
+ await addExternalVideo(meetingId);
+ await addLayout(meetingId, LAYOUT_TYPE[meetingLayout] || 'smart');
+
+ try {
+ const { insertedId, numberAffected } = await Meetings.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added meeting id=${meetingId}`);
+ // Init Timer collection
+ createTimer(meetingId);
+ if (newMeeting.meetingProp.disabledFeatures.indexOf('sharedNotes') === -1) {
+ initPads(meetingId);
+ }
+ if (newMeeting.meetingProp.disabledFeatures.indexOf('captions') === -1) {
+ await initCaptions(meetingId);
+ }
+ if (newMeeting.meetingProp.disabledFeatures.indexOf('reactions') === -1) {
+ await addUserReactionsObserver(meetingId);
+ }
+ } else if (numberAffected) {
+ Logger.info(`Upserted meeting id=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding meeting to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js
new file mode 100644
index 00000000..7c29156e
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeLayout.js
@@ -0,0 +1,48 @@
+import Logger from '/imports/startup/server/logger';
+import { LayoutMeetings } from '/imports/api/meetings';
+import { check } from 'meteor/check';
+import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums';
+
+export default async function changeLayout(
+ meetingId,
+ layout,
+ presentationIsOpen,
+ isResizing,
+ cameraPosition,
+ focusedCamera,
+ presentationVideoRate,
+ pushLayout,
+ requesterUserId,
+) {
+ try {
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(isResizing, Boolean);
+ check(layout, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ layout: LAYOUT_TYPE[layout] || LAYOUT_TYPE.SMART_LAYOUT,
+ layoutUpdatedAt: new Date().getTime(),
+ presentationIsOpen,
+ isResizing,
+ cameraPosition,
+ focusedCamera,
+ presentationVideoRate,
+ pushLayout,
+ },
+ };
+
+ const numberAffected = LayoutMeetings.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`MeetingLayout changed to ${layout} for meeting=${meetingId} requested by user=${requesterUserId}`);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method changeLayout ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js
new file mode 100644
index 00000000..1b1e084b
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeLockSettings.js
@@ -0,0 +1,67 @@
+import Logger from '/imports/startup/server/logger';
+import Meetings from '/imports/api/meetings';
+import { check } from 'meteor/check';
+
+export default async function changeLockSettings(meetingId, payload) {
+ check(meetingId, String);
+ check(payload, {
+ disableCam: Boolean,
+ disableMic: Boolean,
+ disablePrivChat: Boolean,
+ disablePubChat: Boolean,
+ disableNotes: Boolean,
+ hideUserList: Boolean,
+ lockOnJoin: Boolean,
+ lockOnJoinConfigurable: Boolean,
+ hideViewersCursor: Boolean,
+ hideViewersAnnotation: Boolean,
+ setBy: Match.Maybe(String),
+ });
+
+ const {
+ disableCam,
+ disableMic,
+ disablePrivChat,
+ disablePubChat,
+ disableNotes,
+ hideUserList,
+ lockOnJoin,
+ lockOnJoinConfigurable,
+ hideViewersCursor,
+ hideViewersAnnotation,
+ setBy,
+ } = payload;
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ lockSettingsProps: {
+ disableCam,
+ disableMic,
+ disablePrivateChat: disablePrivChat,
+ disablePublicChat: disablePubChat,
+ disableNotes,
+ hideUserList,
+ lockOnJoin,
+ lockOnJoinConfigurable,
+ hideViewersCursor,
+ hideViewersAnnotation,
+ setBy,
+ },
+ },
+ };
+
+ try {
+ const { numberAffected } = await Meetings.upsertAsync(selector, modifier);
+ if (numberAffected) {
+ Logger.info(`Changed meeting={${meetingId}} updated lock settings`);
+ } else {
+ Logger.info(`meeting={${meetingId}} lock settings were not updated`);
+ }
+ } catch (err) {
+ Logger.error(`Changing meeting={${meetingId}} lock settings: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js
new file mode 100644
index 00000000..e5a5f7ef
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserChatLock.js
@@ -0,0 +1,37 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import Meetings from '/imports/api/meetings';
+import { check } from 'meteor/check';
+
+export default async function changeUserChatLock(meetingId, payload) {
+ check(meetingId, String);
+ check(payload, {
+ userId: String,
+ isLocked: Boolean,
+ });
+
+ const { userId, isLocked } = payload;
+
+ const userSelector = {
+ meetingId,
+ userId,
+ };
+
+ const userModifier = {
+ $set: {
+ chatLocked: isLocked,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Users.upsertAsync(userSelector, userModifier);
+
+ if ( numberAffected ) {
+ Logger.info(`Updated lock settings in meeting ${meetingId}: disablePublicChat=${isLocked}`);
+ } else {
+ Logger.info(`Kept lock settings in meeting ${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Changing user chat lock setting: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js
new file mode 100644
index 00000000..c079ed9e
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/changeUserLock.js
@@ -0,0 +1,37 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import { check } from 'meteor/check';
+
+export default async function changeUserLock(meetingId, payload) {
+ check(meetingId, String);
+ check(payload, {
+ userId: String,
+ locked: Boolean,
+ lockedBy: String,
+ });
+
+ const { userId, locked, lockedBy } = payload;
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ locked,
+ },
+ };
+
+ try {
+ const { numberAffected } = Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`User's userId=${userId} lock status was changed to: ${locked} by user userId=${lockedBy}`);
+ } else {
+ Logger.info(`User's userId=${userId} lock status wasn't updated`);
+ }
+ } catch (err) {
+ Logger.error(`Changing user lock setting: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js b/src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js
new file mode 100644
index 00000000..d9f4bc4e
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/clearExternalVideoMeeting.js
@@ -0,0 +1,26 @@
+import { ExternalVideoMeetings } from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearExternalVideoMeeting(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await ExternalVideoMeetings.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared ExternalVideoMeetings in (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing ExternalVideoMeetings in (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await ExternalVideoMeetings.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared ExternalVideoMeetings in all meetings');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing ExternalVideoMeetings in all meetings. ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js b/src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js
new file mode 100644
index 00000000..e8f9cc2d
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining.js
@@ -0,0 +1,26 @@
+import { MeetingTimeRemaining } from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearMeetingTimeRemaining(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await MeetingTimeRemaining.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared MeetingTimeRemaining in (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing MeetingTimeRemaining in (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await MeetingTimeRemaining.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared MeetingTimeRemaining in all meetings');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing MeetingTimeRemaining in all meetings. ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js b/src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js
new file mode 100644
index 00000000..0b21a3f2
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/clearRecordMeeting.js
@@ -0,0 +1,14 @@
+import { RecordMeetings } from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+
+export default async function meetingHasEnded(meetingId) {
+ try {
+ const numberAffected = RecordMeetings.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared record prop from meeting with id ${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing record prop from meeting with id ${meetingId}. ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js b/src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js
new file mode 100644
index 00000000..b1e401f7
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/emitNotification.js
@@ -0,0 +1,10 @@
+import notificationEmitter from '../../notificationEmitter';
+
+export default function emitNotification(body, type) {
+ if (!process.env.BBB_HTML5_ROLE || (process.env.BBB_HTML5_ROLE === 'frontend')) {
+ notificationEmitter.emit('notification', {
+ type,
+ ...body,
+ });
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js b/src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js
new file mode 100644
index 00000000..0ecbff86
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/meetingHasEnded.js
@@ -0,0 +1,77 @@
+import Meetings from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+
+import { removeAnnotationsStreamer } from '/imports/api/annotations/server/streamer';
+import { removeCursorStreamer } from '/imports/api/cursor/server/streamer';
+import { removeExternalVideoStreamer } from '/imports/api/external-videos/server/streamer';
+
+import clearUsers from '/imports/api/users/server/modifiers/clearUsers';
+import clearUsersSettings from '/imports/api/users-settings/server/modifiers/clearUsersSettings';
+import clearGroupChat from '/imports/api/group-chat/server/modifiers/clearGroupChat';
+import clearGuestUsers from '/imports/api/guest-users/server/modifiers/clearGuestUsers';
+import clearBreakouts from '/imports/api/breakouts/server/modifiers/clearBreakouts';
+import clearAnnotations from '/imports/api/annotations/server/modifiers/clearAnnotations';
+import clearSlides from '/imports/api/slides/server/modifiers/clearSlides';
+import clearPolls from '/imports/api/polls/server/modifiers/clearPolls';
+import clearCaptions from '/imports/api/captions/server/modifiers/clearCaptions';
+import clearPads from '/imports/api/pads/server/modifiers/clearPads';
+import clearPresentationPods from '/imports/api/presentation-pods/server/modifiers/clearPresentationPods';
+import clearVoiceUsers from '/imports/api/voice-users/server/modifiers/clearVoiceUsers';
+import clearUserInfo from '/imports/api/users-infos/server/modifiers/clearUserInfo';
+import clearConnectionStatus from '/imports/api/connection-status/server/modifiers/clearConnectionStatus';
+import clearScreenshare from '/imports/api/screenshare/server/modifiers/clearScreenshare';
+import clearTimer from '/imports/api/timer/server/modifiers/clearTimer';
+import clearAudioCaptions from '/imports/api/audio-captions/server/modifiers/clearAudioCaptions';
+import clearMeetingTimeRemaining from '/imports/api/meetings/server/modifiers/clearMeetingTimeRemaining';
+import clearLocalSettings from '/imports/api/local-settings/server/modifiers/clearLocalSettings';
+import clearRecordMeeting from './clearRecordMeeting';
+import clearExternalVideoMeeting from './clearExternalVideoMeeting';
+import clearVoiceCallStates from '/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates';
+import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams';
+import clearAuthTokenValidation from '/imports/api/auth-token-validation/server/modifiers/clearAuthTokenValidation';
+import clearUsersPersistentData from '/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData';
+import clearReactions from '/imports/api/user-reaction/server/modifiers/clearReactions';
+
+import clearWhiteboardMultiUser from '/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser';
+import Metrics from '/imports/startup/server/metrics';
+
+export default async function meetingHasEnded(meetingId) {
+ if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') {
+ removeAnnotationsStreamer(meetingId);
+ removeCursorStreamer(meetingId);
+ removeExternalVideoStreamer(meetingId);
+ }
+
+ await Meetings.removeAsync({ meetingId });
+ await Promise.all([
+ clearCaptions(meetingId),
+ clearPads(meetingId),
+ clearGroupChat(meetingId),
+ clearGuestUsers(meetingId),
+ clearPresentationPods(meetingId),
+ clearBreakouts(meetingId),
+ clearPolls(meetingId),
+ clearAnnotations(meetingId),
+ clearSlides(meetingId),
+ clearUsers(meetingId),
+ clearUsersSettings(meetingId),
+ clearVoiceUsers(meetingId),
+ clearUserInfo(meetingId),
+ clearConnectionStatus(meetingId),
+ clearTimer(meetingId),
+ clearAudioCaptions(meetingId),
+ clearLocalSettings(meetingId),
+ clearMeetingTimeRemaining(meetingId),
+ clearRecordMeeting(meetingId),
+ clearExternalVideoMeeting(meetingId),
+ clearVoiceCallStates(meetingId),
+ clearVideoStreams(meetingId),
+ clearAuthTokenValidation(meetingId),
+ clearWhiteboardMultiUser(meetingId),
+ clearScreenshare(meetingId),
+ clearUsersPersistentData(meetingId),
+ clearReactions(meetingId),
+ ]);
+ await Metrics.removeMeeting(meetingId);
+ return Logger.info(`Cleared Meetings with id ${meetingId}`);
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js
new file mode 100644
index 00000000..57e1977c
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestLobbyMessage.js
@@ -0,0 +1,28 @@
+import Meetings from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+export default async function setGuestLobbyMessage(meetingId, guestLobbyMessage) {
+ check(meetingId, String);
+ check(guestLobbyMessage, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ guestLobbyMessage,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Meetings.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.verbose(`Set guest lobby message meetingId=${meetingId} guestLobbyMessage=${guestLobbyMessage}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting guest lobby message: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js
new file mode 100644
index 00000000..d70ad06e
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/setGuestPolicy.js
@@ -0,0 +1,28 @@
+import Meetings from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+export default async function setGuestPolicy(meetingId, guestPolicy) {
+ check(meetingId, String);
+ check(guestPolicy, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ 'usersProp.guestPolicy': guestPolicy,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Meetings.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.verbose(`Set guest policy meetingId=${meetingId} guestPolicy=${guestPolicy}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting guest policy: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js b/src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js
new file mode 100644
index 00000000..e110741e
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/setPublishedPoll.js
@@ -0,0 +1,28 @@
+import Meetings from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+export default async function setPublishedPoll(meetingId, isPublished) {
+ check(meetingId, String);
+ check(isPublished, Boolean);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ publishedPoll: isPublished,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Meetings.upsert(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Set publishedPoll=${isPublished} in meeitingId=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting publishedPoll=${isPublished} for meetingId=${meetingId}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js b/src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js
new file mode 100644
index 00000000..9b1ea036
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/setPushLayout.js
@@ -0,0 +1,29 @@
+import Logger from '/imports/startup/server/logger';
+import { LayoutMeetings } from '/imports/api/meetings';
+import { check } from 'meteor/check';
+
+export default async function setPushLayout(meetingId, pushLayout, requesterUserId) {
+ try {
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ // TODO: create a exclusive collection for layout changes
+ const modifier = {
+ $set: {
+ pushLayout,
+ },
+ };
+
+ const numberAffected = await LayoutMeetings.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`MeetingLayout pushLayout changed to ${pushLayout} for meeting=${meetingId} requested by user=${requesterUserId}`);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method setPushLayout ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js b/src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js
new file mode 100644
index 00000000..2336b201
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/updateRandomViewer.js
@@ -0,0 +1,143 @@
+import Meetings from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+const SELECT_RANDOM_USER_COUNTDOWN = Meteor.settings.public.selectRandomUser.countdown;
+
+// Time intervals in milliseconds
+// for iteration in animation.
+const intervals = [0, 200, 450, 750, 1100, 1500];
+
+// Used to togle to the first value of intervals to
+// differenciare whether this function has been called
+let updateIndicator = true;
+
+// A finction that toggles
+// the first interval on each call
+function toggleIndicator() {
+ if (updateIndicator) {
+ intervals[0] = 1;
+ } else {
+ intervals[0] = 0;
+ }
+ updateIndicator = !updateIndicator;
+}
+
+function getFiveRandom(userList, userIds) {
+ let IDs = userIds.slice();
+ for (let i = 0; i < intervals.length - 1; i += 1) {
+ if (IDs.length === 0) { // we used up all the options
+ IDs = userIds.slice(); // start over
+ let userId = IDs.splice(0, 1);
+ if (userList[userList.length] === [userId, intervals[i]]) {
+ // If we start over with the one we finnished, change it
+ IDs.push(userId);
+ userId = IDs.splice(0, 1);
+ }
+ userList.push([userId, intervals[i]]);
+ } else {
+ const userId = IDs.splice(Math.floor(Math.random() * IDs.length), 1);
+ userList.push([userId, intervals[i]]);
+ }
+ }
+}
+
+// All possible combinations of 3 elements
+// to speed up randomizing
+const optionsFor3 = [
+ [0, 1, 2],
+ [0, 2, 1],
+ [1, 2, 0],
+ [1, 0, 2],
+ [2, 0, 1],
+ [2, 1, 0],
+];
+
+export default async function updateRandomUser(meetingId, userIds, choice, requesterId) {
+ check(meetingId, String);
+ check(userIds, Array);
+ check(choice, String);
+ check(requesterId, String);
+
+ let userList = [];
+
+ const selector = {
+ meetingId,
+ };
+
+ toggleIndicator();
+
+ const numberOfUsers = userIds.length;
+
+ if (choice === '') {
+ // no viewer
+ userList = [
+ [requesterId, intervals[0]],
+ [requesterId, 0],
+ [requesterId, 0],
+ [requesterId, 0],
+ [requesterId, 0],
+ [requesterId, 0],
+ ];
+ } else if (numberOfUsers === 1) { // If user is only one, obviously it is the chosen one
+ userList = [
+ [userIds[0], intervals[0]],
+ [userIds[0], 0],
+ [userIds[0], 0],
+ [userIds[0], 0],
+ [userIds[0], 0],
+ [userIds[0], 0],
+ ];
+ } else if (!SELECT_RANDOM_USER_COUNTDOWN) {
+ // If animation is disabled, we only care about the chosen one
+ userList = [
+ [choice, intervals[0]],
+ [choice, 0],
+ [choice, 0],
+ [choice, 0],
+ [choice, 0],
+ [choice, 0],
+ ];
+ } else if (numberOfUsers === 2) { // If there are only two users, we can just chow them in turns
+ const IDs = userIds.slice();
+ IDs.splice(choice, 1);
+ userList = [
+ [IDs[0], intervals[0]],
+ [choice, intervals[1]],
+ [IDs[0], intervals[2]],
+ [choice, intervals[3]],
+ [IDs[0], intervals[4]],
+ [choice, intervals[5]],
+ ];
+ } else if (numberOfUsers === 3) {
+ // If there are 3 users, the number of combinations is small, so we'll use that
+ const option = Math.floor(Math.random() * 6);
+ const order = optionsFor3[option];
+ userList = [
+ [userIds[order[0]], intervals[0]],
+ [userIds[order[1]], intervals[1]],
+ [userIds[order[2]], intervals[2]],
+ [userIds[order[0]], intervals[3]],
+ [userIds[order[1]], intervals[4]],
+ [choice, intervals[5]],
+ ];
+ } else { // We generate 5 users randomly, just for animation, and last one is the chosen one
+ getFiveRandom(userList, userIds);
+ userList.push([choice, intervals[intervals.length]]);
+ }
+
+ const modifier = {
+ $set: {
+ randomlySelectedUser: userList,
+ },
+ };
+
+ try {
+ const { insertedId } = await Meetings.upsertAsync(selector, modifier);
+ if (insertedId) {
+ Logger.info(`Set randomly selected userId and interval = ${userList} by requesterId=${requesterId} in meeitingId=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting randomly selected userId and interval = ${userList} by requesterId=${requesterId} in meetingId=${meetingId}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js b/src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js
new file mode 100644
index 00000000..5c4db1a8
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/modifiers/webcamOnlyModerator.js
@@ -0,0 +1,34 @@
+import Logger from '/imports/startup/server/logger';
+import Meetings from '/imports/api/meetings';
+import { check } from 'meteor/check';
+
+export default async function changeWebcamOnlyModerator(meetingId, payload) {
+ check(meetingId, String);
+ check(payload, {
+ webcamsOnlyForModerator: Boolean,
+ setBy: String,
+ });
+ const { webcamsOnlyForModerator } = payload;
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ 'usersProp.webcamsOnlyForModerator': webcamsOnlyForModerator,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Meetings.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Changed meeting={${meetingId}} updated webcam Only for Moderator`);
+ } else {
+ Logger.info(`meeting={${meetingId}} webcam Only for Moderator were not updated`);
+ }
+ } catch (err) {
+ Logger.error(`Changwing meeting={${meetingId}} webcam Only for Moderator: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/meetings/server/publishers.js b/src/2.7.12/imports/api/meetings/server/publishers.js
new file mode 100644
index 00000000..ab7b8240
--- /dev/null
+++ b/src/2.7.12/imports/api/meetings/server/publishers.js
@@ -0,0 +1,193 @@
+import { Meteor } from 'meteor/meteor';
+import { Random } from 'meteor/random';
+import Meetings, {
+ RecordMeetings,
+ MeetingTimeRemaining,
+ ExternalVideoMeetings,
+ LayoutMeetings,
+} from '/imports/api/meetings';
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+import { publicationSafeGuard } from '/imports/api/common/server/helpers';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+import notificationEmitter from '../notificationEmitter';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+function meetings() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Meetings was requested by unauth connection ${this.connection.id}`);
+ return Meetings.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing meeting', { meetingId, userId });
+
+ const selector = {
+ $or: [
+ { meetingId },
+ ],
+ };
+
+ const User = Users.findOne({ userId, meetingId }, { fields: { userId: 1, role: 1 } });
+ if (!!User && User.role === ROLE_MODERATOR) {
+ selector.$or.push({
+ 'meetingProp.isBreakout': true,
+ 'breakoutProps.parentId': meetingId,
+ });
+ // Monitor this publication and stop it when user is not a moderator anymore
+ const comparisonFunc = () => {
+ const user = Users.findOne({ userId, meetingId }, { fields: { role: 1, userId: 1 } });
+ const condition = user.role === ROLE_MODERATOR;
+
+ if (!condition) {
+ Logger.info(`conditions aren't filled anymore in publication ${this._name}:
+ user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`);
+ }
+
+ return condition;
+ };
+ publicationSafeGuard(comparisonFunc, this);
+ }
+ const options = {
+ fields: {
+ password: false,
+ 'welcomeProp.modOnlyMessage': false,
+ },
+ };
+
+ if (User.role !== ROLE_MODERATOR) {
+ options.fields.learningDashboardAccessToken = false;
+ }
+
+ return Meetings.find(selector, options);
+}
+
+function publish(...args) {
+ const boundMeetings = meetings.bind(this);
+ return boundMeetings(...args);
+}
+
+Meteor.publish('meetings', publish);
+
+function recordMeetings() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing RecordMeetings was requested by unauth connection ${this.connection.id}`);
+ return RecordMeetings.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug(`Publishing RecordMeetings for ${meetingId} ${userId}`);
+
+ return RecordMeetings.find({ meetingId });
+}
+function recordPublish(...args) {
+ const boundRecordMeetings = recordMeetings.bind(this);
+ return boundRecordMeetings(...args);
+}
+
+Meteor.publish('record-meetings', recordPublish);
+
+function layoutMeetings() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing LayoutMeetings was requested by unauth connection ${this.connection.id}`);
+ return LayoutMeetings.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug(`Publishing LayoutMeetings for ${meetingId} ${userId}`);
+
+ return LayoutMeetings.find({ meetingId });
+}
+
+function layoutPublish(...args) {
+ const boundLayoutMeetings = layoutMeetings.bind(this);
+ return boundLayoutMeetings(...args);
+}
+
+Meteor.publish('layout-meetings', layoutPublish);
+
+function externalVideoMeetings() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing ExternalVideoMeetings was requested by unauth connection ${this.connection.id}`);
+ return ExternalVideoMeetings.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug(`Publishing ExternalVideoMeetings for ${meetingId} ${userId}`);
+
+ return ExternalVideoMeetings.find({ meetingId });
+}
+
+function externalVideoPublish(...args) {
+ const boundExternalVideoMeetings = externalVideoMeetings.bind(this);
+ return boundExternalVideoMeetings(...args);
+}
+
+Meteor.publish('external-video-meetings', externalVideoPublish);
+
+function meetingTimeRemaining() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing MeetingTimeRemaining was requested by unauth connection ${this.connection.id}`);
+ return MeetingTimeRemaining.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+ Logger.debug(`Publishing MeetingTimeRemaining for ${meetingId} ${userId}`);
+
+ return MeetingTimeRemaining.find({ meetingId });
+}
+function timeRemainingPublish(...args) {
+ const boundtimeRemaining = meetingTimeRemaining.bind(this);
+ return boundtimeRemaining(...args);
+}
+
+Meteor.publish('meeting-time-remaining', timeRemainingPublish);
+
+function notifications() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+ if (tokenValidation && tokenValidation.validationStatus === ValidationStates.VALIDATED) {
+ notificationEmitter.on('notification', (notification) => {
+ const { meetingId, userId } = tokenValidation;
+ switch (notification.type) {
+ case 'notifyAllInMeeting':
+ if (notification.meetingId === meetingId) this.added('notifications', Random.id(), notification);
+ break;
+ case 'NotifyUserInMeeting':
+ if (notification.meetingId === meetingId && notification.userId === userId) this.added('notifications', Random.id(), notification);
+ break;
+ case 'NotifyRoleInMeeting': {
+ const user = Users.findOne({ userId, meetingId }, { fields: { role: 1, userId: 1 } });
+ if (notification.meetingId === meetingId && notification.role === user.role) this.added('notifications', Random.id(), notification);
+ break;
+ }
+ default: Logger.warn(`wrong type: ${notification.type} userId: ${userId}`);
+ }
+ });
+
+ this.ready();
+ } else {
+ Logger.warn(`Publishing notification was requested by unauth connection ${this.connection.id}`);
+ }
+}
+
+function notificationsPublish(...args) {
+ const boundNotifications = notifications.bind(this);
+ return boundNotifications(...args);
+}
+
+Meteor.publish('notifications', notificationsPublish);
diff --git a/src/2.7.12/imports/api/pads/index.js b/src/2.7.12/imports/api/pads/index.js
new file mode 100644
index 00000000..61a4f03e
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/index.js
@@ -0,0 +1,22 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Pads = new Mongo.Collection('pads', collectionOptions);
+const PadsSessions = new Mongo.Collection('pads-sessions', collectionOptions);
+const PadsUpdates = new Mongo.Collection('pads-updates', collectionOptions);
+
+if (Meteor.isServer) {
+ Pads.createIndexAsync({ meetingId: 1, externalId: 1 });
+ PadsSessions.createIndexAsync({ meetingId: 1, userId: 1 });
+ PadsUpdates.createIndexAsync({ meetingId: 1, externalId: 1 });
+}
+
+export {
+ PadsSessions,
+ PadsUpdates,
+};
+
+export default Pads;
diff --git a/src/2.7.12/imports/api/pads/server/eventHandlers.js b/src/2.7.12/imports/api/pads/server/eventHandlers.js
new file mode 100644
index 00000000..58a65eab
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/eventHandlers.js
@@ -0,0 +1,20 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import groupCreated from './handlers/groupCreated';
+import padCreated from './handlers/padCreated';
+import sessionCreated from './handlers/sessionCreated';
+import padUpdated from './handlers/padUpdated';
+import padContent from './handlers/padContent';
+import padTail from './handlers/padTail';
+import sessionDeleted from './handlers/sessionDeleted';
+import captureSharedNotes from './handlers/captureSharedNotes';
+import padPinned from './handlers/padPinned';
+
+RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated);
+RedisPubSub.on('PadCreatedRespMsg', padCreated);
+RedisPubSub.on('PadSessionCreatedRespMsg', sessionCreated);
+RedisPubSub.on('PadUpdatedEvtMsg', padUpdated);
+RedisPubSub.on('PadContentEvtMsg', padContent);
+RedisPubSub.on('PadTailEvtMsg', padTail);
+RedisPubSub.on('PadSessionDeletedEvtMsg', sessionDeleted);
+RedisPubSub.on('CaptureSharedNotesReqEvtMsg', captureSharedNotes);
+RedisPubSub.on('PadPinnedEvtMsg', padPinned);
diff --git a/src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js b/src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js
new file mode 100644
index 00000000..25354bae
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/captureSharedNotes.js
@@ -0,0 +1,22 @@
+import { check } from 'meteor/check';
+import padCapture from '../methods/padCapture';
+
+export default async function captureSharedNotes({ header, body }) {
+ check(header, Object);
+ check(body, Object);
+
+ const {
+ meetingId: parentMeetingId,
+ } = header;
+
+ const {
+ breakoutId,
+ filename,
+ } = body;
+
+ check(breakoutId, String);
+ check(parentMeetingId, String);
+ check(filename, String);
+
+ await padCapture(breakoutId, parentMeetingId, filename);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/groupCreated.js b/src/2.7.12/imports/api/pads/server/handlers/groupCreated.js
new file mode 100644
index 00000000..06fd8feb
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/groupCreated.js
@@ -0,0 +1,16 @@
+import createGroup from '/imports/api/pads/server/modifiers/createGroup';
+
+export default async function groupCreated({ header, body }) {
+ const {
+ meetingId,
+ userId,
+ } = header;
+
+ const {
+ externalId,
+ model,
+ name,
+ } = body;
+
+ await createGroup(meetingId, userId, externalId, model, name);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/padContent.js b/src/2.7.12/imports/api/pads/server/handlers/padContent.js
new file mode 100644
index 00000000..bacb784a
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/padContent.js
@@ -0,0 +1,16 @@
+import contentPad from '/imports/api/pads/server/modifiers/contentPad';
+
+export default async function padContent({ header, body }) {
+ const {
+ meetingId,
+ } = header;
+
+ const {
+ externalId,
+ start,
+ end,
+ text,
+ } = body;
+
+ await contentPad(meetingId, externalId, start, end, text);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/padCreated.js b/src/2.7.12/imports/api/pads/server/handlers/padCreated.js
new file mode 100644
index 00000000..236f500a
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/padCreated.js
@@ -0,0 +1,11 @@
+import createPad from '/imports/api/pads/server/modifiers/createPad';
+
+export default async function padCreated({ header, body }) {
+ const { meetingId } = header;
+ const {
+ externalId,
+ padId,
+ } = body;
+
+ await createPad(meetingId, externalId, padId);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/padPinned.js b/src/2.7.12/imports/api/pads/server/handlers/padPinned.js
new file mode 100644
index 00000000..f3239131
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/padPinned.js
@@ -0,0 +1,14 @@
+import pinPad from '/imports/api/pads/server/modifiers/pinPad';
+
+export default async function padPinned({ header, body }) {
+ const {
+ meetingId,
+ } = header;
+
+ const {
+ externalId,
+ pinned,
+ } = body;
+
+ await pinPad(meetingId, externalId, pinned);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/padTail.js b/src/2.7.12/imports/api/pads/server/handlers/padTail.js
new file mode 100644
index 00000000..18290f8e
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/padTail.js
@@ -0,0 +1,14 @@
+import tailPad from '/imports/api/pads/server/modifiers/tailPad';
+
+export default async function padTail({ header, body }) {
+ const {
+ meetingId,
+ } = header;
+
+ const {
+ externalId,
+ tail,
+ } = body;
+
+ await tailPad(meetingId, externalId, tail);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/padUpdated.js b/src/2.7.12/imports/api/pads/server/handlers/padUpdated.js
new file mode 100644
index 00000000..ba8dadd7
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/padUpdated.js
@@ -0,0 +1,17 @@
+import updatePad from '/imports/api/pads/server/modifiers/updatePad';
+
+export default async function padUpdated({ header, body }) {
+ const {
+ meetingId,
+ } = header;
+
+ const {
+ externalId,
+ padId,
+ userId,
+ rev,
+ changeset,
+ } = body;
+
+ await updatePad(meetingId, externalId, padId, userId, rev, changeset);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js b/src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js
new file mode 100644
index 00000000..af7bf922
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/sessionCreated.js
@@ -0,0 +1,15 @@
+import createSession from '/imports/api/pads/server/modifiers/createSession';
+
+export default async function sessionCreated({ header, body }) {
+ const {
+ meetingId,
+ userId,
+ } = header;
+
+ const {
+ externalId,
+ sessionId,
+ } = body;
+
+ await createSession(meetingId, userId, externalId, sessionId);
+}
diff --git a/src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js b/src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js
new file mode 100644
index 00000000..3d56db23
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/handlers/sessionDeleted.js
@@ -0,0 +1,15 @@
+import deleteSession from '/imports/api/pads/server/modifiers/deleteSession';
+
+export default async function sessionDeleted({ header, body }) {
+ const {
+ meetingId,
+ } = header;
+
+ const {
+ externalId,
+ userId,
+ sessionId,
+ } = body;
+
+ await deleteSession(meetingId, externalId, userId, sessionId);
+}
diff --git a/src/2.7.12/imports/api/pads/server/helpers.js b/src/2.7.12/imports/api/pads/server/helpers.js
new file mode 100644
index 00000000..5b33328a
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/helpers.js
@@ -0,0 +1,51 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+
+const NOTES_CONFIG = Meteor.settings.public.notes;
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+const REDIS_CONFIG = Meteor.settings.private.redis;
+const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+const TOKEN = '$';
+
+const models = {
+ CAPTIONS: CAPTIONS_CONFIG.id,
+ NOTES: NOTES_CONFIG.id,
+};
+
+const getDataFromChangeset = (changeset) => {
+ const splitChangeset = changeset.split(TOKEN);
+ if (splitChangeset.length > 1) {
+ splitChangeset.shift();
+
+ return splitChangeset.join(TOKEN);
+ }
+
+ return '';
+};
+
+const createGroup = (meetingId, externalId, model, name) => {
+ const EVENT_NAME = 'PadCreateGroupReqMsg';
+
+ try {
+ const payload = {
+ externalId,
+ model,
+ name,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, 'nodeJSapp', payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method createGroup ${err.stack}`);
+ }
+};
+
+const initPads = (meetingId) => {
+ if (NOTES_CONFIG.enabled) createGroup(meetingId, NOTES_CONFIG.id, models.NOTES, NOTES_CONFIG.id);
+};
+
+export {
+ getDataFromChangeset,
+ initPads,
+ models,
+};
diff --git a/src/2.7.12/imports/api/pads/server/index.js b/src/2.7.12/imports/api/pads/server/index.js
new file mode 100644
index 00000000..760855cd
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/index.js
@@ -0,0 +1,3 @@
+import './publishers';
+import './methods';
+import './eventHandlers';
diff --git a/src/2.7.12/imports/api/pads/server/methods.js b/src/2.7.12/imports/api/pads/server/methods.js
new file mode 100644
index 00000000..535aba02
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import createGroup from './methods/createGroup';
+import createSession from './methods/createSession';
+import getPadId from './methods/getPadId';
+import pinPad from './methods/pinPad';
+
+Meteor.methods({
+ createGroup,
+ createSession,
+ getPadId,
+ pinPad,
+});
diff --git a/src/2.7.12/imports/api/pads/server/methods/createGroup.js b/src/2.7.12/imports/api/pads/server/methods/createGroup.js
new file mode 100644
index 00000000..81b7d24d
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/createGroup.js
@@ -0,0 +1,31 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function createGroup(externalId, model, name) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadCreateGroupReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(externalId, String);
+ check(model, String);
+ check(name, String);
+
+ const payload = {
+ externalId,
+ model,
+ name,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method createGroup ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/createPad.js b/src/2.7.12/imports/api/pads/server/methods/createPad.js
new file mode 100644
index 00000000..2ee94d03
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/createPad.js
@@ -0,0 +1,26 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function createPad(meetingId, userId, externalId, name) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadCreateReqMsg';
+
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(externalId, String);
+ check(name, String);
+
+ const payload = {
+ externalId,
+ name,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method createPad ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/createSession.js b/src/2.7.12/imports/api/pads/server/methods/createSession.js
new file mode 100644
index 00000000..e2ce8fd7
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/createSession.js
@@ -0,0 +1,27 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function createSession(externalId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadCreateSessionReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(externalId, String);
+
+ const payload = {
+ externalId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method createSession ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/getPadId.js b/src/2.7.12/imports/api/pads/server/methods/getPadId.js
new file mode 100644
index 00000000..2b69d616
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/getPadId.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import Pads from '/imports/api/pads';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default async function getPadId(externalId) {
+ try {
+ const { meetingId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(externalId, String);
+
+ const pad = await Pads.findOneAsync(
+ {
+ meetingId,
+ externalId,
+ },
+ {
+ fields: {
+ padId: 1,
+ },
+ },
+ );
+
+ if (pad && pad.padId) {
+ return pad.padId;
+ }
+
+ return null;
+ } catch (err) {
+ return null;
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/padCapture.js b/src/2.7.12/imports/api/pads/server/methods/padCapture.js
new file mode 100644
index 00000000..a57fc1bd
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/padCapture.js
@@ -0,0 +1,64 @@
+import Pads, { PadsUpdates } from '/imports/api/pads';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+
+export default async function padCapture(breakoutId, parentMeetingId, filename) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadCapturePubMsg';
+ const EVENT_NAME_ERROR = 'PresentationConversionUpdateSysPubMsg';
+ const EXTERNAL_ID = Meteor.settings.public.notes.id;
+
+ try {
+ const pad = await Pads.findOneAsync(
+ {
+ meetingId: breakoutId,
+ externalId: EXTERNAL_ID,
+ },
+ {
+ fields: {
+ padId: 1,
+ },
+ },
+ );
+
+ const update = await PadsUpdates.findOneAsync(
+ {
+ meetingId: breakoutId,
+ externalId: EXTERNAL_ID,
+ }, {
+ fields: {
+ rev: 1,
+ },
+ },
+ );
+
+ if (pad?.padId && update?.rev > 0) {
+ const payload = {
+ parentMeetingId,
+ breakoutId,
+ padId: pad.padId,
+ filename,
+ };
+ Logger.info(`Sending PadCapturePubMsg for meetingId=${breakoutId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`);
+ return RedisPubSub.publishMeetingMessage(CHANNEL, EVENT_NAME, parentMeetingId, payload);
+ }
+
+ // Notify that no content is available
+ const temporaryPresentationId = `${breakoutId}-notes`;
+ const payload = {
+ podId: 'DEFAULT_PRESENTATION_POD',
+ messageKey: '204',
+ code: 'not-used',
+ presentationId: temporaryPresentationId,
+ presName: filename,
+ temporaryPresentationId,
+ };
+
+ Logger.info(`No notes available for capture in meetingId=${breakoutId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`);
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME_ERROR, parentMeetingId, 'system', payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method padCapture ${err.stack}`);
+ return null;
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/pinPad.js b/src/2.7.12/imports/api/pads/server/methods/pinPad.js
new file mode 100644
index 00000000..630cd281
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/pinPad.js
@@ -0,0 +1,29 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function pinPad(externalId, pinned) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadPinnedReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(externalId, String);
+ check(pinned, Boolean);
+
+ const payload = {
+ externalId,
+ pinned,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method pinPad ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/updatePad.js b/src/2.7.12/imports/api/pads/server/methods/updatePad.js
new file mode 100644
index 00000000..19a39800
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/updatePad.js
@@ -0,0 +1,27 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function updatePad(meetingId, userId, externalId, text) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadUpdatePubMsg';
+
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(externalId, String);
+ check(text, String);
+
+ const payload = {
+ externalId,
+ text,
+ transcript: false,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method updatePad ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js b/src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js
new file mode 100644
index 00000000..45a363ed
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/methods/updateTranscriptPad.js
@@ -0,0 +1,29 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function updateTranscriptPad(meetingId, userId, externalId, text) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PadUpdatePubMsg';
+
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(externalId, String);
+ check(text, String);
+
+ // Send a special boolean denoting this was updated by the transcript system
+ // this way we can write it in the 'presenter' pad and still block manual updates by viewers
+ const payload = {
+ externalId,
+ text,
+ transcript: true,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method updateTranscriptPad ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/clearPads.js b/src/2.7.12/imports/api/pads/server/modifiers/clearPads.js
new file mode 100644
index 00000000..40d31018
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/clearPads.js
@@ -0,0 +1,30 @@
+import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+const clear = async (meetingId, name, collection) => {
+ if (meetingId) {
+ try {
+ const result = await collection.removeAsync({ meetingId });
+ if (result) {
+ Logger.info(`Cleared ${name} (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing ${name} (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const result = await collection.removeAsync({});
+ if (result) {
+ Logger.info(`Cleared ${name} (all)`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing ${name} (all). ${err}`);
+ }
+ }
+};
+
+export default async function clearPads(meetingId) {
+ await clear(meetingId, 'Pads', Pads);
+ await clear(meetingId, 'PadsSessions', PadsSessions);
+ await clear(meetingId, 'PadsUpdates', PadsUpdates);
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/contentPad.js b/src/2.7.12/imports/api/pads/server/modifiers/contentPad.js
new file mode 100644
index 00000000..c42642ca
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/contentPad.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import { patch } from '@mconf/bbb-diff';
+import { PadsUpdates } from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+export default async function contentPad(meetingId, externalId, start, end, text) {
+ try {
+ check(meetingId, String);
+ check(externalId, String);
+ check(start, Number);
+ check(end, Number);
+ check(text, String);
+
+ const selector = {
+ meetingId,
+ externalId,
+ };
+
+ const pad = await PadsUpdates.findOneAsync(selector);
+ const content = (pad && pad.content) ? pad.content : '';
+
+ const modifier = {
+ $set: {
+ content: patch(content, { start, end, text }),
+ },
+ };
+
+ await PadsUpdates.upsertAsync(selector, modifier);
+ Logger.debug(`Added pad content external=${externalId} meeting=${meetingId}`);
+ } catch (err) {
+ Logger.error(`Adding pad content to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/createGroup.js b/src/2.7.12/imports/api/pads/server/modifiers/createGroup.js
new file mode 100644
index 00000000..c1c7cc6b
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/createGroup.js
@@ -0,0 +1,39 @@
+import { check } from 'meteor/check';
+import Pads from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+import createPad from '/imports/api/pads/server/methods/createPad';
+
+export default async function createGroup(meetingId, userId, externalId, model, name) {
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(externalId, String);
+ check(model, String);
+ check(name, String);
+
+ const selector = {
+ meetingId,
+ externalId,
+ };
+
+ const modifier = {
+ meetingId,
+ externalId,
+ model,
+ name,
+ };
+
+ const { insertedId } = await Pads.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added pad group external=${externalId} meeting=${meetingId}`);
+ // Each group will get only one pad so we can control access per pad. The pad's name
+ // will be the group's externalId
+ createPad(meetingId, userId, externalId, externalId);
+ } else {
+ Logger.info(`Upserted pad group external=${externalId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding pad group to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/createPad.js b/src/2.7.12/imports/api/pads/server/modifiers/createPad.js
new file mode 100644
index 00000000..f6882193
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/createPad.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import Pads from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+export default async function createPad(meetingId, externalId, padId) {
+ try {
+ check(meetingId, String);
+ check(externalId, String);
+ check(padId, String);
+
+ const selector = {
+ meetingId,
+ externalId,
+ };
+
+ const modifier = {
+ $set: {
+ padId,
+ pinned: false,
+ },
+ };
+
+ const { insertedId } = await Pads.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added pad=${padId} external=${externalId} meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted pad=${padId} external=${externalId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding pad to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/createSession.js b/src/2.7.12/imports/api/pads/server/modifiers/createSession.js
new file mode 100644
index 00000000..615011c7
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/createSession.js
@@ -0,0 +1,35 @@
+import { check } from 'meteor/check';
+import { PadsSessions } from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+export default async function createSession(meetingId, userId, externalId, sessionId) {
+ try {
+ check(meetingId, String);
+ check(userId, String);
+ check(externalId, String);
+ check(sessionId, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $push: {
+ sessions: {
+ [externalId]: sessionId,
+ },
+ },
+ };
+
+ const { insertedId } = await PadsSessions.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.debug(`Added pad session=${sessionId} external=${externalId} user=${userId} meeting=${meetingId}`);
+ } else {
+ Logger.debug(`Upserted pad session=${sessionId} external=${externalId} user=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding pad session to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js b/src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js
new file mode 100644
index 00000000..1b8071e2
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/deleteSession.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import { PadsSessions } from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+export default async function deleteSession(meetingId, externalId, userId, sessionId) {
+ try {
+ check(meetingId, String);
+ check(externalId, String);
+ check(userId, String);
+ check(sessionId, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $pull: {
+ sessions: {
+ [externalId] : sessionId,
+ },
+ },
+ };
+
+ await PadsSessions.upsertAsync(selector, modifier);
+ Logger.debug(`Removed pad session=${sessionId} external=${externalId} user=${userId} meeting=${meetingId}`);
+ } catch (err) {
+ Logger.error(`Removing pad session to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/pinPad.js b/src/2.7.12/imports/api/pads/server/modifiers/pinPad.js
new file mode 100644
index 00000000..ddf12069
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/pinPad.js
@@ -0,0 +1,35 @@
+import { check } from 'meteor/check';
+import { default as Pads } from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+export default async function pinPad(meetingId, externalId, pinned) {
+ try {
+ check(meetingId, String);
+ check(externalId, String);
+ check(pinned, Boolean);
+
+ if (pinned) {
+ await Pads.updateAsync({ meetingId, pinned: true }, { $set: { pinned: false } });
+ }
+
+ const selector = {
+ meetingId,
+ externalId,
+ };
+
+ const modifier = {
+ $set: {
+ pinned,
+ },
+ };
+
+ const numberAffected = await Pads.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ const prefix = pinned ? '' : 'un';
+ Logger.debug(`Pad ${prefix}pinned external=${externalId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Pinning pad: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/tailPad.js b/src/2.7.12/imports/api/pads/server/modifiers/tailPad.js
new file mode 100644
index 00000000..d8d0c80e
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/tailPad.js
@@ -0,0 +1,27 @@
+import { check } from 'meteor/check';
+import { PadsUpdates } from '/imports/api/pads';
+import Logger from '/imports/startup/server/logger';
+
+export default async function tailPad(meetingId, externalId, tail) {
+ try {
+ check(meetingId, String);
+ check(externalId, String);
+ check(tail, String);
+
+ const selector = {
+ meetingId,
+ externalId,
+ };
+
+ const modifier = {
+ $set: {
+ tail,
+ },
+ };
+
+ await PadsUpdates.upsertAsync(selector, modifier);
+ Logger.debug(`Added pad tail external=${externalId} meeting=${meetingId}`);
+ } catch (err) {
+ Logger.error(`Adding pad tail to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/modifiers/updatePad.js b/src/2.7.12/imports/api/pads/server/modifiers/updatePad.js
new file mode 100644
index 00000000..f50edcd4
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/modifiers/updatePad.js
@@ -0,0 +1,35 @@
+import { check } from 'meteor/check';
+import { PadsUpdates } from '/imports/api/pads';
+import { getDataFromChangeset } from '/imports/api/pads/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function updatePad(meetingId, externalId, padId, userId, rev, changeset) {
+ try {
+ check(meetingId, String);
+ check(externalId, String);
+ check(padId, String);
+ check(userId, String);
+ check(rev, Number);
+ check(changeset, String);
+
+ const selector = {
+ meetingId,
+ externalId,
+ };
+
+ const modifier = {
+ $set: {
+ padId,
+ userId,
+ rev,
+ changeset,
+ data: getDataFromChangeset(changeset),
+ },
+ };
+
+ await PadsUpdates.upsertAsync(selector, modifier);
+ Logger.debug(`Added pad update external=${externalId} user=${userId} rev=${rev} meeting=${meetingId}`);
+ } catch (err) {
+ Logger.error(`Adding pad update to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/pads/server/publishers.js b/src/2.7.12/imports/api/pads/server/publishers.js
new file mode 100644
index 00000000..bf55bc60
--- /dev/null
+++ b/src/2.7.12/imports/api/pads/server/publishers.js
@@ -0,0 +1,79 @@
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function pads() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Pads was requested by unauth connection ${this.connection.id}`);
+ return Pads.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.info(`Publishing Pads for ${meetingId} ${userId}`);
+
+ const options = {
+ fields: {
+ padId: 0,
+ },
+ };
+
+ return Pads.find({ meetingId }, options);
+}
+
+async function padsSessions() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing PadsSessions was requested by unauth connection ${this.connection.id}`);
+ return PadsSessions.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.info(`Publishing PadsSessions for ${meetingId} ${userId}`);
+
+ return PadsSessions.find({ meetingId, userId });
+}
+
+async function padsUpdates() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing PadsUpdates was requested by unauth connection ${this.connection.id}`);
+ return PadsUpdates.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.info(`Publishing PadsUpdates for ${meetingId} ${userId}`);
+
+ return PadsUpdates.find({ meetingId });
+}
+
+function publishPads(...args) {
+ const boundPads = pads.bind(this);
+ return boundPads(...args);
+}
+
+function publishPadsSessions(...args) {
+ const boundPadsSessions = padsSessions.bind(this);
+ return boundPadsSessions(...args);
+}
+
+function publishPadsUpdates(...args) {
+ const boundPadsUpdates = padsUpdates.bind(this);
+ return boundPadsUpdates(...args);
+}
+
+Meteor.publish('pads', publishPads);
+
+Meteor.publish('pads-sessions', publishPadsSessions);
+
+Meteor.publish('pads-updates', publishPadsUpdates);
diff --git a/src/2.7.12/imports/api/polls/index.js b/src/2.7.12/imports/api/polls/index.js
new file mode 100644
index 00000000..a9646875
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/index.js
@@ -0,0 +1,17 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Polls = new Mongo.Collection('polls', collectionOptions);
+export const CurrentPoll = new Mongo.Collection('current-poll', { connection: null });
+
+if (Meteor.isServer) {
+ // We can have just one active poll per meeting
+ // makes no sense to index it by anything other than meetingId
+
+ Polls.createIndexAsync({ meetingId: 1 });
+}
+
+export default Polls;
diff --git a/src/2.7.12/imports/api/polls/index.test.js b/src/2.7.12/imports/api/polls/index.test.js
new file mode 100644
index 00000000..14452714
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/index.test.js
@@ -0,0 +1,103 @@
+import { Meteor } from 'meteor/meteor';
+import Polls from '/imports/api/polls';
+import { expect } from 'chai';
+// Modifiers
+import clearPolls from './server/modifiers/clearPolls';
+import removePoll from './server/modifiers/removePoll';
+import addPoll from './server/modifiers/addPoll';
+import updateVotes from './server/modifiers/updateVotes';
+// Handlers
+import pollStarted from './server/handlers/pollStarted';
+import pollStopped from './server/handlers/pollStopped';
+
+// mock test data
+const _id = 'sJt6JaJMsTgy64TZG';
+const meetingId = '183f0bf3a0982a127bdb8161e0c44eb696b3e75c-1623285094106';
+const requester = 'w_iotqesmfrtqj';
+const pollType = 'TF';
+const id = 'd2d9a672040fbde2a47a10bf6c37b6a4b5ae187f-1623285094108/1/1623285145173';
+const answers = [{ id: 0, key: 'True' }, { id: 1, key: 'False' }];
+const users = [];
+const questionText = '';
+const pollObj = {
+ _id,
+ meetingId,
+ requester,
+ pollType,
+ answers,
+ id,
+ users,
+ questionText,
+};
+
+Polls.insert(pollObj);
+const poll = Polls.findOne(pollObj);
+
+if (Meteor.isServer) {
+ describe('Polls Collection', () => {
+ describe('Modifiers :', () => {
+ it('Validate (#_id, #meetingId, #requester, #pollType, #users, #answers)', () => {
+ expect(poll?._id).to.be.a('string').equal(_id);
+ expect(poll?.meetingId).to.be.a('string').equal(meetingId);
+ expect(poll?.requester).to.be.a('string').equal(requester);
+ expect(poll?.pollType).to.be.a('string').equal(pollType);
+ expect(poll?.users).to.be.an('array');
+ expect(poll?.answers).to.be.an('array');
+ });
+
+ it('addPoll(): Should have added a poll', () => {
+ addPoll(meetingId, requester, { id, answers }, pollType, questionText);
+ expect(Polls.findOne({ id, meetingId })?.id).to.be.a('string').equal(pollObj.id);
+ });
+
+ it('updateVotes(): Should update vote for a poll', () => {
+ answers[0].numVotes = 1;
+ answers[1].numVotes = 0;
+
+ updateVotes({
+ id,
+ answers,
+ numResponders: 1,
+ numRespondents: 1,
+ }, meetingId);
+
+ expect(Polls.findOne({ meetingId, id })?.answers[0]?.numVotes).to.be.a('number').equal(1);
+ expect(Polls.findOne({ meetingId, id })?.answers[1]?.numVotes).to.be.a('number').equal(0);
+ });
+
+ it('removePoll(): Should have removed specified poll', () => {
+ removePoll(meetingId, id);
+ expect(Polls.findOne({ meetingId, id })).to.be.an('undefined');
+ });
+
+ it('clearPolls(): Should have cleared all polls', () => {
+ Polls.insert(pollObj);
+ clearPolls();
+ expect(Polls.findOne({ meetingId })).to.be.an('undefined');
+ });
+ });
+
+ describe('Handlers :', () => {
+ it('pollStarted(): should add a poll and reset publishedPoll flag', () => {
+ delete answers[0].numVotes;
+ delete answers[1].numVotes;
+
+ pollStarted({
+ body: {
+ userId: requester,
+ poll: { id, answers },
+ pollType,
+ question: '',
+ },
+ }, meetingId);
+
+ expect(Polls.findOne({ meetingId, id })?.id).to.be.a('string').equal(id);
+ });
+
+ it('pollStopped(): Should have removed poll', () => {
+ pollStopped({ body: { poll: { pollId: id } } }, meetingId);
+ expect(Polls.findOne({ meetingId, id })?.id).to.be.an('undefined');
+ });
+ });
+ });
+}
diff --git a/src/2.7.12/imports/api/polls/server/eventHandlers.js b/src/2.7.12/imports/api/polls/server/eventHandlers.js
new file mode 100644
index 00000000..13f8802f
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/eventHandlers.js
@@ -0,0 +1,14 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handlePollStarted from './handlers/pollStarted';
+import handlePollStopped from './handlers/pollStopped';
+import handlePollPublished from './handlers/pollPublished';
+import handleUserVoted from './handlers/userVoted';
+import handleUserResponded from './handlers/userResponded';
+import handleUserTypedResponse from './handlers/userTypedResponse';
+
+RedisPubSub.on('PollShowResultEvtMsg', handlePollPublished);
+RedisPubSub.on('PollStartedEvtMsg', handlePollStarted);
+RedisPubSub.on('PollStoppedEvtMsg', handlePollStopped);
+RedisPubSub.on('PollUpdatedEvtMsg', handleUserVoted);
+RedisPubSub.on('UserRespondedToPollRespMsg', handleUserResponded);
+RedisPubSub.on('UserRespondedToTypedPollRespMsg', handleUserTypedResponse);
diff --git a/src/2.7.12/imports/api/polls/server/handlers/pollPublished.js b/src/2.7.12/imports/api/polls/server/handlers/pollPublished.js
new file mode 100644
index 00000000..3bf181b9
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/pollPublished.js
@@ -0,0 +1,18 @@
+import { check } from 'meteor/check';
+import setPublishedPoll from '../../../meetings/server/modifiers/setPublishedPoll';
+import handleSendSystemChatForPublishedPoll from './sendPollChatMsg';
+
+const POLL_CHAT_MESSAGE = Meteor.settings.public.poll.chatMessage;
+
+export default function pollPublished({ body }, meetingId) {
+ const { pollId } = body;
+
+ check(meetingId, String);
+ check(pollId, String);
+
+ setPublishedPoll(meetingId, true);
+
+ if (POLL_CHAT_MESSAGE) {
+ handleSendSystemChatForPublishedPoll({ body }, meetingId);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/handlers/pollStarted.js b/src/2.7.12/imports/api/polls/server/handlers/pollStarted.js
new file mode 100644
index 00000000..70d10150
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/pollStarted.js
@@ -0,0 +1,22 @@
+import { check } from 'meteor/check';
+import addPoll from '../modifiers/addPoll';
+import setPublishedPoll from '../../../meetings/server/modifiers/setPublishedPoll';
+
+export default async function pollStarted({ body }, meetingId) {
+ const {
+ userId, poll, pollType, secretPoll, question,
+ } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(poll, Object);
+ check(pollType, String);
+ check(secretPoll, Boolean);
+ check(question, String);
+
+ setPublishedPoll(meetingId, false);
+
+ const result = await addPoll(meetingId, userId, poll, pollType, secretPoll, question);
+
+ return result;
+}
diff --git a/src/2.7.12/imports/api/polls/server/handlers/pollStopped.js b/src/2.7.12/imports/api/polls/server/handlers/pollStopped.js
new file mode 100644
index 00000000..82f33e4c
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/pollStopped.js
@@ -0,0 +1,23 @@
+import { check } from 'meteor/check';
+import removePoll from '../modifiers/removePoll';
+import clearPolls from '../modifiers/clearPolls';
+
+export default async function pollStopped({ body }, meetingId) {
+ const { poll } = body;
+
+ check(meetingId, String);
+
+ if (poll) {
+ const { pollId } = poll;
+
+ check(pollId, String);
+
+ const result = await removePoll(meetingId, pollId);
+
+ return result;
+ }
+
+ const result = await clearPolls(meetingId);
+
+ return result;
+}
diff --git a/src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js b/src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js
new file mode 100644
index 00000000..b246b9c3
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/sendPollChatMsg.js
@@ -0,0 +1,37 @@
+import addSystemMsg from '../../../group-chat-msg/server/modifiers/addSystemMsg';
+import caseInsensitiveReducer from '/imports/utils/caseInsensitiveReducer';
+
+export default function sendPollChatMsg({ body }, meetingId) {
+ const { poll } = body;
+
+ const CHAT_CONFIG = Meteor.settings.public.chat;
+ const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+ const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid;
+ const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
+ const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+
+ const pollResultData = poll;
+ const answers = pollResultData.answers.reduce(caseInsensitiveReducer, []);
+
+ const extra = {
+ type: 'poll',
+ pollResultData: {
+ ...pollResultData,
+ answers,
+ },
+ };
+
+ const payload = {
+ id: `${SYSTEM_CHAT_TYPE}-${CHAT_POLL_RESULTS_MESSAGE}`,
+ timestamp: Date.now(),
+ correlationId: `${PUBLIC_CHAT_SYSTEM_ID}-${Date.now()}`,
+ sender: {
+ id: PUBLIC_CHAT_SYSTEM_ID,
+ name: '',
+ },
+ message: '',
+ extra,
+ };
+
+ return addSystemMsg(meetingId, PUBLIC_GROUP_CHAT_ID, payload);
+}
diff --git a/src/2.7.12/imports/api/polls/server/handlers/userResponded.js b/src/2.7.12/imports/api/polls/server/handlers/userResponded.js
new file mode 100644
index 00000000..3b37ad2c
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/userResponded.js
@@ -0,0 +1,34 @@
+import { check } from 'meteor/check';
+import Polls from '/imports/api/polls';
+import Logger from '/imports/startup/server/logger';
+
+export default async function userResponded({ body }) {
+ const { pollId, userId, answerIds } = body;
+
+ check(pollId, String);
+ check(userId, String);
+ check(answerIds, Array);
+
+ const selector = {
+ id: pollId,
+ };
+
+ const modifier = {
+ $pull: {
+ users: userId,
+ },
+ $push: {
+ responses: { userId, answerIds },
+ },
+ };
+
+ try {
+ const numberAffected = await Polls.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updating Poll response (userId: ${userId}, response: ${JSON.stringify(answerIds)}, pollId: ${pollId})`);
+ }
+ } catch (err) {
+ Logger.error(`Updating Poll responses: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js b/src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js
new file mode 100644
index 00000000..84c15354
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/userTypedResponse.js
@@ -0,0 +1,34 @@
+import { check } from 'meteor/check';
+import Polls from '/imports/api/polls';
+import RedisPubSub from '/imports/startup/server/redis';
+
+export default async function userTypedResponse({ header, body }) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'RespondToPollReqMsg';
+
+ const { pollId, userId, answer } = body;
+ const { meetingId } = header;
+
+ check(pollId, String);
+ check(meetingId, String);
+ check(userId, String);
+ check(answer, String);
+
+ const poll = await Polls.findOneAsync({ meetingId, id: pollId });
+
+ let answerId = 0;
+ poll.answers.forEach((a) => {
+ const { id, key } = a;
+ if (key === answer) answerId = id;
+ });
+
+ const payload = {
+ requesterId: userId,
+ pollId,
+ questionId: 0,
+ answerIds: [answerId],
+ };
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+}
diff --git a/src/2.7.12/imports/api/polls/server/handlers/userVoted.js b/src/2.7.12/imports/api/polls/server/handlers/userVoted.js
new file mode 100644
index 00000000..7c9b7397
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/handlers/userVoted.js
@@ -0,0 +1,26 @@
+import { check } from 'meteor/check';
+import updateVotes from '../modifiers/updateVotes';
+
+export default async function userVoted({ body }, meetingId) {
+ const { poll } = body;
+
+ check(meetingId, String);
+ check(poll, {
+ id: String,
+ questionType: String,
+ questionText: String,
+ answers: [
+ {
+ id: Number,
+ key: String,
+ numVotes: Number,
+ },
+ ],
+ numRespondents: Number,
+ numResponders: Number,
+ });
+
+ const result = await updateVotes(poll, meetingId);
+
+ return result;
+}
diff --git a/src/2.7.12/imports/api/polls/server/index.js b/src/2.7.12/imports/api/polls/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/polls/server/methods.js b/src/2.7.12/imports/api/polls/server/methods.js
new file mode 100644
index 00000000..57d7a082
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/methods.js
@@ -0,0 +1,14 @@
+import { Meteor } from 'meteor/meteor';
+import publishTypedVote from './methods/publishTypedVote';
+import publishVote from './methods/publishVote';
+import publishPoll from './methods/publishPoll';
+import startPoll from './methods/startPoll';
+import stopPoll from './methods/stopPoll';
+
+Meteor.methods({
+ publishVote,
+ publishTypedVote,
+ publishPoll,
+ startPoll,
+ stopPoll,
+});
diff --git a/src/2.7.12/imports/api/polls/server/methods/publishPoll.js b/src/2.7.12/imports/api/polls/server/methods/publishPoll.js
new file mode 100644
index 00000000..2854ea01
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/methods/publishPoll.js
@@ -0,0 +1,37 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import Polls from '/imports/api/polls';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+export default async function publishPoll() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ShowPollResultReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const poll = await Polls.findOneAsync({ meetingId }); // TODO--send pollid from client
+ if (!poll) {
+ Logger.error(`Attempted to publish inexisting poll for meetingId: ${meetingId}`);
+ return false;
+ }
+
+ RedisPubSub.publishUserMessage(
+ CHANNEL,
+ EVENT_NAME,
+ meetingId,
+ requesterUserId,
+ ({ requesterId: requesterUserId, pollId: poll.id }),
+ );
+ } catch (err) {
+ Logger.error(`Exception while invoking method publishPoll ${err.stack}`);
+ }
+ //In this case we return true because
+ //lint asks for async functions to return some value.
+ return true;
+}
diff --git a/src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js b/src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js
new file mode 100644
index 00000000..b2e10e0b
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/methods/publishTypedVote.js
@@ -0,0 +1,79 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import Polls from '/imports/api/polls';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function publishTypedVote(id, pollAnswer) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const MAX_INPUT_CHARS = Meteor.settings.public.poll.maxTypedAnswerLength;
+ let EVENT_NAME = 'RespondToTypedPollReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(pollAnswer, String);
+ check(id, String);
+
+ const allowedToVote = await Polls.findOneAsync({
+ id,
+ users: { $in: [requesterUserId] },
+ meetingId,
+ }, {
+ fields: {
+ users: 1,
+ },
+ });
+
+ if (!allowedToVote) {
+ Logger.info(`Poll User={${requesterUserId}} has already voted in PollId={${id}}`);
+ return null;
+ }
+
+ const activePoll = await Polls.findOneAsync({ meetingId, id }, {
+ fields: {
+ answers: 1,
+ },
+ });
+
+ let existingAnsId = null;
+ activePoll.answers.forEach((a) => {
+ if (a.key === pollAnswer) existingAnsId = a.id;
+ });
+
+ if (existingAnsId !== null) {
+ check(existingAnsId, Number);
+ EVENT_NAME = 'RespondToPollReqMsg';
+
+ return RedisPubSub.publishUserMessage(
+ CHANNEL,
+ EVENT_NAME,
+ meetingId,
+ requesterUserId,
+ {
+ requesterId: requesterUserId,
+ pollId: id,
+ questionId: 0,
+ answerIds: [existingAnsId],
+ },
+ );
+ }
+
+ const payload = {
+ requesterId: requesterUserId,
+ pollId: id,
+ questionId: 0,
+ answer: pollAnswer.substring(0, MAX_INPUT_CHARS),
+ };
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method publishTypedVote ${err.stack}`);
+ }
+ //In this case we return true because
+ //lint asks for async functions to return some value.
+ return true;
+}
diff --git a/src/2.7.12/imports/api/polls/server/methods/publishVote.js b/src/2.7.12/imports/api/polls/server/methods/publishVote.js
new file mode 100644
index 00000000..e079a5d4
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/methods/publishVote.js
@@ -0,0 +1,71 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import Polls from '/imports/api/polls';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default async function publishVote(pollId, pollAnswerIds) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'RespondToPollReqMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(pollAnswerIds, Array);
+ check(pollId, String);
+
+ const allowedToVote = await Polls.findOneAsync({
+ id: pollId,
+ users: { $in: [requesterUserId] },
+ meetingId,
+ }, {
+ fields: {
+ users: 1,
+ },
+ });
+
+ if (!allowedToVote) {
+ Logger.info(`Poll User={${requesterUserId}} has already voted in PollId={${pollId}}`);
+ return null;
+ }
+
+ const selector = {
+ users: requesterUserId,
+ meetingId,
+ 'answers.id': { $all: pollAnswerIds },
+ };
+
+ const payload = {
+ requesterId: requesterUserId,
+ pollId,
+ questionId: 0,
+ answerIds: pollAnswerIds,
+ };
+
+ /*
+ We keep an array of people who were in the meeting at the time the poll
+ was started. The poll is published to them only.
+ Once they vote - their ID is removed and they cannot see the poll anymore
+ */
+ const modifier = {
+ $pull: {
+ users: requesterUserId,
+ },
+ };
+
+ const numberAffected = await Polls.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Removed responded user=${requesterUserId} from poll (meetingId: ${meetingId}, pollId: ${pollId}!)`);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method publishVote ${err.stack}`);
+ }
+ //In this case we return true because
+ //lint asks for async functions to return some value.
+ return true;
+}
diff --git a/src/2.7.12/imports/api/polls/server/methods/startPoll.js b/src/2.7.12/imports/api/polls/server/methods/startPoll.js
new file mode 100644
index 00000000..6aacb997
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/methods/startPoll.js
@@ -0,0 +1,39 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function startPoll(pollTypes, pollType, pollId, secretPoll, question, isMultipleResponse, answers) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ let EVENT_NAME = 'StartPollReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(pollId, String);
+ check(pollType, String);
+ check(secretPoll, Boolean);
+
+ const payload = {
+ requesterId: requesterUserId,
+ pollId: `${pollId}/${new Date().getTime()}`,
+ pollType,
+ secretPoll,
+ question,
+ isMultipleResponse,
+ };
+
+ if (pollType === pollTypes.Custom) {
+ EVENT_NAME = 'StartCustomPollReqMsg';
+ check(answers, Array);
+ payload.answers = answers;
+ }
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method startPoll ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/methods/stopPoll.js b/src/2.7.12/imports/api/polls/server/methods/stopPoll.js
new file mode 100644
index 00000000..7853a7bc
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/methods/stopPoll.js
@@ -0,0 +1,21 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function stopPoll() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StopPollReqMsg';
+
+ try {
+ const { meetingId, requesterUserId: requesterId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterId, String);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterId, ({ requesterId }));
+ } catch (err) {
+ Logger.error(`Exception while invoking method stopPoll ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/modifiers/addPoll.js b/src/2.7.12/imports/api/polls/server/modifiers/addPoll.js
new file mode 100644
index 00000000..7984987a
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/modifiers/addPoll.js
@@ -0,0 +1,56 @@
+import Users from '/imports/api/users';
+import Polls from '/imports/api/polls';
+import Logger from '/imports/startup/server/logger';
+import flat from 'flat';
+import { check } from 'meteor/check';
+
+export default async function addPoll(meetingId, requesterId, poll, pollType, secretPoll, question = '') {
+ check(requesterId, String);
+ check(meetingId, String);
+ check(poll, {
+ id: String,
+ answers: [
+ {
+ id: Number,
+ key: String,
+ },
+ ],
+ isMultipleResponse: Boolean,
+ });
+
+ const userSelector = {
+ meetingId,
+ userId: { $ne: requesterId },
+ clientType: { $ne: 'dial-in-user' },
+ };
+
+ const users = await Users.find(userSelector, { fields: { userId: 1 } })
+ .fetchAsync();
+ const userIds = users.map(user => user.userId);
+
+ const selector = {
+ meetingId,
+ requester: requesterId,
+ id: poll.id,
+ };
+
+ const modifier = Object.assign(
+ { meetingId },
+ { requester: requesterId },
+ { users: userIds },
+ { question, pollType, secretPoll },
+ flat(poll, { safe: true }),
+ );
+
+ try {
+ const { insertedId } = await Polls.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added Poll id=${poll.id}`);
+ } else {
+ Logger.info(`Upserted Poll id=${poll.id}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding Poll to collection: ${poll.id}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js b/src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js
new file mode 100644
index 00000000..f1e74fdd
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/modifiers/clearPolls.js
@@ -0,0 +1,26 @@
+import Polls from '/imports/api/polls';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearPolls(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await Polls.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Polls (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing Polls (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await Polls.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared Polls (all)');
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing Polls (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/modifiers/removePoll.js b/src/2.7.12/imports/api/polls/server/modifiers/removePoll.js
new file mode 100644
index 00000000..b5133edd
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/modifiers/removePoll.js
@@ -0,0 +1,23 @@
+import Polls from '/imports/api/polls';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default async function removePoll(meetingId, id) {
+ check(meetingId, String);
+ check(id, String);
+
+ const selector = {
+ meetingId,
+ id,
+ };
+
+ try {
+ const numberAffected = await Polls.removeAsync(selector);
+
+ if (numberAffected) {
+ Logger.info(`Removed Poll id=${id}`);
+ }
+ } catch (err) {
+ Logger.error(`Removing Poll from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js b/src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js
new file mode 100644
index 00000000..0352a283
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/modifiers/updateVotes.js
@@ -0,0 +1,41 @@
+import Polls from '/imports/api/polls';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import flat from 'flat';
+
+export default async function updateVotes(poll, meetingId) {
+ check(meetingId, String);
+ check(poll, Object);
+
+ const {
+ id,
+ answers,
+ numResponders,
+ numRespondents,
+ } = poll;
+
+ check(id, String);
+ check(answers, Array);
+
+ check(numResponders, Number);
+ check(numRespondents, Number);
+
+ const selector = {
+ meetingId,
+ id,
+ };
+
+ const modifier = {
+ $set: flat(poll, { safe: true }),
+ };
+
+ try {
+ const numberAffected = await Polls.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updating Polls collection vote (meetingId: ${meetingId}, pollId: ${id}!)`);
+ }
+ } catch (err) {
+ Logger.error(`Updating Polls collection vote: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/polls/server/publishers.js b/src/2.7.12/imports/api/polls/server/publishers.js
new file mode 100644
index 00000000..3bd10f52
--- /dev/null
+++ b/src/2.7.12/imports/api/polls/server/publishers.js
@@ -0,0 +1,146 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import Polls from '/imports/api/polls';
+import AuthTokenValidation, {
+ ValidationStates,
+} from '/imports/api/auth-token-validation';
+import { DDPServer } from 'meteor/ddp-server';
+import { publicationSafeGuard } from '/imports/api/common/server/helpers';
+
+Meteor.server.setPublicationStrategy('polls', DDPServer.publicationStrategies.NO_MERGE);
+
+async function currentPoll(secretPoll) {
+ check(secretPoll, Boolean);
+ const tokenValidation = await AuthTokenValidation.findOneAsync({
+ connectionId: this.connection.id,
+ });
+
+ if (
+ !tokenValidation
+ || tokenValidation.validationStatus !== ValidationStates.VALIDATED
+ ) {
+ Logger.warn(
+ `Publishing Polls was requested by unauth connection ${this.connection.id}`,
+ );
+
+ return Polls.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ const User = await Users.findOneAsync({ userId, meetingId },
+ { fields: { role: 1, presenter: 1 } });
+
+ if (!!User && User.presenter) {
+ Logger.debug('Publishing Polls', { meetingId, userId });
+
+ const selector = {
+ meetingId,
+ requester: userId,
+ };
+
+ const options = { fields: {} };
+
+ const hasPoll = await Polls.findOneAsync(selector);
+
+ if ((hasPoll && hasPoll.secretPoll) || secretPoll) {
+ options.fields.responses = 0;
+ selector.secretPoll = true;
+ } else {
+ selector.secretPoll = false;
+ }
+ Mongo.Collection._publishCursor(Polls.find(selector, options), this, 'current-poll');
+ return this.ready();
+ }
+
+ Logger.warn(
+ 'Publishing current-poll was requested by non-presenter connection',
+ { meetingId, userId, connectionId: this.connection.id },
+ );
+ Mongo.Collection._publishCursor(Polls.find({ meetingId: '' }), this, 'current-poll');
+ return this.ready();
+}
+
+function publishCurrentPoll(...args) {
+ const boundPolls = currentPoll.bind(this);
+ return boundPolls(...args);
+}
+
+Meteor.publish('current-poll', publishCurrentPoll);
+
+async function polls() {
+ const tokenValidation = await AuthTokenValidation.findOneAsync({
+ connectionId: this.connection.id,
+ });
+
+ if (
+ !tokenValidation
+ || tokenValidation.validationStatus !== ValidationStates.VALIDATED
+ ) {
+ Logger.warn(
+ `Publishing Polls was requested by unauth connection ${this.connection.id}`,
+ );
+ return Polls.find({ meetingId: '' });
+ }
+
+ const options = {
+ fields: {
+ 'answers.numVotes': 0,
+ responses: 0,
+ },
+ };
+
+ const noKeyOptions = {
+ fields: {
+ 'answers.numVotes': 0,
+ 'answers.key': 0,
+ responses: 0,
+ },
+ };
+
+ const { meetingId, userId } = tokenValidation;
+ const User = await Users.findOneAsync({ userId, meetingId },
+ { fields: { role: 1, presenter: 1 } });
+
+ Logger.debug('Publishing polls', { meetingId, userId });
+
+ const selector = {
+ meetingId,
+ users: userId,
+ };
+
+ if (User) {
+ const poll = await Polls.findOneAsync(selector, noKeyOptions);
+ if (User.presenter || poll?.pollType !== 'R-') {
+ // Monitor this publication and stop it when user is not a presenter anymore
+ // or poll type has changed
+ const comparisonFunc = async () => {
+ const user = await Users.findOneAsync({ userId, meetingId },
+ { fields: { role: 1, userId: 1 } });
+ const currentPoll = await Polls.findOneAsync(selector, noKeyOptions);
+
+ const condition = user.presenter || currentPoll?.pollType !== 'R-';
+
+ if (!condition) {
+ Logger.info(`conditions aren't filled anymore in publication ${this._name}:
+ user.presenter || currentPoll?.pollType !== 'R-' :${condition}, user.presenter: ${user.presenter} pollType: ${currentPoll?.pollType}`);
+ }
+
+ return condition;
+ };
+ publicationSafeGuard(comparisonFunc, this);
+ return Polls.find(selector, options);
+ }
+ }
+
+ return Polls.find(selector, noKeyOptions);
+}
+
+function publish(...args) {
+ const boundPolls = polls.bind(this);
+ return boundPolls(...args);
+}
+
+Meteor.publish('polls', publish);
diff --git a/src/2.7.12/imports/api/presentation-pods/index.js b/src/2.7.12/imports/api/presentation-pods/index.js
new file mode 100644
index 00000000..dc6479c9
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/index.js
@@ -0,0 +1,16 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const PresentationPods = new Mongo.Collection('presentation-pods', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the presentation pods:
+ // 1. meetingId, podId ( 4 )
+
+ PresentationPods.createIndexAsync({ meetingId: 1, podId: 1 });
+}
+
+export default PresentationPods;
diff --git a/src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js b/src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js
new file mode 100644
index 00000000..b1bcd25a
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/eventHandlers.js
@@ -0,0 +1,10 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleCreateNewPresentationPod from './handlers/createNewPresentationPod';
+import handleRemovePresentationPod from './handlers/removePresentationPod';
+import handleSyncGetPresentationPods from './handlers/syncGetPresentationPods';
+import handleSetPresenterInPod from './handlers/setPresenterInPod';
+
+RedisPubSub.on('CreateNewPresentationPodEvtMsg', handleCreateNewPresentationPod);
+RedisPubSub.on('RemovePresentationPodEvtMsg', handleRemovePresentationPod);
+RedisPubSub.on('SetPresenterInPodRespMsg', handleSetPresenterInPod);
+RedisPubSub.on('SyncGetPresentationPodsRespMsg', handleSyncGetPresentationPods);
diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js
new file mode 100644
index 00000000..56d57ed3
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/createNewPresentationPod.js
@@ -0,0 +1,19 @@
+import { check } from 'meteor/check';
+import addPresentationPod from '../modifiers/addPresentationPod';
+
+export default async function handleCreateNewPresentationPod({ body }, meetingId) {
+ check(body, {
+ currentPresenterId: String,
+ podId: String,
+ });
+ check(meetingId, String);
+
+ const { currentPresenterId, podId } = body;
+
+ const pod = {
+ currentPresenterId,
+ podId,
+ };
+
+ await addPresentationPod(meetingId, pod);
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js
new file mode 100644
index 00000000..ce15a283
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/removePresentationPod.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import removePresentationPod from '../modifiers/removePresentationPod';
+
+export default async function handleRemovePresentationPod({ body }, meetingId) {
+ check(body, Object);
+ check(meetingId, String);
+
+ const { podId } = body;
+
+ check(podId, String);
+
+ await removePresentationPod(meetingId, podId);
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js
new file mode 100644
index 00000000..71c113d8
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/setPresenterInPod.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import setPresenterInPod from '../modifiers/setPresenterInPod';
+
+export default async function handleSetPresenterInPod({ body }, meetingId) {
+ check(body, Object);
+
+ const { podId, nextPresenterId } = body;
+
+ check(podId, String);
+ check(nextPresenterId, String);
+
+ await setPresenterInPod(meetingId, podId, nextPresenterId);
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js b/src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js
new file mode 100644
index 00000000..123158f7
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/handlers/syncGetPresentationPods.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import PresentationPods from '/imports/api/presentation-pods';
+import removePresentationPod from '../modifiers/removePresentationPod';
+import addPresentationPod from '../modifiers/addPresentationPod';
+
+export default async function handleSyncGetPresentationPods({ body }, meetingId) {
+ check(body, Object);
+ check(meetingId, String);
+
+ const { pods } = body;
+ check(pods, Array);
+
+ const presentationPodIds = pods.map((pod) => pod.id);
+
+ const presentationPodsToRemove = await PresentationPods.find({
+ meetingId,
+ podId: { $nin: presentationPodIds },
+ }, { fields: { podId: 1 } }).fetchAsync();
+
+ presentationPodsToRemove.forEach(async (p) => removePresentationPod(meetingId, p.podId));
+
+ pods.forEach(async (pod) => {
+ // 'podId' and 'currentPresenterId' for some reason called 'id' and 'currentPresenter'
+ // in this message
+ const {
+ id: podId,
+ currentPresenter: currentPresenterId,
+ presentations,
+ } = pod;
+ await addPresentationPod(meetingId, { podId, currentPresenterId }, presentations);
+ });
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/index.js b/src/2.7.12/imports/api/presentation-pods/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/presentation-pods/server/methods.js b/src/2.7.12/imports/api/presentation-pods/server/methods.js
new file mode 100644
index 00000000..e69de29b
diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js
new file mode 100644
index 00000000..cfabffc7
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/addPresentationPod.js
@@ -0,0 +1,46 @@
+import { Match, check } from 'meteor/check';
+import PresentationPods from '/imports/api/presentation-pods';
+import Logger from '/imports/startup/server/logger';
+import addPresentation from '/imports/api/presentations/server/modifiers/addPresentation';
+
+// 'presentations' is passed down here when we receive a Sync message
+// and it's not used when we just create a new presentation pod
+export default async function addPresentationPod(meetingId, pod, presentations = undefined) {
+ check(meetingId, String);
+ check(presentations, Match.Maybe(Array));
+ check(pod, {
+ currentPresenterId: String,
+ podId: String,
+ });
+
+ const { currentPresenterId, podId } = pod;
+
+ const selector = {
+ meetingId,
+ podId,
+ };
+
+ const modifier = {
+ meetingId,
+ podId,
+ currentPresenterId,
+ };
+
+ try {
+ const { insertedId } = await PresentationPods.upsertAsync(selector, modifier);
+
+ // if it's a Sync message - continue adding the attached presentations
+ if (presentations) {
+ presentations.forEach(async (presentation) =>
+ addPresentation(meetingId, podId, presentation));
+ }
+
+ if (insertedId) {
+ Logger.info(`Added presentation pod id=${podId} meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted presentation pod id=${podId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding presentation pod to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js
new file mode 100644
index 00000000..ff1a6ef0
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/clearPresentationPods.js
@@ -0,0 +1,32 @@
+import PresentationPods from '/imports/api/presentation-pods';
+import Logger from '/imports/startup/server/logger';
+import clearPresentations from '/imports/api/presentations/server/modifiers/clearPresentations';
+import clearPresentationUploadToken from '/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken';
+
+export default async function clearPresentationPods(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await PresentationPods.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ await clearPresentations(meetingId);
+ await clearPresentationUploadToken(meetingId);
+ Logger.info(`Cleared Presentations Pods (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing Presentations Pods (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await PresentationPods.removeAsync({});
+
+ if (numberAffected) {
+ await clearPresentations();
+ await clearPresentationUploadToken();
+ Logger.info('Cleared Presentations Pods (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing Presentations Pods (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js
new file mode 100644
index 00000000..4853f237
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/removePresentationPod.js
@@ -0,0 +1,27 @@
+import { check } from 'meteor/check';
+import PresentationPods from '/imports/api/presentation-pods';
+import Logger from '/imports/startup/server/logger';
+import clearPresentations from '/imports/api/presentations/server/modifiers/clearPresentations';
+import clearPresentationUploadToken from '/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken';
+
+export default async function removePresentationPod(meetingId, podId) {
+ check(meetingId, String);
+ check(podId, String);
+
+ const selector = {
+ meetingId,
+ podId,
+ };
+
+ try {
+ const numberAffected = await PresentationPods.removeAsync(selector);
+
+ if (numberAffected && podId) {
+ Logger.info(`Removed presentation pod id=${podId} meeting=${meetingId}`);
+ clearPresentations(meetingId, podId);
+ clearPresentationUploadToken(meetingId, podId);
+ }
+ } catch (err) {
+ Logger.error(`Error on removing presentation pod from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js b/src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js
new file mode 100644
index 00000000..070d2e24
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/modifiers/setPresenterInPod.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import PresentationPods from '/imports/api/presentation-pods';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setPresenterInPod(meetingId, podId, nextPresenterId) {
+ check(meetingId, String);
+ check(podId, String);
+ check(nextPresenterId, String);
+
+ const selector = {
+ meetingId,
+ podId,
+ };
+
+ const modifier = {
+ $set: {
+ currentPresenterId: nextPresenterId,
+ },
+ };
+
+ try {
+ const { numberAffected } = await PresentationPods.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Set a new presenter in pod id=${podId} meeting=${meetingId} presenter=${nextPresenterId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error on setting a presenter in pod: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-pods/server/publishers.js b/src/2.7.12/imports/api/presentation-pods/server/publishers.js
new file mode 100644
index 00000000..870e8323
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-pods/server/publishers.js
@@ -0,0 +1,26 @@
+import { Meteor } from 'meteor/meteor';
+import PresentationPods from '/imports/api/presentation-pods';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function presentationPods() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing PresentationPods was requested by unauth connection ${this.connection.id}`);
+ return PresentationPods.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+ Logger.debug('Publishing presentation-pods', { meetingId, userId });
+
+ return PresentationPods.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundPresentationPods = presentationPods.bind(this);
+ return boundPresentationPods(...args);
+}
+
+Meteor.publish('presentation-pods', publish);
diff --git a/src/2.7.12/imports/api/presentation-upload-token/index.js b/src/2.7.12/imports/api/presentation-upload-token/index.js
new file mode 100644
index 00000000..4800f0a9
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/index.js
@@ -0,0 +1,7 @@
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const PresentationUploadToken = new Mongo.Collection('presentation-upload-token', collectionOptions);
+
+export default PresentationUploadToken;
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js b/src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js
new file mode 100644
index 00000000..0979d6cc
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/eventHandlers.js
@@ -0,0 +1,8 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers';
+
+import handlePresentationUploadTokenPass from './handlers/presentationUploadTokenPass';
+import handlePresentationUploadTokenFail from './handlers/presentationUploadTokenFail';
+
+RedisPubSub.on('PresentationUploadTokenPassRespMsg', processForHTML5ServerOnly(handlePresentationUploadTokenPass));
+RedisPubSub.on('PresentationUploadTokenFailRespMsg', processForHTML5ServerOnly(handlePresentationUploadTokenFail));
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js
new file mode 100644
index 00000000..34169321
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenFail.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+
+export default async function handlePresentationUploadTokenFail({ body, header }, meetingId) {
+ check(body, Object);
+
+ const { userId } = header;
+ const { podId, filename } = body;
+
+ check(userId, String);
+ check(podId, String);
+ check(filename, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ podId,
+ filename,
+ };
+
+ try {
+ const { numberAffected } = await PresentationUploadToken
+ .upsertAsync(selector, { failed: true, authzToken: null });
+
+ if (numberAffected) {
+ Logger.info(`Removing presentationToken filename=${filename} podId=${podId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Removing presentationToken from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js
new file mode 100644
index 00000000..bac1f00a
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/handlers/presentationUploadTokenPass.js
@@ -0,0 +1,45 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+
+export default async function handlePresentationUploadTokenPass({ body, header }, meetingId) {
+ check(body, Object);
+
+ const { userId } = header;
+ const { podId, authzToken, filename, temporaryPresentationId } = body;
+
+ check(userId, String);
+ check(podId, String);
+ check(authzToken, String);
+ check(filename, String);
+ check(temporaryPresentationId, String)
+
+ const selector = {
+ meetingId,
+ podId,
+ userId,
+ filename,
+ temporaryPresentationId,
+ };
+
+ const modifier = {
+ meetingId,
+ podId,
+ userId,
+ filename,
+ authzToken,
+ temporaryPresentationId,
+ failed: false,
+ used: false,
+ };
+
+ try {
+ const { insertedId } = await PresentationUploadToken.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Inserting presentationToken filename=${filename} podId=${podId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Inserting presentationToken from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/index.js b/src/2.7.12/imports/api/presentation-upload-token/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/methods.js b/src/2.7.12/imports/api/presentation-upload-token/server/methods.js
new file mode 100644
index 00000000..d417f740
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/methods.js
@@ -0,0 +1,8 @@
+import { Meteor } from 'meteor/meteor';
+import requestPresentationUploadToken from './methods/requestPresentationUploadToken';
+import setUsedToken from './methods/setUsedToken';
+
+Meteor.methods({
+ requestPresentationUploadToken,
+ setUsedToken,
+});
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js b/src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js
new file mode 100644
index 00000000..fa70d9fb
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/methods/requestPresentationUploadToken.js
@@ -0,0 +1,34 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function requestPresentationUploadToken(
+ podId,
+ filename,
+ temporaryPresentationId,
+) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'PresentationUploadTokenReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(podId, String);
+ check(filename, String);
+ check(temporaryPresentationId, String);
+
+ const payload = {
+ podId,
+ filename,
+ temporaryPresentationId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method requestPresentationUploadToken ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js b/src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js
new file mode 100644
index 00000000..597d8643
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/methods/setUsedToken.js
@@ -0,0 +1,32 @@
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+export default async function setUsedToken(authzToken) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(authzToken, String);
+
+ const payload = {
+ $set: {
+ used: true,
+ },
+ };
+
+ const numberAffected = await PresentationUploadToken.updateAsync({
+ meetingId,
+ userId: requesterUserId,
+ authzToken,
+ }, payload);
+
+ if (numberAffected) {
+ Logger.info(`Token: ${authzToken} has been set as used in meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method setUsedToken ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js b/src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js
new file mode 100644
index 00000000..fdda7272
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/modifiers/clearPresentationUploadToken.js
@@ -0,0 +1,44 @@
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearPresentationUploadToken(
+ meetingId,
+ podId,
+) {
+ if (meetingId && podId) {
+ try {
+ const numberAffected = await PresentationUploadToken.removeAsync({ meetingId, podId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Presentations Upload Token (${meetingId}, ${podId})`);
+ return true;
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing Presentations Upload Token (${meetingId}, ${podId}). ${err}`);
+ return false;
+ }
+ }
+
+ if (meetingId) {
+ try {
+ const numberAffected = await PresentationUploadToken.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Presentations Upload Token (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing Presentations Upload Token (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ // clearing presentations for the whole server
+ const numberAffected = await PresentationUploadToken.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared Presentations Upload Token (all)');
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing Presentations Upload Token (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/presentation-upload-token/server/publishers.js b/src/2.7.12/imports/api/presentation-upload-token/server/publishers.js
new file mode 100644
index 00000000..63216ba2
--- /dev/null
+++ b/src/2.7.12/imports/api/presentation-upload-token/server/publishers.js
@@ -0,0 +1,40 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function presentationUploadToken(podId, filename, temporaryPresentationId) {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing PresentationUploadToken was requested by unauth connection ${this.connection.id}`);
+ return PresentationUploadToken.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ check(podId, String);
+ check(filename, String);
+ check(temporaryPresentationId, String);
+
+ const selector = {
+ meetingId,
+ podId,
+ userId,
+ filename,
+ temporaryPresentationId,
+ };
+
+ Logger.debug('Publishing PresentationUploadToken', { meetingId, userId });
+
+ return PresentationUploadToken.find(selector);
+}
+
+function publish(...args) {
+ const boundPresentationUploadToken = presentationUploadToken.bind(this);
+ return boundPresentationUploadToken(...args);
+}
+
+Meteor.publish('presentation-upload-token', publish);
diff --git a/src/2.7.12/imports/api/presentations/index.js b/src/2.7.12/imports/api/presentations/index.js
new file mode 100644
index 00000000..9263cd55
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/index.js
@@ -0,0 +1,20 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+export const UploadingPresentations = Meteor.isClient ? new Mongo.Collection("uploadingPresentations", collectionOptions) : null;
+const Presentations = new Mongo.Collection('presentations', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the presentations:
+ // 1. meetingId, podId, id ( 3 )
+ // 2. meetingId, id ( 1 )
+ // 3. meetingId, id, current ( 2 )
+ // 4. meetingId ( 1 )
+
+ Presentations.createIndexAsync({ meetingId: 1, podId: 1, id: 1 });
+}
+
+export default Presentations;
diff --git a/src/2.7.12/imports/api/presentations/server/eventHandlers.js b/src/2.7.12/imports/api/presentations/server/eventHandlers.js
new file mode 100644
index 00000000..b146c8af
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/eventHandlers.js
@@ -0,0 +1,22 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handlePresentationAdded from './handlers/presentationAdded';
+import handlePresentationRemove from './handlers/presentationRemove';
+import handlePresentationCurrentSet from './handlers/presentationCurrentSet';
+import handlePresentationConversionUpdate from './handlers/presentationConversionUpdate';
+import handlePresentationDownloadableSet from './handlers/presentationDownloadableSet';
+import handlePresentationExport from './handlers/presentationExport';
+import handlePresentationExportToastUpdate from './handlers/presentationExportToastUpdate';
+
+RedisPubSub.on('PdfConversionInvalidErrorEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationPageGeneratedEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationPageCountErrorEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationUploadedFileTimeoutErrorEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationHasInvalidMimeTypeErrorEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationConversionUpdateEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationUploadedFileTooLargeErrorEvtMsg', handlePresentationConversionUpdate);
+RedisPubSub.on('PresentationConversionCompletedEvtMsg', handlePresentationAdded);
+RedisPubSub.on('RemovePresentationEvtMsg', handlePresentationRemove);
+RedisPubSub.on('SetCurrentPresentationEvtMsg', handlePresentationCurrentSet);
+RedisPubSub.on('SetPresentationDownloadableEvtMsg', handlePresentationDownloadableSet);
+RedisPubSub.on('NewPresFileAvailableEvtMsg', handlePresentationExport);
+RedisPubSub.on('PresAnnStatusEvtMsg', handlePresentationExportToastUpdate);
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js
new file mode 100644
index 00000000..b2d9c1d8
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationAdded.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+import addPresentation from '../modifiers/addPresentation';
+
+export default async function handlePresentationAdded({ body }, meetingId) {
+ check(body, Object);
+
+ const { presentation, podId } = body;
+
+ check(meetingId, String);
+ check(podId, String);
+ check(presentation, Object);
+
+ await addPresentation(meetingId, podId, presentation);
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js
new file mode 100644
index 00000000..996e5c6a
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationConversionUpdate.js
@@ -0,0 +1,137 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Presentations from '/imports/api/presentations';
+
+// const OFFICE_DOC_CONVERSION_SUCCESS_KEY = 'OFFICE_DOC_CONVERSION_SUCCESS';
+const OFFICE_DOC_CONVERSION_FAILED_KEY = 'OFFICE_DOC_CONVERSION_FAILED';
+const OFFICE_DOC_CONVERSION_INVALID_KEY = 'OFFICE_DOC_CONVERSION_INVALID';
+const SUPPORTED_DOCUMENT_KEY = 'SUPPORTED_DOCUMENT';
+const UNSUPPORTED_DOCUMENT_KEY = 'UNSUPPORTED_DOCUMENT';
+const PAGE_COUNT_FAILED_KEY = 'PAGE_COUNT_FAILED';
+const PAGE_COUNT_EXCEEDED_KEY = 'PAGE_COUNT_EXCEEDED';
+const PDF_HAS_BIG_PAGE_KEY = 'PDF_HAS_BIG_PAGE';
+const GENERATED_SLIDE_KEY = 'GENERATED_SLIDE';
+const FILE_TOO_LARGE_KEY = 'FILE_TOO_LARGE';
+const CONVERSION_TIMEOUT_KEY = 'CONVERSION_TIMEOUT';
+const INVALID_MIME_TYPE_KEY = 'INVALID_MIME_TYPE';
+const NO_CONTENT = '204';
+// const GENERATING_THUMBNAIL_KEY = 'GENERATING_THUMBNAIL';
+// const GENERATED_THUMBNAIL_KEY = 'GENERATED_THUMBNAIL';
+// const GENERATING_TEXTFILES_KEY = 'GENERATING_TEXTFILES';
+// const GENERATED_TEXTFILES_KEY = 'GENERATED_TEXTFILES';
+// const GENERATING_SVGIMAGES_KEY = 'GENERATING_SVGIMAGES';
+// const GENERATED_SVGIMAGES_KEY = 'GENERATED_SVGIMAGES';
+// const CONVERSION_COMPLETED_KEY = 'CONVERSION_COMPLETED';
+
+export default async function handlePresentationConversionUpdate({ body }, meetingId) {
+ check(body, Object);
+
+ const {
+ presentationId, podId, messageKey: status, presName: presentationName, temporaryPresentationId,
+ } = body;
+
+ check(meetingId, String);
+ check(presentationId, Match.Maybe(String));
+ check(podId, Match.Maybe(String));
+ check(status, String);
+ check(temporaryPresentationId, Match.Maybe(String));
+
+ const statusModifier = {
+ 'conversion.status': status,
+ 'conversion.error': false,
+ 'conversion.done': false,
+ };
+
+ switch (status) {
+ case SUPPORTED_DOCUMENT_KEY:
+ statusModifier.id = presentationId;
+ statusModifier.name = presentationName;
+ break;
+
+ case FILE_TOO_LARGE_KEY:
+ statusModifier['conversion.maxFileSize'] = body.maxFileSize;
+ case UNSUPPORTED_DOCUMENT_KEY:
+ case OFFICE_DOC_CONVERSION_FAILED_KEY:
+ case INVALID_MIME_TYPE_KEY:
+ statusModifier['conversion.error'] = true;
+ statusModifier['conversion.fileMime'] = body.fileMime;
+ statusModifier['conversion.fileExtension'] = body.fileExtension;
+ case OFFICE_DOC_CONVERSION_INVALID_KEY:
+ case PAGE_COUNT_FAILED_KEY:
+ case PAGE_COUNT_EXCEEDED_KEY:
+ statusModifier['conversion.maxNumberPages'] = body.maxNumberPages;
+ case PDF_HAS_BIG_PAGE_KEY:
+ statusModifier.id = presentationId ?? body.presentationToken;
+ statusModifier.name = presentationName ?? body.presentationName;
+ statusModifier['conversion.error'] = true;
+ statusModifier['conversion.bigPageSize'] = body.bigPageSize;
+ break;
+ case CONVERSION_TIMEOUT_KEY:
+ statusModifier['conversion.error'] = true;
+ statusModifier['conversion.maxNumberOfAttempts'] = body.maxNumberOfAttempts;
+ statusModifier['conversion.numberPageError'] = body.page;
+
+ break;
+ case GENERATED_SLIDE_KEY:
+ statusModifier['conversion.pagesCompleted'] = body.pagesCompleted;
+ statusModifier['conversion.numPages'] = body.numberOfPages;
+ break;
+
+ case NO_CONTENT:
+ statusModifier['conversion.done'] = false;
+ statusModifier['conversion.error'] = true;
+ statusModifier.id = presentationId;
+ statusModifier.name = presentationName;
+ break;
+
+ default:
+ break;
+ }
+
+ const selector = {
+ meetingId,
+ podId,
+ id: presentationId ?? body.presentationToken,
+ };
+
+ let modifier
+ if (temporaryPresentationId){
+ modifier = {
+ $set: Object.assign({ meetingId, podId, renderedInToast: false, temporaryPresentationId, }, statusModifier),
+ };
+ } else {
+ modifier = {
+ $set: Object.assign({ meetingId, renderedInToast: false, podId }, statusModifier),
+ };
+ }
+
+ try {
+ const presentations = await Presentations.find(selector).fetchAsync();
+ const isPresentationPersisted = await Promise.all(presentations.map(async (item) => {
+ if (item.temporaryPresentationId && temporaryPresentationId) {
+ return item.temporaryPresentationId === temporaryPresentationId;
+ } else {
+ return item.id === presentationId;
+ }
+ }));
+ const isPersisted = isPresentationPersisted.some((item) => item === true);
+
+ let insertedID;
+ if (!isPersisted) {
+ const { insertedId } = await Presentations.upsertAsync(selector, modifier);
+ insertedID = insertedId;
+ } else {
+ selector['conversion.error'] = false;
+ const { insertedId } = await Presentations.updateAsync(selector, modifier);
+ insertedID = insertedId;
+ }
+
+ if (insertedID) {
+ Logger.info(`Updated presentation conversion status=${status} id=${presentationId} meeting=${meetingId}`);
+ } else {
+ Logger.debug('Upserted presentation conversion', { status, presentationId, meetingId });
+ }
+ } catch (err) {
+ Logger.error(`Updating conversion status presentation to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js
new file mode 100644
index 00000000..d4b53ed0
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationCurrentSet.js
@@ -0,0 +1,15 @@
+import { check } from 'meteor/check';
+import setCurrentPresentation from '../modifiers/setCurrentPresentation';
+
+export default async function handlePresentationCurrentSet({ body }, meetingId) {
+ check(body, Object);
+
+ const { presentationId, podId } = body;
+
+ check(meetingId, String);
+ check(presentationId, String);
+ check(podId, String);
+
+ const result = await setCurrentPresentation(meetingId, podId, presentationId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js
new file mode 100644
index 00000000..67884b9e
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationDownloadableSet.js
@@ -0,0 +1,20 @@
+import { check } from 'meteor/check';
+import setPresentationDownloadable from '../modifiers/setPresentationDownloadable';
+
+export default async function handlePresentationDownloadableSet({ body }, meetingId) {
+ check(body, Object);
+
+ const {
+ presentationId, podId, downloadable, downloadableExtension,
+ } = body;
+
+ check(meetingId, String);
+ check(presentationId, String);
+ check(podId, String);
+ check(downloadable, Boolean);
+ check(downloadableExtension, String);
+
+ const result = await setPresentationDownloadable(meetingId, podId, presentationId, downloadable,
+ downloadableExtension);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js
new file mode 100644
index 00000000..985276c0
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationExport.js
@@ -0,0 +1,42 @@
+import { check } from 'meteor/check';
+import sendExportedPresentationChatMsg from '/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg';
+import setPresentationExporting from '/imports/api/presentations/server/modifiers/setPresentationExporting';
+import setOriginalUriDownload from '/imports/api/presentations/server/modifiers/setOriginalUriDownload';
+
+export default async function handlePresentationExport({ body }, meetingId) {
+ check(body, Object);
+ check(meetingId, String);
+
+ const {
+ annotatedFileURI,
+ originalFileURI,
+ convertedFileURI,
+ presId,
+ fileStateType,
+ } = body;
+
+ check(annotatedFileURI, String);
+ check(originalFileURI, String);
+ check(convertedFileURI, String);
+ check(presId, String);
+ check(fileStateType, String);
+
+ if (fileStateType === 'Original' || fileStateType === 'Converted') {
+ if (fileStateType === 'Converted') {
+ await setOriginalUriDownload(
+ meetingId,
+ presId,
+ convertedFileURI,
+ );
+ } else {
+ await setOriginalUriDownload(
+ meetingId,
+ presId,
+ originalFileURI,
+ );
+ }
+ } else {
+ await sendExportedPresentationChatMsg(meetingId, presId, annotatedFileURI, fileStateType);
+ }
+ await setPresentationExporting(meetingId, presId, { status: 'EXPORTED' });
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js
new file mode 100644
index 00000000..ecb6b92f
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationExportToastUpdate.js
@@ -0,0 +1,21 @@
+import { check } from 'meteor/check';
+import setPresentationExporting from '/imports/api/presentations/server/modifiers/setPresentationExporting';
+
+export default async function handlePresentationExportToastUpdate({ body }, meetingId) {
+ check(body, Object);
+ check(meetingId, String);
+
+ const {
+ presId, pageNumber, totalPages, status, error,
+ } = body;
+
+ check(presId, String);
+ check(pageNumber, Number);
+ check(totalPages, Number);
+ check(status, String);
+ check(error, Boolean);
+
+ await setPresentationExporting(meetingId, presId, {
+ pageNumber, totalPages, status, error,
+ });
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js b/src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js
new file mode 100644
index 00000000..f77faabc
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/presentationRemove.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+
+import removePresentation from '../modifiers/removePresentation';
+
+export default async function handlePresentationRemove({ body }, meetingId) {
+ const { podId, presentationId } = body;
+
+ check(meetingId, String);
+ check(podId, String);
+ check(presentationId, String);
+
+ const result = await removePresentation(meetingId, podId, presentationId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js b/src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js
new file mode 100644
index 00000000..e3ff491b
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/handlers/sendExportedPresentationChatMsg.js
@@ -0,0 +1,37 @@
+import addSystemMsg from '/imports/api/group-chat-msg/server/modifiers/addSystemMsg';
+import Presentations from '/imports/api/presentations';
+
+const DEFAULT_FILENAME = 'annotated_slides.pdf';
+
+export default async function sendExportedPresentationChatMsg(meetingId,
+ presentationId, fileURI, fileStateType) {
+ const CHAT_CONFIG = Meteor.settings.public.chat;
+ const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+ const PUBLIC_CHAT_SYSTEM_ID = CHAT_CONFIG.system_userid;
+ const CHAT_EXPORTED_PRESENTATION_MESSAGE = CHAT_CONFIG
+ .system_messages_keys.chat_exported_presentation;
+ const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+
+ const pres = await Presentations.findOneAsync({ meetingId, id: presentationId });
+
+ const extra = {
+ type: 'presentation',
+ fileURI,
+ filename: pres?.name || DEFAULT_FILENAME,
+ fileStateType,
+ };
+
+ const payload = {
+ id: `${SYSTEM_CHAT_TYPE}-${CHAT_EXPORTED_PRESENTATION_MESSAGE}`,
+ timestamp: Date.now(),
+ correlationId: `${PUBLIC_CHAT_SYSTEM_ID}-${Date.now()}`,
+ sender: {
+ id: PUBLIC_CHAT_SYSTEM_ID,
+ name: '',
+ },
+ message: '',
+ extra,
+ };
+ const result = await addSystemMsg(meetingId, PUBLIC_GROUP_CHAT_ID, payload);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/presentations/server/index.js b/src/2.7.12/imports/api/presentations/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/presentations/server/methods.js b/src/2.7.12/imports/api/presentations/server/methods.js
new file mode 100644
index 00000000..a99dbf40
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/methods.js
@@ -0,0 +1,14 @@
+import { Meteor } from 'meteor/meteor';
+import removePresentation from './methods/removePresentation';
+import setPresentationRenderedInToast from './methods/setPresentationRenderedInToast';
+import setPresentation from './methods/setPresentation';
+import setPresentationDownloadable from './methods/setPresentationDownloadable';
+import exportPresentation from './methods/exportPresentation';
+
+Meteor.methods({
+ removePresentation,
+ setPresentation,
+ setPresentationDownloadable,
+ exportPresentation,
+ setPresentationRenderedInToast,
+});
diff --git a/src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js b/src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js
new file mode 100644
index 00000000..70d5d458
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/methods/exportPresentation.js
@@ -0,0 +1,61 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+import setPresentationExporting from '/imports/api/presentations/server/modifiers/setPresentationExporting';
+import Presentations from '/imports/api/presentations';
+
+const EXPORTING_THRESHOLD_PER_SLIDE = 2500;
+
+export default async function exportPresentation(presentationId, fileStateType) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'MakePresentationDownloadReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(presentationId, String);
+
+ const payload = {
+ presId: presentationId,
+ allPages: true,
+ fileStateType,
+ pages: [],
+ };
+
+ const setObserver = async () => {
+ let timeoutRef = null;
+
+ const selector = { meetingId, id: presentationId };
+ const cursor = Presentations.find(selector);
+ const pres = await Presentations.findOneAsync(selector);
+ const numPages = pres?.pages?.length ?? 1;
+ const threshold = EXPORTING_THRESHOLD_PER_SLIDE * numPages;
+
+ const observer = cursor.observe({
+ changed: (doc) => {
+ const { status } = doc.exportation;
+
+ if (status === 'EXPORTED') {
+ Meteor.clearTimeout(timeoutRef);
+ }
+ },
+ });
+
+ timeoutRef = Meteor.setTimeout(async () => {
+ observer.stop();
+ await setPresentationExporting(meetingId, presentationId, { status: 'TIMEOUT' });
+ }, threshold);
+ };
+
+ await setPresentationExporting(meetingId, presentationId, { status: 'RUNNING' });
+ await setObserver();
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method exportPresentation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/methods/removePresentation.js b/src/2.7.12/imports/api/presentations/server/methods/removePresentation.js
new file mode 100644
index 00000000..8a2ef1e8
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/methods/removePresentation.js
@@ -0,0 +1,28 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function removePresentation(presentationId, podId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'RemovePresentationPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(presentationId, String);
+ check(podId, String);
+
+ const payload = {
+ presentationId,
+ podId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method removePresentation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/methods/setPresentation.js b/src/2.7.12/imports/api/presentations/server/methods/setPresentation.js
new file mode 100644
index 00000000..3ff16aee
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/methods/setPresentation.js
@@ -0,0 +1,28 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function setPresentation(presentationId, podId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetCurrentPresentationPubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(presentationId, String);
+ check(podId, String);
+
+ const payload = {
+ presentationId,
+ podId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setPresentation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js b/src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js
new file mode 100644
index 00000000..1ad147ca
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/methods/setPresentationDownloadable.js
@@ -0,0 +1,31 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function setPresentationDownloadable(presentationId, downloadable, fileStateType) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetPresentationDownloadablePubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(downloadable, Match.Maybe(Boolean));
+ check(presentationId, String);
+ check(fileStateType, Match.Maybe(String));
+
+ const payload = {
+ presentationId,
+ podId: 'DEFAULT_PRESENTATION_POD',
+ downloadable,
+ fileStateType,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setPresentationDownloadable ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js b/src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js
new file mode 100644
index 00000000..6ef32720
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/methods/setPresentationRenderedInToast.js
@@ -0,0 +1,28 @@
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+export default async function setPresentationRenderedInToast() {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ $set: { renderedInToast: true },
+ };
+
+ const numberAffected = await Presentations.updateAsync({
+ renderedInToast: false,
+ meetingId,
+ }, payload, {multi: true});
+
+ if (numberAffected) {
+ Logger.info(`Presentations have been set as rendered in the toast within meeting=${meetingId}, ${numberAffected} documents affected.`);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method setPresentationRenderedInToast ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js b/src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js
new file mode 100644
index 00000000..06819c9c
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/addPresentation.js
@@ -0,0 +1,88 @@
+import axios from 'axios';
+import { check } from 'meteor/check';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+import flat from 'flat';
+import addSlide from '/imports/api/slides/server/modifiers/addSlide';
+import setCurrentPresentation from './setCurrentPresentation';
+
+const getSlideText = async (url) => {
+ let content = '';
+ try {
+ const request = await axios(url);
+ content = request.data.toString();
+ } catch (error) {
+ Logger.error(`No file found. ${error}`);
+ }
+ return content;
+};
+
+const addSlides = (meetingId, podId, presentationId, slides) => {
+ slides.forEach(async (slide) => {
+ const content = await getSlideText(slide.txtUri);
+
+ Object.assign(slide, { content });
+
+ await addSlide(meetingId, podId, presentationId, slide);
+ });
+};
+
+export default async function addPresentation(meetingId, podId, presentation) {
+ check(meetingId, String);
+ check(podId, String);
+ check(presentation, {
+ id: String,
+ name: String,
+ current: Boolean,
+ temporaryPresentationId: String,
+ pages: [
+ {
+ id: String,
+ num: Number,
+ thumbUri: String,
+ txtUri: String,
+ svgUri: String,
+ current: Boolean,
+ xOffset: Number,
+ yOffset: Number,
+ widthRatio: Number,
+ heightRatio: Number,
+ },
+ ],
+ downloadable: Boolean,
+ removable: Boolean,
+ isInitialPresentation: Boolean,
+ filenameConverted: String,
+ });
+
+ const selector = {
+ meetingId,
+ podId,
+ id: presentation.id,
+ };
+
+ const modifier = {
+ $set: Object.assign({
+ meetingId,
+ podId,
+ 'conversion.done': true,
+ 'conversion.error': false,
+ 'exportation.status': null,
+ }, flat(presentation, { safe: true })),
+ };
+
+ try {
+ await Presentations.upsertAsync(selector, modifier);
+
+ await addSlides(meetingId, podId, presentation.id, presentation.pages);
+
+ if (presentation.current) {
+ setCurrentPresentation(meetingId, podId, presentation.id);
+ Logger.info(`Added presentation id=${presentation.id} meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted presentation id=${presentation.id} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding presentation to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js b/src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js
new file mode 100644
index 00000000..663b9d36
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/clearPresentations.js
@@ -0,0 +1,44 @@
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearPresentations(meetingId, podId) {
+ // clearing presentations for 1 pod
+ if (meetingId && podId) {
+ try {
+ const numberAffected = await Presentations.removeAsync({ meetingId, podId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Presentations (${meetingId}, ${podId})`);
+ return true;
+ }
+ } catch (err) {
+ Logger.error(`Error on cleaning Presentations (${meetingId}, ${podId}). ${err}`);
+ return false;
+ }
+ }
+
+ // clearing presentations for the whole meeting
+ if (meetingId) {
+ try {
+ const numberAffected = await Presentations.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Presentations (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on cleaning Presentations (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await Presentations.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared Presentations (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error on cleaning Presentations (all). ${err}`);
+ }
+ }
+ // Returned true because the function requires some value to be returned.
+ return true;
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js b/src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js
new file mode 100644
index 00000000..46498c5b
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/removePresentation.js
@@ -0,0 +1,28 @@
+import { check } from 'meteor/check';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+
+import clearSlidesPresentation from '/imports/api/slides/server/modifiers/clearSlidesPresentation';
+
+export default async function removePresentation(meetingId, podId, presentationId) {
+ check(meetingId, String);
+ check(presentationId, String);
+ check(podId, String);
+
+ const selector = {
+ meetingId,
+ podId,
+ id: presentationId,
+ };
+
+ try {
+ const numberAffected = await Presentations.removeAsync(selector);
+
+ if (numberAffected) {
+ await clearSlidesPresentation(meetingId, presentationId);
+ Logger.info(`Removed presentation id=${presentationId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Removing presentation from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js b/src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js
new file mode 100644
index 00000000..6a735f6f
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/setCurrentPresentation.js
@@ -0,0 +1,70 @@
+import { check } from 'meteor/check';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setCurrentPresentation(meetingId, podId, presentationId) {
+ check(meetingId, String);
+ check(presentationId, String);
+ check(podId, String);
+
+ const oldCurrent = {
+ selector: {
+ meetingId,
+ podId,
+ current: true,
+ id: {
+ $ne: presentationId,
+ },
+ },
+ modifier: {
+ $set: { current: false },
+ },
+ callback: (err) => {
+ if (err) {
+ Logger.error(`Unsetting the current presentation: ${err}`);
+ return;
+ }
+
+ Logger.info('Unsetted as current presentation');
+ },
+ };
+
+ const newCurrent = {
+ selector: {
+ meetingId,
+ podId,
+ id: presentationId,
+ },
+ modifier: {
+ $set: { current: true },
+ },
+ callback: (err) => {
+ if (err) {
+ Logger.error(`Setting as current presentation id=${presentationId}: ${err}`);
+ return;
+ }
+
+ Logger.info(`Setted as current presentation id=${presentationId}`);
+ },
+ };
+
+ const oldPresentation = await Presentations.findOneAsync(oldCurrent.selector);
+ const newPresentation = await Presentations.findOneAsync(newCurrent.selector);
+// We update it before unset current to avoid the case where theres no current presentation.
+ if (newPresentation) {
+ try{
+ await Presentations.updateAsync(newPresentation._id, newCurrent.modifier);
+ } catch(e){
+ newCurrent.callback(e);
+ }
+ }
+
+ if (oldPresentation) {
+ try {
+ await Presentations.updateAsync(oldCurrent.selector, oldCurrent.modifier, { multi: true });
+ } catch (e) {
+ oldCurrent.callback(e);
+ }
+ }
+
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js b/src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js
new file mode 100644
index 00000000..d00f1ddb
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/setOriginalUriDownload.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setOriginalUriDownload(meetingId, presentationId, fileURI) {
+ check(meetingId, String);
+ check(presentationId, String);
+ check(fileURI, String);
+
+ const selector = {
+ meetingId,
+ id: presentationId,
+ };
+
+ const modifier = {
+ $set: {
+ originalFileURI: fileURI,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Presentations.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Set URI for file ${presentationId} in meeting ${meetingId} URI=${fileURI}`);
+ }
+ } catch (err) {
+ Logger.error(`Could not set URI for file ${presentationId} in meeting ${meetingId} ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js
new file mode 100644
index 00000000..a7623fc0
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationDownloadable.js
@@ -0,0 +1,39 @@
+import { check } from 'meteor/check';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setPresentationDownloadable(meetingId, podId,
+ presentationId, downloadable, extensionToBeDownloadable) {
+ check(meetingId, String);
+ check(presentationId, String);
+ check(podId, String);
+ check(downloadable, Boolean);
+ check(extensionToBeDownloadable, String);
+
+ const selector = {
+ meetingId,
+ podId,
+ id: presentationId,
+ };
+
+ let downloadableExtension = extensionToBeDownloadable;
+ if (!downloadable) {
+ downloadableExtension = '';
+ }
+ const modifier = {
+ $set: {
+ downloadable,
+ downloadableExtension,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Presentations.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Set downloadable status on presentation {${presentationId} in meeting {${meetingId}}`);
+ }
+ } catch (err) {
+ Logger.error(`Could not set downloadable on pres {${presentationId} in meeting {${meetingId}} ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js
new file mode 100644
index 00000000..ec91f762
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/modifiers/setPresentationExporting.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setPresentationExporting(meetingId, presentationId, exportation) {
+ check(meetingId, String);
+ check(presentationId, String);
+ check(exportation, Object);
+
+ const selector = {
+ meetingId,
+ id: presentationId,
+ };
+
+ const modifier = {
+ $set: {
+ exportation,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Presentations.upsertAsync(selector, modifier);
+
+ if (numberAffected && ['RUNNING', 'EXPORTED'].includes(exportation?.status)) {
+ Logger.info(`Set exporting status on presentation ${presentationId} in meeting ${meetingId} status=${exportation.status}`);
+ }
+ } catch (err) {
+ Logger.error(`Could not set exporting status on pres ${presentationId} in meeting ${meetingId} ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/presentations/server/publishers.js b/src/2.7.12/imports/api/presentations/server/publishers.js
new file mode 100644
index 00000000..df2a8e47
--- /dev/null
+++ b/src/2.7.12/imports/api/presentations/server/publishers.js
@@ -0,0 +1,27 @@
+import { Meteor } from 'meteor/meteor';
+import Presentations from '/imports/api/presentations';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function presentations() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Presentation was requested by unauth connection ${this.connection.id}`);
+ return Presentations.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing Presentations', { meetingId, userId });
+
+ return Presentations.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundPresentations = presentations.bind(this);
+ return boundPresentations(...args);
+}
+
+Meteor.publish('presentations', publish);
diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/errors.js b/src/2.7.12/imports/api/screenshare/client/bridge/errors.js
new file mode 100644
index 00000000..11339954
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/client/bridge/errors.js
@@ -0,0 +1,59 @@
+import {
+ SFU_SERVER_SIDE_ERRORS
+} from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
+
+// Mapped getDisplayMedia errors. These are bridge agnostic
+// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia
+const GDM_ERRORS = {
+ // Fallback error: 1130
+ 1130: 'GetDisplayMediaGenericError',
+ 1131: 'AbortError',
+ 1132: 'InvalidStateError',
+ 1133: 'OverconstrainedError',
+ 1134: 'TypeError',
+ 1135: 'NotFoundError',
+ 1136: 'NotAllowedError',
+ 1137: 'NotSupportedError',
+ 1138: 'NotReadableError',
+};
+
+// Import as many bridge specific errors you want in this utilitary and shove
+// them into the error class slots down below.
+const CLIENT_SIDE_ERRORS = {
+ 1101: "SIGNALLING_TRANSPORT_DISCONNECTED",
+ 1102: "SIGNALLING_TRANSPORT_CONNECTION_FAILED",
+ 1104: "SCREENSHARE_PLAY_FAILED",
+ 1105: "PEER_NEGOTIATION_FAILED",
+ 1107: "ICE_STATE_FAILED",
+ 1110: "ENDED_WHILE_STARTING",
+ 1120: "MEDIA_TIMEOUT",
+ 1121: "UNKNOWN_ERROR",
+};
+
+const SERVER_SIDE_ERRORS = {
+ ...SFU_SERVER_SIDE_ERRORS,
+}
+
+const AGGREGATED_ERRORS = {
+ ...CLIENT_SIDE_ERRORS,
+ ...SERVER_SIDE_ERRORS,
+ ...GDM_ERRORS,
+}
+
+const expandErrors = () => {
+ const expandedErrors = Object.keys(AGGREGATED_ERRORS).reduce((map, key) => {
+ map[AGGREGATED_ERRORS[key]] = { errorCode: key, errorMessage: AGGREGATED_ERRORS[key] };
+ return map;
+ }, {});
+
+ return { ...AGGREGATED_ERRORS, ...expandedErrors };
+}
+
+const SCREENSHARING_ERRORS = expandErrors();
+
+export {
+ GDM_ERRORS,
+ // All errors, [code]: [message]
+ // Expanded errors. It's AGGREGATED + message: { errorCode, errorMessage }
+ SCREENSHARING_ERRORS,
+}
diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/index.js b/src/2.7.12/imports/api/screenshare/client/bridge/index.js
new file mode 100644
index 00000000..2188d631
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/client/bridge/index.js
@@ -0,0 +1,5 @@
+import KurentoBridge from './kurento';
+
+const screenshareBridge = new KurentoBridge();
+
+export default screenshareBridge;
diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/kurento.js b/src/2.7.12/imports/api/screenshare/client/bridge/kurento.js
new file mode 100644
index 00000000..71d1aaa3
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/client/bridge/kurento.js
@@ -0,0 +1,405 @@
+import Auth from '/imports/ui/services/auth';
+import logger from '/imports/startup/client/logger';
+import BridgeService from './service';
+import ScreenshareBroker from '/imports/ui/services/bbb-webrtc-sfu/screenshare-broker';
+import { setIsSharing, screenShareEndAlert, setOutputDeviceId } from '/imports/ui/components/screenshare/service';
+import { SCREENSHARING_ERRORS } from './errors';
+import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
+
+const SFU_CONFIG = Meteor.settings.public.kurento;
+const SFU_URL = SFU_CONFIG.wsUrl;
+const OFFERING = SFU_CONFIG.screenshare.subscriberOffering;
+const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
+const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
+const { screenshare: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
+const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
+
+const BRIDGE_NAME = 'kurento'
+const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
+const SEND_ROLE = 'send';
+const RECV_ROLE = 'recv';
+const DEFAULT_VOLUME = 1;
+
+// the error-code mapping is bridge specific; that's why it's not in the errors util
+const ERROR_MAP = {
+ 1301: SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_DISCONNECTED,
+ 1302: SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_CONNECTION_FAILED,
+ 1305: SCREENSHARING_ERRORS.PEER_NEGOTIATION_FAILED,
+ 1307: SCREENSHARING_ERRORS.ICE_STATE_FAILED,
+ 1310: SCREENSHARING_ERRORS.ENDED_WHILE_STARTING,
+};
+
+const mapErrorCode = (error) => {
+ const { errorCode } = error;
+ const mappedError = ERROR_MAP[errorCode];
+
+ if (errorCode == null || mappedError == null) return error;
+ error.errorCode = mappedError.errorCode;
+ error.errorMessage = mappedError.errorMessage;
+ error.message = mappedError.errorMessage;
+
+ return error;
+}
+
+export default class KurentoScreenshareBridge {
+ constructor() {
+ this.role;
+ this.broker;
+ this._gdmStream;
+ this.hasAudio = false;
+ this.connectionAttempts = 0;
+ this.reconnecting = false;
+ this.reconnectionTimeout;
+ this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
+ this.startedOnce = false;
+ this.outputDeviceId = null;
+ }
+
+ get gdmStream() {
+ return this._gdmStream;
+ }
+
+ set gdmStream(stream) {
+ this._gdmStream = stream;
+ }
+
+ _shouldReconnect() {
+ // Sender/presenter reconnect is *not* implemented yet
+ return this.reconnectionTimeout == null && this.role === RECV_ROLE;
+ }
+
+ /**
+ * Get the RTCPeerConnection object related to the screensharing stream.
+ * @returns {Object} The RTCPeerConnection object related to the presenter/
+ * viewer peer. If there's no stream being shared, returns
+ * null.
+ */
+ getPeerConnection() {
+ try {
+ let peerConnection = null;
+
+ if (this.broker && this.broker.webRtcPeer) {
+ peerConnection = this.broker.webRtcPeer.peerConnection;
+ }
+
+ return peerConnection;
+ } catch (error) {
+ return null;
+ }
+ }
+
+ inboundStreamReconnect() {
+ const currentRestartIntervalMs = this.restartIntervalMs;
+
+ logger.warn({
+ logCode: 'screenshare_viewer_reconnect',
+ extraInfo: {
+ reconnecting: this.reconnecting,
+ role: this.role,
+ bridge: BRIDGE_NAME,
+ },
+ }, 'Screenshare viewer is reconnecting');
+
+ // Cleanly stop everything before triggering a reconnect
+ this._stop();
+ // Create new reconnect interval time
+ this.restartIntervalMs = BridgeService.getNextReconnectionInterval(currentRestartIntervalMs);
+ this.view(this.hasAudio).then(() => {
+ this.clearReconnectionTimeout();
+ }).catch((error) => {
+ // Error handling is a no-op because it will be "handled" in handleViewerFailure
+ logger.debug({
+ logCode: 'screenshare_reconnect_failed',
+ extraInfo: {
+ errorCode: error.errorCode,
+ errorMessage: error.errorMessage,
+ reconnecting: this.reconnecting,
+ role: this.role,
+ bridge: BRIDGE_NAME
+ },
+ }, 'Screensharing reconnect failed');
+ });
+ }
+
+ handleConnectionTimeoutExpiry() {
+ this.reconnecting = true;
+
+ switch (this.role) {
+ case RECV_ROLE:
+ return this.inboundStreamReconnect();
+
+ // Sender/presenter reconnect is *not* implemented yet
+ case SEND_ROLE:
+ default:
+ this.reconnecting = false;
+ logger.error({
+ logCode: 'screenshare_wont_reconnect',
+ extraInfo: {
+ role: this.broker?.role || this.role,
+ started: !!(this.broker?.started),
+ bridge: BRIDGE_NAME,
+ },
+ }, 'Screen sharing will not reconnect');
+ break;
+ }
+ }
+
+ maxConnectionAttemptsReached () {
+ return this.connectionAttempts > BridgeService.MAX_CONN_ATTEMPTS;
+ }
+
+ scheduleReconnect({
+ overrideTimeout,
+ } = { }) {
+ if (this.reconnectionTimeout == null) {
+ let nextRestartInterval = this.restartIntervalMs;
+ if (typeof overrideTimeout === 'number') nextRestartInterval = overrideTimeout;
+
+ this.reconnectionTimeout = setTimeout(
+ this.handleConnectionTimeoutExpiry.bind(this),
+ nextRestartInterval,
+ );
+ }
+ }
+
+ clearReconnectionTimeout () {
+ this.reconnecting = false;
+ this.restartIntervalMs = BridgeService.BASE_MEDIA_TIMEOUT;
+
+ if (this.reconnectionTimeout) {
+ clearTimeout(this.reconnectionTimeout);
+ this.reconnectionTimeout = null;
+ }
+ }
+
+ setVolume(volume) {
+ const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
+
+ if (mediaElement) {
+ if (typeof volume === 'number' && volume >= 0 && volume <= 1) {
+ mediaElement.volume = volume;
+ }
+
+ return mediaElement.volume;
+ }
+
+ return DEFAULT_VOLUME;
+ }
+
+ getVolume() {
+ const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
+
+ if (mediaElement) return mediaElement.volume;
+
+ return DEFAULT_VOLUME;
+ }
+
+ handleViewerStart() {
+ const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
+
+ if (mediaElement && this.broker && this.broker.webRtcPeer) {
+ const stream = this.broker.webRtcPeer.getRemoteStream();
+
+ if (this.hasAudio && this.outputDeviceId && typeof this.outputDeviceId === 'string') {
+ setOutputDeviceId(this.outputDeviceId);
+ }
+
+ BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, !this.broker.hasAudio);
+ }
+
+ this.startedOnce = true;
+ this.clearReconnectionTimeout();
+ this.connectionAttempts = 0;
+ }
+
+ handleBrokerFailure(error) {
+ mapErrorCode(error);
+ const { errorMessage, errorCode } = error;
+
+ logger.error({
+ logCode: 'screenshare_broker_failure',
+ extraInfo: {
+ errorCode,
+ errorMessage,
+ role: this.broker.role,
+ started: this.broker.started,
+ reconnecting: this.reconnecting,
+ bridge: BRIDGE_NAME,
+ },
+ }, `Screenshare broker failure: ${errorMessage}`);
+
+ notifyStreamStateChange('screenshare', 'failed');
+ // Screensharing was already successfully negotiated and error occurred during
+ // during call; schedule a reconnect
+ if (this._shouldReconnect()) {
+ // this.broker.started => whether the reconnect should happen immediately.
+ // If this session previously established connection (N-sessions back)
+ // and it failed abruptly, then the timeout is overridden to a intermediate value
+ // (BASE_RECONNECTION_TIMEOUT)
+ let overrideTimeout;
+ if (this.broker?.started) {
+ overrideTimeout = 0;
+ } else if (this.startedOnce) {
+ overrideTimeout = BridgeService.BASE_RECONNECTION_TIMEOUT;
+ }
+
+ this.scheduleReconnect({ overrideTimeout });
+ }
+
+ return error;
+ }
+
+ async view(options = {
+ hasAudio: false,
+ outputDeviceId: null,
+ }) {
+ this.hasAudio = options.hasAudio;
+ this.outputDeviceId = options.outputDeviceId;
+ this.role = RECV_ROLE;
+ const iceServers = await BridgeService.getIceServers(Auth.sessionToken);
+ const brokerOptions = {
+ iceServers,
+ userName: Auth.fullname,
+ hasAudio: options.hasAudio,
+ offering: OFFERING,
+ mediaServer: BridgeService.getMediaServerAdapter(),
+ signalCandidates: SIGNAL_CANDIDATES,
+ forceRelay: shouldForceRelay(),
+ traceLogs: TRACE_LOGS,
+ gatheringTimeout: GATHERING_TIMEOUT,
+ };
+
+ this.broker = new ScreenshareBroker(
+ Auth.authenticateURL(SFU_URL),
+ BridgeService.getConferenceBridge(),
+ Auth.userID,
+ Auth.meetingID,
+ this.role,
+ brokerOptions,
+ );
+
+ this.broker.onstart = this.handleViewerStart.bind(this);
+ this.broker.onerror = this.handleBrokerFailure.bind(this);
+ if (!this.reconnecting) {
+ this.broker.onended = this.handleEnded.bind(this);
+ }
+ return this.broker.view().finally(this.scheduleReconnect.bind(this));
+ }
+
+ handlePresenterStart() {
+ logger.info({
+ logCode: 'screenshare_presenter_start_success',
+ }, 'Screenshare presenter started succesfully');
+ this.clearReconnectionTimeout();
+ this.startedOnce = true;
+ this.reconnecting = false;
+ this.connectionAttempts = 0;
+ }
+
+ handleEnded() {
+ screenShareEndAlert();
+ }
+
+ share(stream, onFailure, contentType) {
+ return new Promise(async (resolve, reject) => {
+ this.onerror = onFailure;
+ this.connectionAttempts += 1;
+ this.role = SEND_ROLE;
+ this.hasAudio = BridgeService.streamHasAudioTrack(stream);
+ this.gdmStream = stream;
+
+ const onerror = (error) => {
+ const normalizedError = this.handleBrokerFailure(error);
+ if (!this.broker.started) {
+ // Broker hasn't started - if there are retries left, try again.
+ if (this.maxConnectionAttemptsReached()) {
+ this.clearReconnectionTimeout();
+ this.connectionAttempts = 0;
+ onFailure(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
+ reject(SCREENSHARING_ERRORS.MEDIA_TIMEOUT);
+ }
+ } else if (!this._shouldReconnect()) {
+ // Broker has started - should reconnect? If it shouldn't, end it.
+ onFailure(normalizedError);
+ }
+ };
+
+ const iceServers = await BridgeService.getIceServers(Auth.sessionToken);
+ const options = {
+ iceServers,
+ userName: Auth.fullname,
+ stream,
+ hasAudio: this.hasAudio,
+ contentType: contentType,
+ bitrate: BridgeService.BASE_BITRATE,
+ offering: true,
+ mediaServer: BridgeService.getMediaServerAdapter(),
+ signalCandidates: SIGNAL_CANDIDATES,
+ forceRelay: shouldForceRelay(),
+ traceLogs: TRACE_LOGS,
+ networkPriority: NETWORK_PRIORITY,
+ gatheringTimeout: GATHERING_TIMEOUT,
+ };
+
+ this.broker = new ScreenshareBroker(
+ Auth.authenticateURL(SFU_URL),
+ BridgeService.getConferenceBridge(),
+ Auth.userID,
+ Auth.meetingID,
+ this.role,
+ options,
+ );
+
+ this.broker.onerror = onerror.bind(this);
+ this.broker.onstreamended = this.stop.bind(this);
+ this.broker.onstart = this.handlePresenterStart.bind(this);
+ this.broker.onended = this.handleEnded.bind(this);
+
+ this.broker.share().then(() => {
+ this.scheduleReconnect();
+ return resolve();
+ }).catch((error) => reject(mapErrorCode(error)));
+ });
+ }
+
+ // This is a reconnect-safe internal method. Should be used when one wants
+ // to clear the internal components (ie broker, connection timeouts) without
+ // affecting externally controlled components (ie gDM stream,
+ // media tag, connectionAttempts, ...)
+ _stop() {
+ if (this.broker) {
+ this.broker.stop();
+ // Checks if this session is a sharer and if it's not reconnecting
+ // If that's the case, clear the local sharing state in screen sharing UI
+ // component tracker to be extra sure we won't have any client-side state
+ // inconsistency - prlanzarin
+ if (this.broker && this.broker.role === SEND_ROLE && !this.reconnecting) {
+ setIsSharing(false);
+ }
+ this.broker = null;
+ }
+
+ this.clearReconnectionTimeout();
+ }
+
+ stop() {
+ const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
+
+ this._stop();
+ this.connectionAttempts = 0;
+
+ if (mediaElement && typeof mediaElement.pause === 'function') {
+ mediaElement.pause();
+ mediaElement.srcObject = null;
+ }
+
+ if (this.gdmStream) {
+ MediaStreamUtils.stopMediaStreamTracks(this.gdmStream);
+ this.gdmStream = null;
+ }
+
+ this.outputDeviceId = null;
+ }
+}
diff --git a/src/2.7.12/imports/api/screenshare/client/bridge/service.js b/src/2.7.12/imports/api/screenshare/client/bridge/service.js
new file mode 100644
index 00000000..9216eb6a
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/client/bridge/service.js
@@ -0,0 +1,164 @@
+import Meetings from '/imports/api/meetings';
+import logger from '/imports/startup/client/logger';
+import { fetchWebRTCMappedStunTurnServers, getMappedFallbackStun } from '/imports/utils/fetchStunTurnServers';
+import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play';
+import { SCREENSHARING_ERRORS } from './errors';
+import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
+
+const {
+ constraints: GDM_CONSTRAINTS,
+ mediaTimeouts: MEDIA_TIMEOUTS,
+ bitrate: BASE_BITRATE,
+ mediaServer: DEFAULT_SCREENSHARE_MEDIA_SERVER,
+} = Meteor.settings.public.kurento.screenshare;
+const {
+ baseTimeout: BASE_MEDIA_TIMEOUT,
+ maxTimeout: MAX_MEDIA_TIMEOUT,
+ maxConnectionAttempts: MAX_CONN_ATTEMPTS,
+ timeoutIncreaseFactor: TIMEOUT_INCREASE_FACTOR,
+ baseReconnectionTimeout: BASE_RECONNECTION_TIMEOUT,
+} = MEDIA_TIMEOUTS;
+
+const HAS_DISPLAY_MEDIA = (typeof navigator.getDisplayMedia === 'function'
+ || (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function'));
+
+const getConferenceBridge = () => Meetings.findOne().voiceProp.voiceConf;
+
+const normalizeGetDisplayMediaError = (error) => {
+ return SCREENSHARING_ERRORS[error.name] || SCREENSHARING_ERRORS.GetDisplayMediaGenericError;
+};
+
+const getBoundGDM = () => {
+ if (typeof navigator.getDisplayMedia === 'function') {
+ return navigator.getDisplayMedia.bind(navigator);
+ } else if (navigator.mediaDevices && typeof navigator.mediaDevices.getDisplayMedia === 'function') {
+ return navigator.mediaDevices.getDisplayMedia.bind(navigator.mediaDevices);
+ }
+}
+
+const getScreenStream = async () => {
+ const gDMCallback = (stream) => {
+ // Some older Chromium variants choke on gDM when audio: true by NOT generating
+ // a promise rejection AND not generating a valid input screen stream, need to
+ // work around that manually for now - prlanzarin
+ if (stream == null) {
+ return Promise.reject(SCREENSHARING_ERRORS.NotSupportedError);
+ }
+
+ if (typeof stream.getVideoTracks === 'function'
+ && typeof GDM_CONSTRAINTS.video === 'object') {
+ stream.getVideoTracks().forEach(track => {
+ if (typeof track.applyConstraints === 'function') {
+ track.applyConstraints(GDM_CONSTRAINTS.video).catch(error => {
+ logger.warn({
+ logCode: 'screenshare_videoconstraint_failed',
+ extraInfo: { errorName: error.name, errorCode: error.code },
+ },
+ 'Error applying screenshare video constraint');
+ });
+ }
+ });
+ }
+
+ if (typeof stream.getAudioTracks === 'function'
+ && typeof GDM_CONSTRAINTS.audio === 'object') {
+ stream.getAudioTracks().forEach(track => {
+ if (typeof track.applyConstraints === 'function') {
+ track.applyConstraints(GDM_CONSTRAINTS.audio).catch(error => {
+ logger.warn({
+ logCode: 'screenshare_audioconstraint_failed',
+ extraInfo: { errorName: error.name, errorCode: error.code },
+ }, 'Error applying screenshare audio constraint');
+ });
+ }
+ });
+ }
+
+ return Promise.resolve(stream);
+ };
+
+ const getDisplayMedia = getBoundGDM();
+
+ if (typeof getDisplayMedia === 'function') {
+ return getDisplayMedia(GDM_CONSTRAINTS)
+ .then(gDMCallback)
+ .catch(error => {
+ const normalizedError = normalizeGetDisplayMediaError(error);
+ logger.error({
+ logCode: 'screenshare_getdisplaymedia_failed',
+ extraInfo: { errorCode: normalizedError.errorCode, errorMessage: normalizedError.errorMessage },
+ }, 'getDisplayMedia call failed');
+ return Promise.reject(normalizedError);
+ });
+ } else {
+ // getDisplayMedia isn't supported, error its way out
+ return Promise.reject(SCREENSHARING_ERRORS.NotSupportedError);
+ }
+};
+
+const getIceServers = (sessionToken) => {
+ return fetchWebRTCMappedStunTurnServers(sessionToken).catch(error => {
+ logger.error({
+ logCode: 'screenshare_fetchstunturninfo_error',
+ extraInfo: { error }
+ }, 'Screenshare bridge failed to fetch STUN/TURN info');
+ return getMappedFallbackStun();
+ });
+}
+
+const getMediaServerAdapter = () => {
+ return getFromMeetingSettings('media-server-screenshare', DEFAULT_SCREENSHARE_MEDIA_SERVER);
+}
+
+const getNextReconnectionInterval = (oldInterval) => {
+ return Math.min(
+ (TIMEOUT_INCREASE_FACTOR * Math.max(oldInterval, BASE_RECONNECTION_TIMEOUT)),
+ MAX_MEDIA_TIMEOUT,
+ );
+}
+
+const streamHasAudioTrack = (stream) => {
+ return stream
+ && typeof stream.getAudioTracks === 'function'
+ && stream.getAudioTracks().length >= 1;
+}
+
+const dispatchAutoplayHandlingEvent = (mediaElement) => {
+ const tagFailedEvent = new CustomEvent('screensharePlayFailed',
+ { detail: { mediaElement } });
+ window.dispatchEvent(tagFailedEvent);
+}
+
+const screenshareLoadAndPlayMediaStream = (stream, mediaElement, muted) => {
+ return loadAndPlayMediaStream(stream, mediaElement, muted).catch(error => {
+ // NotAllowedError equals autoplay issues, fire autoplay handling event.
+ // This will be handled in the screenshare react component.
+ if (error.name === 'NotAllowedError') {
+ logger.error({
+ logCode: 'screenshare_error_autoplay',
+ extraInfo: { errorName: error.name },
+ }, 'Screen share media play failed: autoplay error');
+ dispatchAutoplayHandlingEvent(mediaElement);
+ } else {
+ throw {
+ errorCode: SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorCode,
+ errorMessage: error.message || SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorMessage,
+ };
+ }
+ });
+}
+
+export default {
+ HAS_DISPLAY_MEDIA,
+ getConferenceBridge,
+ getScreenStream,
+ getIceServers,
+ getNextReconnectionInterval,
+ streamHasAudioTrack,
+ screenshareLoadAndPlayMediaStream,
+ getMediaServerAdapter,
+ BASE_MEDIA_TIMEOUT,
+ BASE_RECONNECTION_TIMEOUT,
+ MAX_CONN_ATTEMPTS,
+ BASE_BITRATE,
+};
diff --git a/src/2.7.12/imports/api/screenshare/index.js b/src/2.7.12/imports/api/screenshare/index.js
new file mode 100644
index 00000000..fc380b23
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/index.js
@@ -0,0 +1,16 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Screenshare = new Mongo.Collection('screenshare', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the screenshare:
+ // 1. meetingId
+
+ Screenshare.createIndexAsync({ meetingId: 1 });
+}
+
+export default Screenshare;
diff --git a/src/2.7.12/imports/api/screenshare/server/eventHandlers.js b/src/2.7.12/imports/api/screenshare/server/eventHandlers.js
new file mode 100644
index 00000000..ee75acab
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/eventHandlers.js
@@ -0,0 +1,8 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleScreenshareStarted from './handlers/screenshareStarted';
+import handleScreenshareStopped from './handlers/screenshareStopped';
+import handleScreenshareSync from './handlers/screenshareSync';
+
+RedisPubSub.on('ScreenshareRtmpBroadcastStartedEvtMsg', handleScreenshareStarted);
+RedisPubSub.on('ScreenshareRtmpBroadcastStoppedEvtMsg', handleScreenshareStopped);
+RedisPubSub.on('SyncGetScreenshareInfoRespMsg', handleScreenshareSync);
diff --git a/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js
new file mode 100644
index 00000000..44a4f931
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStarted.js
@@ -0,0 +1,9 @@
+import { check } from 'meteor/check';
+import addScreenshare from '../modifiers/addScreenshare';
+
+export default async function handleScreenshareStarted({ body }, meetingId) {
+ check(meetingId, String);
+ check(body, Object);
+ const result = await addScreenshare(meetingId, body);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js
new file mode 100644
index 00000000..c2d262c9
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareStopped.js
@@ -0,0 +1,11 @@
+import { check } from 'meteor/check';
+import clearScreenshare from '../modifiers/clearScreenshare';
+
+export default async function handleScreenshareStopped({ body }, meetingId) {
+ const { screenshareConf } = body;
+
+ check(meetingId, String);
+ check(screenshareConf, String);
+ const result = await clearScreenshare(meetingId, screenshareConf);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js
new file mode 100644
index 00000000..0b55b85d
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/handlers/screenshareSync.js
@@ -0,0 +1,20 @@
+import { check } from 'meteor/check';
+import addScreenshare from '../modifiers/addScreenshare';
+import clearScreenshare from '../modifiers/clearScreenshare';
+
+export default async function handleScreenshareSync({ body }, meetingId) {
+ check(meetingId, String);
+ check(body, Object);
+
+ const { isBroadcasting, screenshareConf } = body;
+
+ check(screenshareConf, String);
+ check(isBroadcasting, Boolean);
+
+ if (!isBroadcasting) {
+ const result = await clearScreenshare(meetingId, screenshareConf);
+ return result;
+ }
+ const result = await addScreenshare(meetingId, body);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/screenshare/server/index.js b/src/2.7.12/imports/api/screenshare/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/screenshare/server/methods.js b/src/2.7.12/imports/api/screenshare/server/methods.js
new file mode 100644
index 00000000..1ce65c36
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/methods.js
@@ -0,0 +1,4 @@
+import { Meteor } from 'meteor/meteor';
+
+Meteor.methods({
+});
diff --git a/src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js b/src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js
new file mode 100644
index 00000000..ba41cdff
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/modifiers/addScreenshare.js
@@ -0,0 +1,29 @@
+import { check } from 'meteor/check';
+import flat from 'flat';
+import Logger from '/imports/startup/server/logger';
+import Screenshare from '/imports/api/screenshare';
+
+export default async function addScreenshare(meetingId, body) {
+ check(meetingId, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ meetingId,
+ screenshare: flat(body),
+ },
+ };
+
+ try {
+ const { numberAffected } = await Screenshare.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Upserted screenshare id=${body.screenshareConf}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding screenshare to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js b/src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js
new file mode 100644
index 00000000..c0cba5d4
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/modifiers/clearScreenshare.js
@@ -0,0 +1,22 @@
+import Logger from '/imports/startup/server/logger';
+import Screenshare from '/imports/api/screenshare';
+
+export default async function clearScreenshare(meetingId, screenshareConf) {
+ try {
+ let numberAffected;
+
+ if (meetingId && screenshareConf) {
+ numberAffected = await Screenshare.removeAsync({ meetingId, 'screenshare.screenshareConf': screenshareConf });
+ } else if (meetingId) {
+ numberAffected = await Screenshare.removeAsync({ meetingId });
+ } else {
+ numberAffected = await Screenshare.removeAsync({});
+ }
+
+ if (numberAffected) {
+ Logger.info(`removed screenshare meetingId=${meetingId} id=${screenshareConf}`);
+ }
+ } catch (err) {
+ Logger.error(`removing screenshare to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/screenshare/server/publishers.js b/src/2.7.12/imports/api/screenshare/server/publishers.js
new file mode 100644
index 00000000..a073c9ee
--- /dev/null
+++ b/src/2.7.12/imports/api/screenshare/server/publishers.js
@@ -0,0 +1,27 @@
+import Screenshare from '/imports/api/screenshare';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function screenshare() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Screenshare was requested by unauth connection ${this.connection.id}`);
+ return Screenshare.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing Screenshare', { meetingId, userId });
+
+ return Screenshare.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundScreenshare = screenshare.bind(this);
+ return boundScreenshare(...args);
+}
+
+Meteor.publish('screenshare', publish);
diff --git a/src/2.7.12/imports/api/slides/index.js b/src/2.7.12/imports/api/slides/index.js
new file mode 100644
index 00000000..e1acb162
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/index.js
@@ -0,0 +1,29 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Slides = new Mongo.Collection('slides', collectionOptions);
+const SlidePositions = new Mongo.Collection('slide-positions', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the slides:
+
+ // 1. meetingId ( 1 )
+ // 2. meetingId, podId ( 1 )
+ // 3. meetingId, presentationId ( 1 )
+ // 3. meetingId, presentationId, num ( 1 )
+ // 4. meetingId, podId, presentationId, id ( 3 ) - incl. resizeSlide, which can be intense
+ // 5. meetingId, podId, presentationId, current ( 1 )
+
+ Slides.createIndexAsync({
+ meetingId: 1, podId: 1, presentationId: 1, id: 1,
+ });
+
+ SlidePositions.createIndexAsync({
+ meetingId: 1, podId: 1, presentationId: 1, id: 1,
+ });
+}
+
+export { Slides, SlidePositions };
diff --git a/src/2.7.12/imports/api/slides/server/eventHandlers.js b/src/2.7.12/imports/api/slides/server/eventHandlers.js
new file mode 100644
index 00000000..b039b82f
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/eventHandlers.js
@@ -0,0 +1,6 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleSlideResize from './handlers/slideResize';
+import handleSlideChange from './handlers/slideChange';
+
+RedisPubSub.on('ResizeAndMovePageEvtMsg', handleSlideResize);
+RedisPubSub.on('SetCurrentPageEvtMsg', handleSlideChange);
diff --git a/src/2.7.12/imports/api/slides/server/handlers/slideChange.js b/src/2.7.12/imports/api/slides/server/handlers/slideChange.js
new file mode 100644
index 00000000..9bff7998
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/handlers/slideChange.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import changeCurrentSlide from '../modifiers/changeCurrentSlide';
+
+export default async function handleSlideChange({ body }, meetingId) {
+ const { pageId, presentationId, podId } = body;
+
+ check(pageId, String);
+ check(presentationId, String);
+ check(podId, String);
+
+ const result = await changeCurrentSlide(meetingId, podId, presentationId, pageId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/slides/server/handlers/slideResize.js b/src/2.7.12/imports/api/slides/server/handlers/slideResize.js
new file mode 100644
index 00000000..2cb3d72c
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/handlers/slideResize.js
@@ -0,0 +1,10 @@
+import { check } from 'meteor/check';
+import resizeSlide from '../modifiers/resizeSlide';
+
+export default async function handleSlideResize({ body }, meetingId) {
+ check(meetingId, String);
+ check(body, Object);
+
+ const result = await resizeSlide(meetingId, body);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/slides/server/helpers.js b/src/2.7.12/imports/api/slides/server/helpers.js
new file mode 100644
index 00000000..bf1ae4c1
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/helpers.js
@@ -0,0 +1,27 @@
+
+const calculateSlideData = (slideData) => {
+ const {
+ width, height, xOffset, yOffset, widthRatio, heightRatio,
+ } = slideData;
+
+ // calculating viewBox and offsets for the current presentation
+ const maxImageWidth = 1440;
+ const maxImageHeight = 1080;
+
+ const ratio = Math.min(maxImageWidth / width, maxImageHeight / height);
+ const scaledWidth = width * ratio;
+ const scaledHeight = height * ratio;
+ const scaledViewBoxWidth = width * widthRatio / 100 * ratio;
+ const scaledViewBoxHeight = height * heightRatio / 100 * ratio;
+
+ return {
+ width: scaledWidth,
+ height: scaledHeight,
+ x: xOffset,
+ y: yOffset,
+ viewBoxWidth: scaledViewBoxWidth,
+ viewBoxHeight: scaledViewBoxHeight,
+ };
+};
+
+export default calculateSlideData;
diff --git a/src/2.7.12/imports/api/slides/server/index.js b/src/2.7.12/imports/api/slides/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/slides/server/methods.js b/src/2.7.12/imports/api/slides/server/methods.js
new file mode 100644
index 00000000..e5616579
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/methods.js
@@ -0,0 +1,8 @@
+import { Meteor } from 'meteor/meteor';
+import switchSlide from './methods/switchSlide';
+import zoomSlide from './methods/zoomSlide';
+
+Meteor.methods({
+ switchSlide,
+ zoomSlide,
+});
diff --git a/src/2.7.12/imports/api/slides/server/methods/switchSlide.js b/src/2.7.12/imports/api/slides/server/methods/switchSlide.js
new file mode 100644
index 00000000..0753b943
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/methods/switchSlide.js
@@ -0,0 +1,56 @@
+import Presentations from '/imports/api/presentations';
+import { Slides } from '/imports/api/slides';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function switchSlide(slideNumber, podId) {
+ // TODO-- send presentationId and SlideId
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetCurrentPagePubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(slideNumber, Number);
+ check(podId, String);
+
+ const selector = {
+ meetingId,
+ podId,
+ current: true,
+ };
+
+ const Presentation = await Presentations.findOneAsync(selector);
+
+ if (!Presentation) {
+ throw new Meteor.Error('presentation-not-found', 'You need a presentation to be able to switch slides');
+ }
+
+ const Slide = await Slides.findOneAsync({
+ meetingId,
+ podId,
+ presentationId: Presentation.id,
+ num: slideNumber,
+ });
+
+ if (!Slide) {
+ throw new Meteor.Error('slide-not-found', `Slide number ${slideNumber} not found in the current presentation`);
+ }
+
+ const payload = {
+ podId,
+ presentationId: Presentation.id,
+ pageId: Slide.id,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method switchSlide ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/methods/zoomSlide.js b/src/2.7.12/imports/api/slides/server/methods/zoomSlide.js
new file mode 100644
index 00000000..37313618
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/methods/zoomSlide.js
@@ -0,0 +1,65 @@
+import Presentations from '/imports/api/presentations';
+import { Slides } from '/imports/api/slides';
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default async function zoomSlide(slideNumber, podId, widthRatio, heightRatio, x, y) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ResizeAndMovePagePubMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(slideNumber, Number);
+ check(podId, String);
+ check(widthRatio, Number);
+ check(heightRatio, Number);
+ check(x, Number);
+ check(y, Number);
+
+ const selector = {
+ meetingId,
+ podId,
+ current: true,
+ };
+ const Presentation = await Presentations.findOneAsync(selector);
+
+ if (!Presentation) {
+ throw new Meteor.Error('presentation-not-found', 'You need a presentation to be able to switch slides');
+ }
+
+ let validSlideNum = slideNumber;
+ if (validSlideNum > Presentation?.pages?.length) validSlideNum = 1;
+
+ const Slide = await Slides.findOneAsync({
+ meetingId,
+ podId,
+ presentationId: Presentation.id,
+ num: validSlideNum,
+ });
+
+ if (!Slide) {
+ throw new Meteor.Error('slide-not-found', `Slide number ${validSlideNum} not found in the current presentation`);
+ }
+
+ const payload = {
+ podId,
+ presentationId: Presentation.id,
+ pageId: Slide.id,
+ xOffset: x,
+ yOffset: y,
+ widthRatio,
+ heightRatio,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method zoomSlide ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/modifiers/addSlide.js b/src/2.7.12/imports/api/slides/server/modifiers/addSlide.js
new file mode 100644
index 00000000..e785af53
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/modifiers/addSlide.js
@@ -0,0 +1,130 @@
+import probe from 'probe-image-size';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import flat from 'flat';
+import RedisPubSub from '/imports/startup/server/redis';
+import { Slides } from '/imports/api/slides';
+import Logger from '/imports/startup/server/logger';
+import { SVG, PNG } from '/imports/utils/mimeTypes';
+import calculateSlideData from '/imports/api/slides/server/helpers';
+import addSlidePositions from './addSlidePositions';
+
+const loadSlidesFromHttpAlways = Meteor.settings.private.app.loadSlidesFromHttpAlways || false;
+
+const requestWhiteboardHistory = async (meetingId, slideId) => {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'GetWhiteboardAnnotationsReqMsg';
+ const USER_ID = 'nodeJSapp';
+
+ const payload = {
+ whiteboardId: slideId,
+ };
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, payload);
+};
+
+const SUPPORTED_TYPES = [SVG, PNG];
+
+const fetchImageSizes = (imageUri) => probe(imageUri)
+ .then((result) => {
+ if (!SUPPORTED_TYPES.includes(result.mime)) {
+ throw new Meteor.Error('invalid-image-type', `received ${result.mime} expecting ${SUPPORTED_TYPES.join()}`);
+ }
+
+ return {
+ width: result.width,
+ height: result.height,
+ };
+ })
+ .catch((reason) => {
+ Logger.error(`Error parsing image size. ${reason}. uri=${imageUri}`);
+ return reason;
+ });
+
+export default async function addSlide(meetingId, podId, presentationId, slide) {
+ check(podId, String);
+ check(presentationId, String);
+
+ check(slide, {
+ id: String,
+ num: Number,
+ thumbUri: String,
+ txtUri: String,
+ svgUri: String,
+ current: Boolean,
+ xOffset: Number,
+ yOffset: Number,
+ widthRatio: Number,
+ heightRatio: Number,
+ content: String,
+ });
+
+ const {
+ id: slideId,
+ xOffset,
+ yOffset,
+ widthRatio,
+ heightRatio,
+ ...restSlide
+ } = slide;
+
+ const selector = {
+ meetingId,
+ podId,
+ presentationId,
+ id: slideId,
+ };
+
+ const imageUri = slide.svgUri || slide.pngUri;
+
+ const modifier = {
+ $set: Object.assign(
+ { meetingId },
+ { podId },
+ { presentationId },
+ { id: slideId },
+ { imageUri },
+ flat(restSlide),
+ { safe: true },
+ ),
+ };
+
+ const imageSizeUri = (loadSlidesFromHttpAlways ? imageUri.replace(/^https/i, 'http') : imageUri);
+
+ return fetchImageSizes(imageSizeUri)
+ .then(async ({ width, height }) => {
+ // there is a rare case when for a very long not-active meeting the presentation
+ // files just disappear and width/height can't be retrieved
+ if (width && height) {
+ // pre-calculating the width, height, and vieBox coordinates / dimensions
+ // to unload the client-side
+ const slideData = {
+ width,
+ height,
+ xOffset,
+ yOffset,
+ widthRatio,
+ heightRatio,
+ };
+ const slidePosition = calculateSlideData(slideData);
+
+ await addSlidePositions(meetingId, podId, presentationId, slideId, slidePosition);
+ }
+
+ try {
+ const { insertedId, numberAffected } = await Slides.upsertAsync(selector, modifier);
+
+ await requestWhiteboardHistory(meetingId, slideId);
+
+ if (insertedId) {
+ Logger.info(`Added slide id=${slideId} pod=${podId} presentation=${presentationId}`);
+ } else if (numberAffected) {
+ Logger.info(`Upserted slide id=${slideId} pod=${podId} presentation=${presentationId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error on adding slide to collection: ${err}`);
+ }
+ })
+ .catch((reason) => Logger.error(`Error parsing image size. ${reason}. slide=${slideId} uri=${imageUri}`));
+}
diff --git a/src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js b/src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js
new file mode 100644
index 00000000..250baaf3
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/modifiers/addSlidePositions.js
@@ -0,0 +1,56 @@
+import { SlidePositions } from '/imports/api/slides';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import flat from 'flat';
+
+export default async function addSlidePositions(
+ meetingId,
+ podId,
+ presentationId,
+ slideId,
+ slidePosition,
+) {
+ check(meetingId, String);
+ check(podId, String);
+ check(presentationId, String);
+ check(slideId, String);
+
+ check(slidePosition, {
+ width: Number,
+ height: Number,
+ x: Number,
+ y: Number,
+ viewBoxWidth: Number,
+ viewBoxHeight: Number,
+ });
+
+ const selector = {
+ meetingId,
+ podId,
+ presentationId,
+ id: slideId,
+ };
+
+ const modifier = {
+ $set: Object.assign(
+ { meetingId },
+ { podId },
+ { presentationId },
+ { id: slideId },
+ flat(slidePosition),
+ { safe: true },
+ ),
+ };
+
+ try {
+ const { insertedId } = await SlidePositions.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added slide position id=${slideId} pod=${podId} presentation=${presentationId}`);
+ } else {
+ Logger.info(`Upserted slide position id=${slideId} pod=${podId} presentation=${presentationId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding slide position to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js b/src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js
new file mode 100644
index 00000000..8776fd65
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/modifiers/changeCurrentSlide.js
@@ -0,0 +1,66 @@
+import { check } from 'meteor/check';
+import { Slides } from '/imports/api/slides';
+import Logger from '/imports/startup/server/logger';
+
+export default async function changeCurrentSlide(meetingId, podId, presentationId, slideId) {
+ check(meetingId, String);
+ check(presentationId, String);
+ check(slideId, String);
+ check(podId, String);
+
+ const oldCurrent = {
+ selector: {
+ meetingId,
+ podId,
+ presentationId,
+ current: true,
+ },
+ modifier: {
+ $set: { current: false },
+ },
+ callback: (err) => {
+ if (err) {
+ Logger.error(`Unsetting the current slide: ${err}`);
+ return;
+ }
+
+ Logger.info('Unsetted the current slide');
+ },
+ };
+
+ const newCurrent = {
+ selector: {
+ meetingId,
+ podId,
+ presentationId,
+ id: slideId,
+ },
+ modifier: {
+ $set: { current: true },
+ },
+ callback: (err) => {
+ if (err) {
+ Logger.error(`Setting as current slide id=${slideId}: ${err}`);
+ return;
+ }
+
+ Logger.info(`Setted as current slide id=${slideId}`);
+ },
+ };
+
+ const oldSlide = await Slides.findOneAsync(oldCurrent.selector);
+ const newSlide = await Slides.findOneAsync(newCurrent.selector);
+
+ // if the oldCurrent and newCurrent have the same ids
+ if (oldSlide && newSlide && (oldSlide._id === newSlide._id)) {
+ return;
+ }
+
+ if (newSlide) {
+ await Slides.updateAsync(newSlide._id, newCurrent.modifier, newCurrent.callback);
+ }
+
+ if (oldSlide) {
+ await Slides.updateAsync(oldSlide._id, oldCurrent.modifier, oldCurrent.callback);
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js b/src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js
new file mode 100644
index 00000000..744b474c
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/modifiers/clearSlides.js
@@ -0,0 +1,38 @@
+import { Slides, SlidePositions } from '/imports/api/slides';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearSlides(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffectedSlidePositions = await SlidePositions.removeAsync({ meetingId });
+
+ const numberAffected = await Slides.removeAsync({ meetingId });
+
+ if (numberAffectedSlidePositions) {
+ Logger.info(`Cleared SlidePositions (${meetingId})`);
+ }
+
+ if (numberAffected) {
+ Logger.info(`Cleared Slides (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on cleaning Slides (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffectedSlidePositions = await SlidePositions.removeAsync({ meetingId });
+
+ const numberAffected = await Slides.removeAsync({ meetingId });
+
+ if (numberAffectedSlidePositions) {
+ Logger.info(`Cleared SlidePositions (${meetingId})`);
+ }
+
+ if (numberAffected) {
+ Logger.info('Cleared Slides (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error on cleaning Slides (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js b/src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js
new file mode 100644
index 00000000..5f50ddcc
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/modifiers/clearSlidesPresentation.js
@@ -0,0 +1,30 @@
+import { Slides, SlidePositions } from '/imports/api/slides';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import clearAnnotations from '/imports/api/annotations/server/modifiers/clearAnnotations';
+
+export default async function clearSlidesPresentation(meetingId, presentationId) {
+ check(meetingId, String);
+ check(presentationId, String);
+
+ const selector = {
+ meetingId,
+ presentationId,
+ };
+
+ const whiteboardIds = await Slides.find(selector, { fields: { id: 1 } }).mapAsync(row => row.id);
+
+ try {
+ await SlidePositions.removeAsync(selector);
+
+ const numberAffected = await Slides.removeAsync(selector);
+
+ if (numberAffected) {
+ whiteboardIds.forEach(async (whiteboardId) => clearAnnotations(meetingId, whiteboardId));
+
+ Logger.info(`Removed Slides where presentationId=${presentationId}`);
+ }
+ } catch (err) {
+ Logger.error(`Removing Slides from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js b/src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js
new file mode 100644
index 00000000..3612a236
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/modifiers/resizeSlide.js
@@ -0,0 +1,63 @@
+import { check } from 'meteor/check';
+import { SlidePositions } from '/imports/api/slides';
+import Logger from '/imports/startup/server/logger';
+import calculateSlideData from '/imports/api/slides/server/helpers';
+
+export default async function resizeSlide(meetingId, slide) {
+ check(meetingId, String);
+
+ const {
+ podId,
+ presentationId,
+ pageId,
+ widthRatio,
+ heightRatio,
+ xOffset,
+ yOffset,
+ } = slide;
+
+ const selector = {
+ meetingId,
+ podId,
+ presentationId,
+ id: pageId,
+ };
+
+ // fetching the current slide data
+ // and pre-calculating the width, height, and vieBox coordinates / sizes
+ // to reduce the client-side load
+ const SlidePosition = await SlidePositions.findOneAsync(selector);
+
+ if (SlidePosition) {
+ const {
+ width,
+ height,
+ } = SlidePosition;
+
+ const slideData = {
+ width,
+ height,
+ xOffset,
+ yOffset,
+ widthRatio,
+ heightRatio,
+ };
+ const calculatedData = calculateSlideData(slideData);
+
+ const modifier = {
+ $set: calculatedData,
+ };
+
+ try {
+ const numberAffected = await SlidePositions.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug(`Resized slide positions id=${pageId}`);
+ } else {
+ Logger.info(`No slide positions found with id=${pageId}`);
+ }
+ } catch (err) {
+ Logger.error(`Resizing slide positions id=${pageId}: ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/slides/server/publishers.js b/src/2.7.12/imports/api/slides/server/publishers.js
new file mode 100644
index 00000000..767d6401
--- /dev/null
+++ b/src/2.7.12/imports/api/slides/server/publishers.js
@@ -0,0 +1,50 @@
+import { Slides, SlidePositions } from '/imports/api/slides';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function slides() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Slides was requested by unauth connection ${this.connection.id}`);
+ return Slides.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing Slides', { meetingId, userId });
+
+ return Slides.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundSlides = slides.bind(this);
+ return boundSlides(...args);
+}
+
+Meteor.publish('slides', publish);
+
+async function slidePositions() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing SlidePositions was requested by unauth connection ${this.connection.id}`);
+ return SlidePositions.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing SlidePositions', { meetingId, userId });
+
+ return SlidePositions.find({ meetingId });
+}
+
+function publishPositions(...args) {
+ const boundSlidePositions = slidePositions.bind(this);
+ return boundSlidePositions(...args);
+}
+
+Meteor.publish('slide-positions', publishPositions);
diff --git a/src/2.7.12/imports/api/timer/index.js b/src/2.7.12/imports/api/timer/index.js
new file mode 100644
index 00000000..c43d8e33
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/index.js
@@ -0,0 +1,9 @@
+import { Meteor } from 'meteor/meteor';
+
+const Timer = new Mongo.Collection('timer');
+
+if (Meteor.isServer) {
+ Timer.createIndex({ meetingId: 1 });
+}
+
+export default Timer;
diff --git a/src/2.7.12/imports/api/timer/server/eventHandlers.js b/src/2.7.12/imports/api/timer/server/eventHandlers.js
new file mode 100644
index 00000000..48d442ff
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/eventHandlers.js
@@ -0,0 +1,20 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleTimerActivated from './handlers/timerActivated';
+import handleTimerDeactivated from './handlers/timerDeactivated';
+import handleTimerStarted from './handlers/timerStarted';
+import handleTimerStopped from './handlers/timerStopped';
+import handleTimerSwitched from './handlers/timerSwitched';
+import handleTimerSet from './handlers/timerSet';
+import handleTimerReset from './handlers/timerReset';
+import handleTimerEnded from './handlers/timerEnded';
+import handleTrackSet from './handlers/trackSet';
+
+RedisPubSub.on('ActivateTimerRespMsg', handleTimerActivated);
+RedisPubSub.on('DeactivateTimerRespMsg', handleTimerDeactivated);
+RedisPubSub.on('StartTimerRespMsg', handleTimerStarted);
+RedisPubSub.on('StopTimerRespMsg', handleTimerStopped);
+RedisPubSub.on('SwitchTimerRespMsg', handleTimerSwitched);
+RedisPubSub.on('SetTimerRespMsg', handleTimerSet);
+RedisPubSub.on('ResetTimerRespMsg', handleTimerReset);
+RedisPubSub.on('TimerEndedEvtMsg', handleTimerEnded);
+RedisPubSub.on('SetTrackRespMsg', handleTrackSet);
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerActivated.js b/src/2.7.12/imports/api/timer/server/handlers/timerActivated.js
new file mode 100644
index 00000000..fdeaccba
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerActivated.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerActivated({ body }, meetingId) {
+ const { userId } = body;
+ check(meetingId, String);
+ check(userId, String);
+
+ updateTimer({
+ action: 'activate',
+ meetingId,
+ requesterUserId: userId,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js b/src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js
new file mode 100644
index 00000000..61a7ae45
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerDeactivated.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerDeactivated({ body }, meetingId) {
+ const { userId } = body;
+ check(meetingId, String);
+ check(userId, String);
+
+ updateTimer({
+ action: 'deactivate',
+ meetingId,
+ requesterUserId: userId,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerEnded.js b/src/2.7.12/imports/api/timer/server/handlers/timerEnded.js
new file mode 100644
index 00000000..410b3eb5
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerEnded.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerEnded({ body }, meetingId) {
+ check(meetingId, String);
+ check(body, Object);
+
+ updateTimer({
+ action: 'reset',
+ meetingId,
+ requesterUserId: 'nodeJSapp',
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerReset.js b/src/2.7.12/imports/api/timer/server/handlers/timerReset.js
new file mode 100644
index 00000000..f44b69f2
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerReset.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerReset({ body }, meetingId) {
+ const { userId } = body;
+ check(meetingId, String);
+ check(userId, String);
+
+ updateTimer({
+ action: 'reset',
+ meetingId,
+ requesterUserId: userId,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerSet.js b/src/2.7.12/imports/api/timer/server/handlers/timerSet.js
new file mode 100644
index 00000000..0ae32827
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerSet.js
@@ -0,0 +1,17 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerSet({ body }, meetingId) {
+ const { userId, time } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(time, Number);
+
+ updateTimer({
+ action: 'set',
+ meetingId,
+ requesterUserId: userId,
+ time,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerStarted.js b/src/2.7.12/imports/api/timer/server/handlers/timerStarted.js
new file mode 100644
index 00000000..2400f46a
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerStarted.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerStarted({ body }, meetingId) {
+ const { userId } = body;
+ check(meetingId, String);
+ check(userId, String);
+
+ updateTimer({
+ action: 'start',
+ meetingId,
+ requesterUserId: userId,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerStopped.js b/src/2.7.12/imports/api/timer/server/handlers/timerStopped.js
new file mode 100644
index 00000000..cb4023e5
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerStopped.js
@@ -0,0 +1,17 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerStopped({ body }, meetingId) {
+ const { userId, accumulated } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(accumulated, Number);
+
+ updateTimer({
+ action: 'stop',
+ meetingId,
+ requesterUserId: userId,
+ accumulated,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js b/src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js
new file mode 100644
index 00000000..38dc03d2
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/timerSwitched.js
@@ -0,0 +1,17 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTimerSwitched({ body }, meetingId) {
+ const { userId, stopwatch } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(stopwatch, Boolean);
+
+ updateTimer({
+ action: 'switch',
+ meetingId,
+ requesterUserId: userId,
+ stopwatch,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/handlers/trackSet.js b/src/2.7.12/imports/api/timer/server/handlers/trackSet.js
new file mode 100644
index 00000000..570cffb3
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/handlers/trackSet.js
@@ -0,0 +1,18 @@
+import { check } from 'meteor/check';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+
+export default function handleTrackSet({ body }, meetingId) {
+ const { userId, track } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(track, String);
+
+ updateTimer({
+ action: 'track',
+ meetingId,
+ requesterUserId: userId,
+ stopwatch: false,
+ track,
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/helpers.js b/src/2.7.12/imports/api/timer/server/helpers.js
new file mode 100644
index 00000000..88494d6a
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/helpers.js
@@ -0,0 +1,40 @@
+import { Meteor } from 'meteor/meteor';
+
+const TIMER_CONFIG = Meteor.settings.public.timer;
+
+const MILLI_IN_MINUTE = 60000;
+
+const TRACKS = [
+ 'noTrack',
+ 'track1',
+ 'track2',
+ 'track3',
+];
+
+const isEnabled = () => TIMER_CONFIG.enabled;
+
+const getDefaultTime = () => TIMER_CONFIG.time * MILLI_IN_MINUTE;
+
+const getInitialState = () => {
+ const time = getDefaultTime();
+ check(time, Number);
+
+ return {
+ stopwatch: true,
+ running: false,
+ time,
+ accumulated: 0,
+ timestamp: 0,
+ track: TRACKS[0],
+ };
+};
+
+const isTrackValid = (track) => TRACKS.includes(track);
+
+export {
+ TRACKS,
+ isEnabled,
+ getDefaultTime,
+ getInitialState,
+ isTrackValid,
+};
diff --git a/src/2.7.12/imports/api/timer/server/index.js b/src/2.7.12/imports/api/timer/server/index.js
new file mode 100644
index 00000000..f57b4620
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/index.js
@@ -0,0 +1,3 @@
+import './methods';
+import './eventHandlers';
+import './publishers';
diff --git a/src/2.7.12/imports/api/timer/server/methods.js b/src/2.7.12/imports/api/timer/server/methods.js
new file mode 100644
index 00000000..85be11fa
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods.js
@@ -0,0 +1,24 @@
+import { Meteor } from 'meteor/meteor';
+import activateTimer from './methods/activateTimer';
+import deactivateTimer from './methods/deactivateTimer';
+import resetTimer from './methods/resetTimer';
+import startTimer from './methods/startTimer';
+import stopTimer from './methods/stopTimer';
+import switchTimer from './methods/switchTimer';
+import setTimer from './methods/setTimer';
+import getServerTime from './methods/getServerTime';
+import setTrack from './methods/setTrack';
+import timerEnded from './methods/endTimer';
+
+Meteor.methods({
+ activateTimer,
+ deactivateTimer,
+ resetTimer,
+ startTimer,
+ stopTimer,
+ switchTimer,
+ setTimer,
+ getServerTime,
+ setTrack,
+ timerEnded,
+});
diff --git a/src/2.7.12/imports/api/timer/server/methods/activateTimer.js b/src/2.7.12/imports/api/timer/server/methods/activateTimer.js
new file mode 100644
index 00000000..41cb168c
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/activateTimer.js
@@ -0,0 +1,23 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { getInitialState } from '../helpers';
+
+export default function activateTimer() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ActivateTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = getInitialState();
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Activating timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/createTimer.js b/src/2.7.12/imports/api/timer/server/methods/createTimer.js
new file mode 100644
index 00000000..262aedcf
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/createTimer.js
@@ -0,0 +1,29 @@
+import { check } from 'meteor/check';
+import { getInitialState, isEnabled } from '/imports/api/timer/server/helpers';
+import addTimer from '/imports/api/timer/server/modifiers/addTimer';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+
+// This method should only be used by the server
+export default function createTimer(meetingId) {
+ check(meetingId, String);
+
+ // Avoid timer creation if this feature is disabled
+ if (!isEnabled()) {
+ Logger.warn(`Timers are disabled for ${meetingId}`);
+ return;
+ }
+
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'CreateTimerPubMsg';
+
+ try {
+ addTimer(meetingId);
+ const payload = getInitialState();
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, 'nodeJsApp', payload);
+ } catch (err) {
+ Logger.error(`Activating timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js b/src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js
new file mode 100644
index 00000000..5cbd4a33
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/deactivateTimer.js
@@ -0,0 +1,20 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function deactivateTimer() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'DeactivateTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, {});
+ } catch (err) {
+ Logger.error(`Deactivating timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/endTimer.js b/src/2.7.12/imports/api/timer/server/methods/endTimer.js
new file mode 100644
index 00000000..835f2425
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/endTimer.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import updateTimer from '/imports/api/timer/server/modifiers/updateTimer';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function timerEnded() {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ updateTimer({
+ action: 'ended',
+ meetingId,
+ requesterUserId,
+ });
+}
+
+// This method should only be used by the server
+export function sysEndTimer(meetingId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'TimerEndedPubMsg';
+ const USER_ID = 'nodeJSapp';
+
+ try {
+ check(meetingId, String);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, {});
+ } catch (err) {
+ Logger.error(`Ending timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/getServerTime.js b/src/2.7.12/imports/api/timer/server/methods/getServerTime.js
new file mode 100644
index 00000000..6ff652f1
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/getServerTime.js
@@ -0,0 +1,5 @@
+export default function getServerTime() {
+ if (this.userId) return Date.now();
+
+ return 0;
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/resetTimer.js b/src/2.7.12/imports/api/timer/server/methods/resetTimer.js
new file mode 100644
index 00000000..4d534f2c
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/resetTimer.js
@@ -0,0 +1,20 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function resetTimer() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ResetTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, {});
+ } catch (err) {
+ Logger.error(`Resetting timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/setTimer.js b/src/2.7.12/imports/api/timer/server/methods/setTimer.js
new file mode 100644
index 00000000..b17e7574
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/setTimer.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function setTimer(time) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(time, Number);
+
+ const payload = {
+ time,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Setting timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/setTrack.js b/src/2.7.12/imports/api/timer/server/methods/setTrack.js
new file mode 100644
index 00000000..a63262db
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/setTrack.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { isTrackValid } from '/imports/api/timer/server/helpers';
+
+export default function setTrack(track) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetTrackReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(track, String);
+
+ if (isTrackValid(track)) {
+ const payload = {
+ track,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } else {
+ Logger.warn(`User=${requesterUserId} tried to set invalid track '${track}' in meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting track: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/startTimer.js b/src/2.7.12/imports/api/timer/server/methods/startTimer.js
new file mode 100644
index 00000000..cb372a30
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/startTimer.js
@@ -0,0 +1,20 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function startTimer() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StartTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, {});
+ } catch (err) {
+ Logger.error(`Starting timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/stopTimer.js b/src/2.7.12/imports/api/timer/server/methods/stopTimer.js
new file mode 100644
index 00000000..3b769f65
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/stopTimer.js
@@ -0,0 +1,92 @@
+import { check } from 'meteor/check';
+import Timer from '/imports/api/timer';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function stopTimer() {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StopTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const now = Date.now();
+ const timer = Timer.findOne(
+ { meetingId },
+ {
+ fields:
+ {
+ stopwatch: 1,
+ time: 1,
+ accumulated: 1,
+ timestamp: 1,
+ },
+ },
+ );
+
+ if (timer) {
+ const {
+ timestamp,
+ } = timer;
+
+ const accumulated = timer.accumulated + (now - timestamp);
+
+ const payload = {
+ accumulated,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } else {
+ Logger.warn(`Could not stop timer for meeting=${meetingId}, timer not found`);
+ }
+ } catch (err) {
+ Logger.error(`Stopping timer: ${err}`);
+ }
+}
+
+// This method should only be used by the server
+export function sysStopTimer(meetingId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StopTimerReqMsg';
+ const USER_ID = 'nodeJSapp';
+
+ try {
+ check(meetingId, String);
+ const now = Date.now();
+ const timer = Timer.findOne(
+ { meetingId },
+ {
+ fields:
+ {
+ stopwatch: 1,
+ time: 1,
+ accumulated: 1,
+ timestamp: 1,
+ },
+ },
+ );
+
+ if (timer) {
+ const {
+ timestamp,
+ } = timer;
+
+ const accumulated = timer.accumulated + (now - timestamp);
+
+ const payload = {
+ accumulated,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, payload);
+ } else {
+ Logger.warn(`Could not stop timer for meeting=${meetingId}, timer not found`);
+ }
+ } catch (err) {
+ Logger.error(`Stopping timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/methods/switchTimer.js b/src/2.7.12/imports/api/timer/server/methods/switchTimer.js
new file mode 100644
index 00000000..0dfbaa0d
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/methods/switchTimer.js
@@ -0,0 +1,25 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function switchTimer(stopwatch) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SwitchTimerReqMsg';
+
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(stopwatch, Boolean);
+
+ const payload = {
+ stopwatch,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Switching timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/modifiers/addTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/addTimer.js
new file mode 100644
index 00000000..2ab47072
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/modifiers/addTimer.js
@@ -0,0 +1,34 @@
+import { check } from 'meteor/check';
+import Timer from '/imports/api/timer';
+import Logger from '/imports/startup/server/logger';
+import { getInitialState } from '/imports/api/timer/server/helpers';
+
+// This method should only be used by the server
+export default function addTimer(meetingId) {
+ check(meetingId, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ meetingId,
+ ...getInitialState(),
+ active: false,
+ ended: 0,
+ };
+
+ const cb = (err, numChanged) => {
+ if (err) {
+ return Logger.error(`Adding timer to the collection: ${err}`);
+ }
+
+ if (numChanged) {
+ return Logger.debug(`Added timer meeting=${meetingId}`);
+ }
+
+ return Logger.debug(`Upserted timer meeting=${meetingId}`);
+ };
+
+ return Timer.upsert(selector, modifier, cb);
+}
diff --git a/src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js
new file mode 100644
index 00000000..49e595c1
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/modifiers/clearTimer.js
@@ -0,0 +1,14 @@
+import Timer from '/imports/api/timer';
+import Logger from '/imports/startup/server/logger';
+
+export default function clearTimer(meetingId) {
+ if (meetingId) {
+ return Timer.remove({ meetingId }, () => {
+ Logger.info(`Cleared Timer (${meetingId})`);
+ });
+ }
+
+ return Timer.remove({}, () => {
+ Logger.info('Cleared Timer (all)');
+ });
+}
diff --git a/src/2.7.12/imports/api/timer/server/modifiers/endTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/endTimer.js
new file mode 100644
index 00000000..bcedfdd1
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/modifiers/endTimer.js
@@ -0,0 +1,18 @@
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+
+export default function endTimer(meetingId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'TimerEndedPubMsg';
+ const USER_ID = 'nodeJSapp';
+
+ try {
+ check(meetingId, String);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, {});
+ } catch (err) {
+ Logger.error(`Ending timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js
new file mode 100644
index 00000000..1ed53990
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/modifiers/stopTimer.js
@@ -0,0 +1,46 @@
+import { check } from 'meteor/check';
+import Timer from '/imports/api/timer';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+
+export default function stopTimer(meetingId) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'StopTimerReqMsg';
+ const USER_ID = 'nodeJSapp';
+
+ try {
+ check(meetingId, String);
+ const now = Date.now();
+ const timer = Timer.findOne(
+ { meetingId },
+ {
+ fields:
+ {
+ stopwatch: 1,
+ time: 1,
+ accumulated: 1,
+ timestamp: 1,
+ },
+ },
+ );
+
+ if (timer) {
+ const {
+ timestamp,
+ } = timer;
+
+ const accumulated = timer.accumulated + (now - timestamp);
+
+ const payload = {
+ accumulated,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, USER_ID, payload);
+ } else {
+ Logger.warn(`Could not stop timer for meeting=${meetingId}, timer not found`);
+ }
+ } catch (err) {
+ Logger.error(`Stopping timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js b/src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js
new file mode 100644
index 00000000..1bf3b1f2
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/modifiers/updateTimer.js
@@ -0,0 +1,188 @@
+import { check } from 'meteor/check';
+import Timer from '/imports/api/timer';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import { TRACKS, getInitialState } from '/imports/api/timer/server/helpers';
+import { sysStopTimer } from '../methods/stopTimer';
+import { sysEndTimer } from '../methods/endTimer';
+
+const getActivateModifier = () => ({
+ $set: {
+ active: true,
+ ...getInitialState(),
+ ended: 0,
+ },
+});
+
+const getDeactivateModifier = () => ({
+ $set: {
+ active: false,
+ running: false,
+ ended: 0,
+ },
+});
+
+const getResetModifier = () => ({
+ $set: {
+ accumulated: 0,
+ timestamp: Date.now(),
+ ended: 0,
+ },
+});
+
+const handleTimerEndedNotifications = (fields, meetingId, handle) => {
+ const meetingUsers = Users.find({ meetingId }).count();
+
+ if (fields.running === false) {
+ handle.stop();
+ }
+
+ if (fields.ended >= meetingUsers) {
+ sysStopTimer(meetingId);
+ sysEndTimer(meetingId);
+ }
+};
+
+const setTimerEndObserver = (meetingId) => {
+ const { stopwatch } = Timer.findOne({ meetingId });
+
+ if (stopwatch === false) {
+ const meetingTimer = Timer.find(
+ { meetingId },
+ { fields: { ended: 1, running: 1 } },
+ );
+ const handle = meetingTimer.observeChanges({
+ changed: (id, fields) => {
+ handleTimerEndedNotifications(fields, meetingId, handle);
+ },
+ });
+ }
+};
+
+const getStartModifier = () => ({
+ $set: {
+ running: true,
+ timestamp: Date.now(),
+ ended: 0,
+ },
+});
+
+const getStopModifier = (accumulated) => ({
+ $set: {
+ running: false,
+ accumulated,
+ timestamp: 0,
+ ended: 0,
+ },
+});
+
+const getSwitchModifier = (stopwatch) => ({
+ $set: {
+ stopwatch,
+ running: false,
+ accumulated: 0,
+ timestamp: 0,
+ track: TRACKS[0],
+ ended: 0,
+ },
+});
+
+const getSetModifier = (time) => ({
+ $set: {
+ running: false,
+ accumulated: 0,
+ timestamp: 0,
+ time,
+ },
+});
+
+const getTrackModifier = (track) => ({
+ $set: {
+ track,
+ },
+});
+
+const getEndedModifier = () => ({
+ $inc: {
+ ended: 1,
+ },
+});
+
+const logTimer = (meetingId, requesterUserId, action, stopwatch, time, track) => {
+ if (action === 'switch') {
+ Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action} stopwatch=${stopwatch} `);
+ } else if (action === 'set' && time !== 0) {
+ Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action} ${time}ms`);
+ } else if (action === 'track') {
+ Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action} changed to ${track}`);
+ } else {
+ Logger.info(`Timer: meetingId=${meetingId} requesterUserId=${requesterUserId} action=${action}`);
+ }
+};
+
+export default function updateTimer({
+ action,
+ meetingId,
+ requesterUserId,
+ time = 0,
+ stopwatch = true,
+ accumulated = 0,
+ track = TRACKS[0],
+}) {
+ check(action, String);
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(time, Number);
+ check(stopwatch, Boolean);
+ check(accumulated, Number);
+ check(track, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ let modifier;
+
+ switch (action) {
+ case 'activate':
+ modifier = getActivateModifier();
+ break;
+ case 'deactivate':
+ modifier = getDeactivateModifier();
+ break;
+ case 'reset':
+ modifier = getResetModifier();
+ break;
+ case 'start':
+ setTimerEndObserver(meetingId);
+ modifier = getStartModifier();
+ break;
+ case 'stop':
+ modifier = getStopModifier(accumulated);
+ break;
+ case 'switch':
+ modifier = getSwitchModifier(stopwatch);
+ break;
+ case 'set':
+ modifier = getSetModifier(time);
+ break;
+ case 'track':
+ modifier = getTrackModifier(track);
+ break;
+ case 'ended':
+ modifier = getEndedModifier();
+ break;
+ default:
+ Logger.error(`Unhandled timer action=${action}`);
+ }
+
+ try {
+ const { numberAffected } = Timer.upsert(selector, modifier);
+
+ if (numberAffected) {
+ logTimer(meetingId, requesterUserId, action, stopwatch, time, track);
+ }
+ } catch (err) {
+ Logger.error(`Updating timer: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/timer/server/publishers.js b/src/2.7.12/imports/api/timer/server/publishers.js
new file mode 100644
index 00000000..c9535d41
--- /dev/null
+++ b/src/2.7.12/imports/api/timer/server/publishers.js
@@ -0,0 +1,22 @@
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import Timer from '/imports/api/timer';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+function timer() {
+ if (!this.userId) {
+ return Timer.find({ meetingId: '' });
+ }
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ Logger.info(`Publishing timer for ${meetingId} ${requesterUserId}`);
+
+ return Timer.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundTimer = timer.bind(this);
+ return boundTimer(...args);
+}
+
+Meteor.publish('timer', publish);
diff --git a/src/2.7.12/imports/api/user-reaction/index.js b/src/2.7.12/imports/api/user-reaction/index.js
new file mode 100644
index 00000000..eb5ffcbd
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/index.js
@@ -0,0 +1,14 @@
+import { Meteor } from 'meteor/meteor';
+
+const expireSeconds = Meteor.settings.public.userReaction.expire;
+const UserReaction = new Mongo.Collection('user-reaction');
+
+if (Meteor.isServer) {
+ // TTL indexes are special single-field indexes to automatically remove documents
+ // from a collection after a certain amount of time.
+ // A single-field with only a date is necessary to this special single-field index, because
+ // compound indexes do not support TTL.
+ UserReaction._ensureIndex({ creationDate: 1 }, { expireAfterSeconds: expireSeconds });
+}
+
+export default UserReaction;
diff --git a/src/2.7.12/imports/api/user-reaction/server/eventHandlers.js b/src/2.7.12/imports/api/user-reaction/server/eventHandlers.js
new file mode 100644
index 00000000..31437b0d
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/eventHandlers.js
@@ -0,0 +1,6 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleSetUserReaction from './handlers/setUserReaction';
+import handleClearUsersReaction from './handlers/clearUsersReaction';
+
+RedisPubSub.on('UserReactionEmojiChangedEvtMsg', handleSetUserReaction);
+RedisPubSub.on('ClearedAllUsersReactionEvtMsg', handleClearUsersReaction);
diff --git a/src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js b/src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js
new file mode 100644
index 00000000..b9113912
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/handlers/clearUsersReaction.js
@@ -0,0 +1,7 @@
+import { check } from 'meteor/check';
+import clearReactions from '../modifiers/clearReactions';
+
+export default function handleClearUsersReaction({ body }, meetingId) {
+ check(meetingId, String);
+ clearReactions(meetingId);
+}
diff --git a/src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js b/src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js
new file mode 100644
index 00000000..4b8e2862
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/handlers/setUserReaction.js
@@ -0,0 +1,7 @@
+import addUserReaction from '../modifiers/addUserReaction';
+
+export default function handleSetUserReaction({ body }, meetingId) {
+ const { userId, reactionEmoji } = body;
+
+ addUserReaction(meetingId, userId, reactionEmoji);
+}
diff --git a/src/2.7.12/imports/api/user-reaction/server/helpers.js b/src/2.7.12/imports/api/user-reaction/server/helpers.js
new file mode 100644
index 00000000..1efe976b
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/helpers.js
@@ -0,0 +1,44 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import UserReactions from '/imports/api/user-reaction';
+import Logger from '/imports/startup/server/logger';
+
+const expireSeconds = Meteor.settings.public.userReaction.expire;
+const expireMilliseconds = expireSeconds * 1000;
+
+const notifyExpiredReaction = (meetingId, userId) => {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserReactionTimeExpiredCmdMsg';
+ const NODE_USER = 'nodeJSapp';
+ const emoji = 'none';
+
+ check(meetingId, String);
+
+ const payload = {
+ userId,
+ };
+
+ Logger.verbose('User emoji status updated due to expiration time', {
+ emoji, NODE_USER, meetingId,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, NODE_USER, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method resetUserReaction ${err.stack}`);
+ }
+};
+
+const addUserReactionsObserver = (meetingId) => {
+ const meetingUserReactions = UserReactions.find({ meetingId });
+ return meetingUserReactions.observe({
+ removed(document) {
+ const isExpirationTriggeredRemoval = (Date.now() - Date.parse(document.creationDate)) >= expireMilliseconds;
+ if (isExpirationTriggeredRemoval) {
+ notifyExpiredReaction(meetingId, document.userId);
+ }
+ },
+ });
+};
+
+export default addUserReactionsObserver;
diff --git a/src/2.7.12/imports/api/user-reaction/server/index.js b/src/2.7.12/imports/api/user-reaction/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/user-reaction/server/methods.js b/src/2.7.12/imports/api/user-reaction/server/methods.js
new file mode 100644
index 00000000..0e69eb8f
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/methods.js
@@ -0,0 +1,8 @@
+import { Meteor } from 'meteor/meteor';
+import setUserReaction from './methods/setUserReaction';
+import clearAllUsersReaction from './methods/clearAllUsersReaction';
+
+Meteor.methods({
+ setUserReaction,
+ clearAllUsersReaction,
+});
diff --git a/src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js b/src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js
new file mode 100644
index 00000000..77b7e960
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/methods/clearAllUsersReaction.js
@@ -0,0 +1,30 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function clearAllUsersReaction() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ClearAllUsersReactionCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ userId: requesterUserId,
+ };
+
+ Logger.verbose('Sending clear all users reactions', {
+ requesterUserId, meetingId,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method clearAllUsersReaction ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js b/src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js
new file mode 100644
index 00000000..5a8889a6
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/methods/setUserReaction.js
@@ -0,0 +1,36 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+const ALLOWED_REACTIONS = Meteor.settings.public.userReaction.reactions;
+const nativeEmojis = ALLOWED_REACTIONS.map((reaction) => reaction.native);
+
+export default function setUserReaction(reactionEmoji, userId = undefined) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserReactionEmojiReqMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(reactionEmoji, String);
+ if (!nativeEmojis.includes(reactionEmoji) && reactionEmoji !== 'none') return;
+
+ const payload = {
+ reactionEmoji,
+ userId: userId || requesterUserId,
+ };
+
+ Logger.verbose('User reactionEmoji status updated', {
+ reactionEmoji, requesterUserId, meetingId,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setUserReaction ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js b/src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js
new file mode 100644
index 00000000..53d46c37
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/modifiers/addUserReaction.js
@@ -0,0 +1,34 @@
+import UserReaction from '/imports/api/user-reaction';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+export default function addUserReaction(meetingId, userId, reaction) {
+ check(meetingId, String);
+ check(userId, String);
+ check(reaction, String);
+
+ const selector = {
+ creationDate: new Date(),
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ meetingId,
+ userId,
+ reaction,
+ },
+ };
+
+ try {
+ UserReaction.remove({ meetingId, userId });
+ const { numberAffected } = UserReaction.upsert(selector, modifier);
+
+ if (numberAffected) {
+ Logger.verbose(`Added user reaction meetingId=${meetingId} userId=${userId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding user reaction: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js b/src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js
new file mode 100644
index 00000000..4696b48b
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/modifiers/clearReactions.js
@@ -0,0 +1,26 @@
+import UserReaction from '/imports/api/user-reaction';
+import Logger from '/imports/startup/server/logger';
+
+export default function clearReactions(meetingId) {
+ const selector = {};
+
+ if (meetingId) {
+ selector.meetingId = meetingId;
+ }
+
+ try {
+ const numberAffected = UserReaction.remove(selector);
+
+ if (numberAffected) {
+ if (meetingId) {
+ Logger.info(`Removed UserReaction (${meetingId})`);
+ } else {
+ Logger.info('Removed UserReaction (all)');
+ }
+ } else {
+ Logger.warn('Removing UserReaction nonaffected');
+ }
+ } catch (err) {
+ Logger.error(`Removing UserReaction: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/user-reaction/server/publishers.js b/src/2.7.12/imports/api/user-reaction/server/publishers.js
new file mode 100644
index 00000000..82658437
--- /dev/null
+++ b/src/2.7.12/imports/api/user-reaction/server/publishers.js
@@ -0,0 +1,27 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import UserReaction from '/imports/api/user-reaction';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+function userReaction() {
+ if (!this.userId) {
+ return UserReaction.find({ meetingId: '' });
+ }
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ Logger.info(`Publishing user reaction for ${meetingId} ${requesterUserId}`);
+
+ return UserReaction.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundUserReaction = userReaction.bind(this);
+ return boundUserReaction(...args);
+}
+
+Meteor.publish('user-reaction', publish);
diff --git a/src/2.7.12/imports/api/users-infos/index.js b/src/2.7.12/imports/api/users-infos/index.js
new file mode 100644
index 00000000..ee64f75c
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/index.js
@@ -0,0 +1,13 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const UserInfos = new Mongo.Collection('users-infos', collectionOptions);
+
+if (Meteor.isServer) {
+ UserInfos.createIndexAsync({ meetingId: 1, userId: 1 });
+}
+
+export default UserInfos;
diff --git a/src/2.7.12/imports/api/users-infos/server/eventHandlers.js b/src/2.7.12/imports/api/users-infos/server/eventHandlers.js
new file mode 100644
index 00000000..ad5c5536
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/eventHandlers.js
@@ -0,0 +1,4 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleUserInformation from './handlers/userInformation';
+
+RedisPubSub.on('LookUpUserRespMsg', handleUserInformation);
diff --git a/src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js b/src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js
new file mode 100644
index 00000000..6f3262ca
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/handlers/userInformation.js
@@ -0,0 +1,17 @@
+import { check } from 'meteor/check';
+import addUserInfo from '../modifiers/addUserInfo';
+
+export default async function handleUserInformation({ header, body }) {
+ check(body, Object);
+ check(header, Object);
+
+ const { userInfo } = body;
+ const { userId, meetingId } = header;
+
+ check(userInfo, Array);
+ check(userId, String);
+ check(meetingId, String);
+
+ const result = await addUserInfo(userInfo, userId, meetingId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/users-infos/server/index.js b/src/2.7.12/imports/api/users-infos/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/users-infos/server/methods.js b/src/2.7.12/imports/api/users-infos/server/methods.js
new file mode 100644
index 00000000..3b11b958
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/methods.js
@@ -0,0 +1,8 @@
+import { Meteor } from 'meteor/meteor';
+import requestUserInformation from './methods/requestUserInformation';
+import removeUserInformation from './methods/removeUserInformation';
+
+Meteor.methods({
+ requestUserInformation,
+ removeUserInformation,
+});
diff --git a/src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js b/src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js
new file mode 100644
index 00000000..66e76206
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/methods/removeUserInformation.js
@@ -0,0 +1,26 @@
+import UserInfos from '/imports/api/users-infos';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+export default async function removeUserInformation() {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const selector = {
+ meetingId,
+ requesterUserId,
+ };
+
+ const numberAffected = await UserInfos.removeAsync(selector);
+
+ if (numberAffected) {
+ Logger.info(`Removed user information: requester id=${requesterUserId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method removeUserInformation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js b/src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js
new file mode 100644
index 00000000..6a9b2c4d
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/methods/requestUserInformation.js
@@ -0,0 +1,27 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function getUserInformation(externalUserId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toThirdParty;
+ const EVENT_NAME = 'LookUpUserReqMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(externalUserId, String);
+
+ const payload = {
+ externalUserId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method getUserInformation ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js b/src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js
new file mode 100644
index 00000000..882f0efa
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/modifiers/addUserInfo.js
@@ -0,0 +1,20 @@
+import UserInfos from '/imports/api/users-infos';
+import Logger from '/imports/startup/server/logger';
+
+export default async function addUserInfo(userInfo, requesterUserId, meetingId) {
+ const info = {
+ meetingId,
+ requesterUserId,
+ userInfo,
+ };
+
+ try {
+ const numberAffected = await UserInfos.insertAsync(info);
+
+ if (numberAffected) {
+ Logger.info(`Added user information: requester id=${requesterUserId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding user information to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js
new file mode 100644
index 00000000..ca76f675
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfo.js
@@ -0,0 +1,14 @@
+import UserInfos from '/imports/api/users-infos';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearUsersInfo(meetingId) {
+ try {
+ const numberAffected = await UserInfos.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared User Infos (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing User Infos (${meetingId}). ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js
new file mode 100644
index 00000000..8766fe41
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/modifiers/clearUserInfoForRequester.js
@@ -0,0 +1,14 @@
+import UserInfos from '/imports/api/users-infos';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearUsersInfoForRequester(meetingId, requesterUserId) {
+ try {
+ const numberAffected = await UserInfos.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared User Infos requested by user=${requesterUserId}`);
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing User Infos requested by user=${requesterUserId}. ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-infos/server/publishers.js b/src/2.7.12/imports/api/users-infos/server/publishers.js
new file mode 100644
index 00000000..0bdc333c
--- /dev/null
+++ b/src/2.7.12/imports/api/users-infos/server/publishers.js
@@ -0,0 +1,27 @@
+import { Meteor } from 'meteor/meteor';
+import UserInfos from '/imports/api/users-infos';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function userInfos() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing UserInfos was requested by unauth connection ${this.connection.id}`);
+ return UserInfos.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId: requesterUserId } = tokenValidation;
+
+ Logger.debug('Publishing UserInfos requested', { meetingId, requesterUserId });
+
+ return UserInfos.find({ meetingId, requesterUserId });
+}
+
+function publish(...args) {
+ const boundUserInfos = userInfos.bind(this);
+ return boundUserInfos(...args);
+}
+
+Meteor.publish('users-infos', publish);
diff --git a/src/2.7.12/imports/api/users-persistent-data/index.js b/src/2.7.12/imports/api/users-persistent-data/index.js
new file mode 100644
index 00000000..4f5a0190
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/index.js
@@ -0,0 +1,13 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const UsersPersistentData = new Mongo.Collection('users-persistent-data', collectionOptions);
+
+if (Meteor.isServer) {
+ UsersPersistentData.createIndexAsync({ meetingId: 1, userId: 1 });
+}
+
+export default UsersPersistentData;
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/index.js b/src/2.7.12/imports/api/users-persistent-data/server/index.js
new file mode 100644
index 00000000..f7744b2b
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/index.js
@@ -0,0 +1 @@
+import './publishers';
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js
new file mode 100644
index 00000000..33c6a510
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/addUserPersistentData.js
@@ -0,0 +1,87 @@
+import { check } from 'meteor/check';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import Logger from '/imports/startup/server/logger';
+
+export default async function addUserPersistentData(user) {
+ check(user, {
+ meetingId: String,
+ sortName: String,
+ color: String,
+ speechLocale: String,
+ mobile: Boolean,
+ breakoutProps: Object,
+ inactivityCheck: Boolean,
+ responseDelay: Number,
+ loggedOut: Boolean,
+ intId: String,
+ extId: String,
+ name: String,
+ pin: Boolean,
+ role: String,
+ guest: Boolean,
+ authed: Boolean,
+ waitingForAcceptance: Match.Maybe(Boolean),
+ guestStatus: String,
+ emoji: String,
+ reactionEmoji: String,
+ raiseHand: Boolean,
+ away: Boolean,
+ presenter: Boolean,
+ locked: Boolean,
+ avatar: String,
+ webcamBackground: String,
+ clientType: String,
+ left: Boolean,
+ effectiveConnectionType: null,
+ });
+
+ const {
+ intId,
+ extId,
+ meetingId,
+ name,
+ role,
+ token,
+ avatar,
+ webcamBackground,
+ guest,
+ color,
+ pin,
+ } = user;
+
+ const userData = {
+ userId: intId,
+ extId,
+ meetingId,
+ name,
+ role,
+ token,
+ avatar,
+ webcamBackground,
+ guest,
+ color,
+ pin,
+ loggedOut: false,
+ };
+
+ const selector = {
+ userId: intId,
+ meetingId,
+ };
+
+ const modifier = {
+ $set: userData,
+ };
+
+ try {
+ const { insertedId } = await UsersPersistentData.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added user id=${intId} to user persistent Data: meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted user id=${intId} to user persistent Data: meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding note to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js
new file mode 100644
index 00000000..918767e5
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus.js
@@ -0,0 +1,25 @@
+import Logger from '/imports/startup/server/logger';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+
+export default async function changeHasConnectionStatus(hasConnectionStatus, userId, meetingId) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ 'shouldPersist.hasConnectionStatus': hasConnectionStatus,
+ },
+ };
+
+ try {
+ const numberAffected = await UsersPersistentData.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Changed hasConnectionStatus=${hasConnectionStatus} id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Change hasConnectionStatus error: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js
new file mode 100644
index 00000000..77b186f6
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/changeHasMessages.js
@@ -0,0 +1,30 @@
+import Logger from '/imports/startup/server/logger';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
+
+export default async function changeHasMessages(hasMessages, userId, meetingId, chatId) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const type = chatId === PUBLIC_GROUP_CHAT_KEY ? 'public' : 'private';
+
+ const modifier = {
+ $set: {
+ [`shouldPersist.hasMessages.${type}`]: hasMessages,
+ },
+ };
+
+ try {
+ const numberAffected = await UsersPersistentData.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Changed hasMessages=${hasMessages} id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Change hasMessages error: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js
new file mode 100644
index 00000000..19bbe807
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearChatHasMessages.js
@@ -0,0 +1,30 @@
+import Logger from '/imports/startup/server/logger';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
+
+export default async function clearChatHasMessages(meetingId, chatId) {
+ const selector = {
+ meetingId,
+ };
+
+ const type = chatId === PUBLIC_GROUP_CHAT_KEY ? 'public' : 'private';
+
+ const modifier = {
+ $set: {
+ [`shouldPersist.hasMessages.${type}`]: false,
+ },
+ };
+
+ try {
+ const numberAffected = await UsersPersistentData
+ .updateAsync(selector, modifier, { multi: true });
+
+ if (numberAffected) {
+ Logger.info(`Cleared hasMessages meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Clear hasMessages error: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js
new file mode 100644
index 00000000..ec659dab
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/clearUsersPersistentData.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import UsersPersistentData from '/imports/api/users-persistent-data/index';
+
+export default async function clearUsersPersistentData(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await UsersPersistentData.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared users persistent data (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error clearing users persistent data (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await UsersPersistentData.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared users persistent data (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error clearing users persistent data (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js
new file mode 100644
index 00000000..09e95432
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus.js
@@ -0,0 +1,26 @@
+import { check } from 'meteor/check';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setloggedOutStatus(userId, meetingId, status = true) {
+ check(userId, String);
+ check(meetingId, String);
+ check(status, Boolean);
+
+ const selector = {
+ userId,
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ loggedOut: status,
+ },
+ };
+
+ try {
+ await UsersPersistentData.updateAsync(selector, modifier);
+ } catch (err) {
+ Logger.error(`Setting users persistent data's logged out status to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js
new file mode 100644
index 00000000..95c97af9
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateRole.js
@@ -0,0 +1,26 @@
+import { check } from 'meteor/check';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import Logger from '/imports/startup/server/logger';
+
+export default async function updateRole(userId, meetingId, role) {
+ check(userId, String);
+ check(meetingId, String);
+ check(role, String);
+
+ const selector = {
+ userId,
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ role,
+ },
+ };
+
+ try {
+ await UsersPersistentData.updateAsync(selector, modifier);
+ } catch (err) {
+ Logger.error(`Updating users persistent data's role to the collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js
new file mode 100644
index 00000000..b24ef57d
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js
@@ -0,0 +1,38 @@
+import { check } from 'meteor/check';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import Logger from '/imports/startup/server/logger';
+import Breakouts from '/imports/api/breakouts';
+
+export default async function updateUserBreakoutRoom(meetingId, breakoutId, users) {
+ check(meetingId, String);
+ check(breakoutId, String);
+ check(users, Array);
+
+ const lastBreakoutRoom = await Breakouts.findOneAsync({ breakoutId }, {
+ fields: {
+ isDefaultName: 1,
+ sequence: 1,
+ shortName: 1,
+ },
+ });
+ await Promise.all(users.map(async (user) => {
+ const userId = user.id.substr(0, user.id.lastIndexOf('-'));
+
+ const selector = {
+ userId,
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ lastBreakoutRoom,
+ },
+ };
+
+ try {
+ await UsersPersistentData.updateAsync(selector, modifier);
+ } catch (err) {
+ Logger.error(`Updating users persistent data's lastBreakoutRoom to the collection: ${err}`);
+ }
+ }));
+}
diff --git a/src/2.7.12/imports/api/users-persistent-data/server/publishers.js b/src/2.7.12/imports/api/users-persistent-data/server/publishers.js
new file mode 100644
index 00000000..3eb78e0c
--- /dev/null
+++ b/src/2.7.12/imports/api/users-persistent-data/server/publishers.js
@@ -0,0 +1,54 @@
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import { Meteor } from 'meteor/meteor';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+async function usersPersistentData() {
+ if (!this.userId) {
+ return UsersPersistentData.find({ meetingId: '' });
+ }
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const selector = {
+ meetingId,
+ };
+
+ const options = {};
+
+ const User = await Users
+ .findOneAsync({ userId: requesterUserId, meetingId }, { fields: { role: 1 } });
+ if (!User || User.role !== ROLE_MODERATOR) {
+ options.fields = {
+ lastBreakoutRoom: false,
+ };
+
+ // viewers are allowed to see other users' data if:
+ // user is logged in or user sent a message in chat
+ const viewerSelector = {
+ meetingId,
+ $or: [
+ {
+ 'shouldPersist.hasMessages': true,
+ },
+ {
+ loggedOut: false,
+ },
+ ],
+ };
+ return UsersPersistentData.find(viewerSelector, options);
+ }
+ return UsersPersistentData.find(selector, options);
+}
+
+function publishUsersPersistentData(...args) {
+ const boundUsers = usersPersistentData.bind(this);
+ return boundUsers(...args);
+}
+
+Meteor.publish('users-persistent-data', publishUsersPersistentData);
diff --git a/src/2.7.12/imports/api/users-settings/index.js b/src/2.7.12/imports/api/users-settings/index.js
new file mode 100644
index 00000000..7c822af5
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/index.js
@@ -0,0 +1,15 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const UserSettings = new Mongo.Collection('users-settings', collectionOptions);
+
+if (Meteor.isServer) {
+ UserSettings.createIndexAsync({
+ meetingId: 1, userId: 1,
+ });
+}
+
+export default UserSettings;
diff --git a/src/2.7.12/imports/api/users-settings/server/index.js b/src/2.7.12/imports/api/users-settings/server/index.js
new file mode 100644
index 00000000..cb2e6664
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/server/index.js
@@ -0,0 +1,2 @@
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/users-settings/server/methods.js b/src/2.7.12/imports/api/users-settings/server/methods.js
new file mode 100644
index 00000000..85bcb7d1
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/server/methods.js
@@ -0,0 +1,6 @@
+import { Meteor } from 'meteor/meteor';
+import addUserSettings from './methods/addUserSettings';
+
+Meteor.methods({
+ addUserSettings,
+});
diff --git a/src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js b/src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js
new file mode 100644
index 00000000..31d7f8af
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/server/methods/addUserSettings.js
@@ -0,0 +1,137 @@
+import { check } from 'meteor/check';
+import addUserSetting from '/imports/api/users-settings/server/modifiers/addUserSetting';
+import logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+const oldParameters = {
+ askForFeedbackOnLogout: 'bbb_ask_for_feedback_on_logout',
+ autoJoin: 'bbb_auto_join_audio',
+ autoShareWebcam: 'bbb_auto_share_webcam',
+ clientTitle: 'bbb_client_title',
+ customStyle: 'bbb_custom_style',
+ customStyleUrl: 'bbb_custom_style_url',
+ displayBrandingArea: 'bbb_display_branding_area',
+ enableVideo: 'bbb_enable_video',
+ forceListenOnly: 'bbb_force_listen_only',
+ hidePresentationOnJoin: 'bbb_hide_presentation',
+ listenOnlyMode: 'bbb_listen_only_mode',
+ multiUserPenOnly: 'bbb_multi_user_pen_only',
+ multiUserTools: 'bbb_multi_user_tools',
+ presenterTools: 'bbb_presenter_tools',
+ shortcuts: 'bbb_shortcuts',
+ skipCheck: 'bbb_skip_check_audio',
+};
+
+const oldParametersKeys = Object.keys(oldParameters);
+
+const currentParameters = [
+ // APP
+ 'bbb_ask_for_feedback_on_logout',
+ 'bbb_override_default_locale',
+ 'bbb_auto_join_audio',
+ 'bbb_client_title',
+ 'bbb_force_listen_only',
+ 'bbb_listen_only_mode',
+ 'bbb_skip_check_audio',
+ 'bbb_skip_check_audio_on_first_join',
+ 'bbb_fullaudio_bridge',
+ 'bbb_transparent_listen_only',
+ 'bbb_show_animations_default',
+ // BRANDING
+ 'bbb_display_branding_area',
+ // SHORTCUTS
+ 'bbb_shortcuts',
+ // KURENTO
+ 'bbb_auto_share_webcam',
+ 'bbb_preferred_camera_profile',
+ 'bbb_enable_video',
+ 'bbb_record_video',
+ 'bbb_skip_video_preview',
+ 'bbb_skip_video_preview_on_first_join',
+ 'bbb_mirror_own_webcam',
+ // PRESENTATION
+ 'bbb_force_restore_presentation_on_new_events',
+ // WHITEBOARD
+ 'bbb_multi_user_pen_only',
+ 'bbb_presenter_tools',
+ 'bbb_multi_user_tools',
+ // SKINNING/THEMMING
+ 'bbb_custom_style',
+ 'bbb_custom_style_url',
+ // LAYOUT
+ 'bbb_hide_presentation_on_join',
+ 'bbb_show_participants_on_login',
+ 'bbb_show_public_chat_on_login',
+ 'bbb_hide_actions_bar',
+ 'bbb_hide_nav_bar',
+ 'bbb_change_layout',
+ 'bbb_direct_leave_button',
+ // TRANSCRIPTION
+ 'bbb_transcription_partial_utterances',
+ 'bbb_transcription_min_utterance_length',
+ 'bbb_transcription_provider',
+];
+
+function valueParser(val) {
+ try {
+ const parsedValue = JSON.parse(val.toLowerCase().trim());
+ return parsedValue;
+ } catch (error) {
+ logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`);
+ return val;
+ }
+}
+
+export default function addUserSettings(settings) {
+ try {
+ check(settings, [Object]);
+
+ const { meetingId, requesterUserId: userId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(userId, String);
+
+ let parameters = {};
+
+ settings.forEach((el) => {
+ const settingKey = Object.keys(el).shift();
+ const normalizedKey = settingKey.trim();
+
+ if (currentParameters.includes(normalizedKey)) {
+ if (!Object.keys(parameters).includes(normalizedKey)) {
+ parameters = {
+ [normalizedKey]: valueParser(el[settingKey]),
+ ...parameters,
+ };
+ } else {
+ parameters[normalizedKey] = el[settingKey];
+ }
+ return;
+ }
+
+ if (oldParametersKeys.includes(normalizedKey)) {
+ const matchingNewKey = oldParameters[normalizedKey];
+ if (!Object.keys(parameters).includes(matchingNewKey)) {
+ parameters = {
+ [matchingNewKey]: valueParser(el[settingKey]),
+ ...parameters,
+ };
+ }
+ return;
+ }
+
+ logger.warn(`Parameter ${normalizedKey} not handled`);
+ });
+
+ const settingsAdded = [];
+ Object.entries(parameters).forEach((el) => {
+ const setting = el[0];
+ const value = el[1];
+ settingsAdded.push(addUserSetting(meetingId, userId, setting, value));
+ });
+
+ return settingsAdded;
+ } catch (err) {
+ logger.error(`Exception while invoking method addUserSettings ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js b/src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js
new file mode 100644
index 00000000..682e67ae
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/server/modifiers/addUserSetting.js
@@ -0,0 +1,34 @@
+import { check } from 'meteor/check';
+import UserSettings from '/imports/api/users-settings';
+import Logger from '/imports/startup/server/logger';
+
+export default async function addUserSetting(meetingId, userId, setting, value) {
+ check(meetingId, String);
+ check(userId, String);
+ check(setting, String);
+ check(value, Match.Any);
+
+ const selector = {
+ meetingId,
+ userId,
+ setting,
+ };
+ const modifier = {
+ $set: {
+ meetingId,
+ userId,
+ setting,
+ value,
+ },
+ };
+
+ try {
+ const { numberAffected } = await UserSettings.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.verbose('Upserted user setting', { meetingId, userId, setting });
+ }
+ } catch (err) {
+ Logger.error(`Adding user setting to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js b/src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js
new file mode 100644
index 00000000..2b6bac05
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/server/modifiers/clearUsersSettings.js
@@ -0,0 +1,14 @@
+import UserSettings from '/imports/api/users-settings';
+import Logger from '/imports/startup/server/logger';
+
+export default async function clearUsersSettings(meetingId) {
+ try {
+ const numberAffected = await UserSettings.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared User Settings (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing User Settings (${meetingId}). ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users-settings/server/publishers.js b/src/2.7.12/imports/api/users-settings/server/publishers.js
new file mode 100644
index 00000000..2bce6ed0
--- /dev/null
+++ b/src/2.7.12/imports/api/users-settings/server/publishers.js
@@ -0,0 +1,27 @@
+import { Meteor } from 'meteor/meteor';
+import UserSettings from '/imports/api/users-settings';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function userSettings() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing UserSettings was requested by unauth connection ${this.connection.id}`);
+ return UserSettings.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing UserSettings', { meetingId, userId });
+
+ return UserSettings.find({ meetingId, userId });
+}
+
+function publish(...args) {
+ const boundUserSettings = userSettings.bind(this);
+ return boundUserSettings(...args);
+}
+
+Meteor.publish('users-settings', publish);
diff --git a/src/2.7.12/imports/api/users/index.js b/src/2.7.12/imports/api/users/index.js
new file mode 100644
index 00000000..3b867291
--- /dev/null
+++ b/src/2.7.12/imports/api/users/index.js
@@ -0,0 +1,18 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const Users = new Mongo.Collection('users', collectionOptions);
+export const CurrentUser = new Mongo.Collection('current-user', { connection: null });
+
+if (Meteor.isServer) {
+ // types of queries for the users:
+ // 1. meetingId
+ // 2. meetingId, userId
+ // { connection: Meteor.isClient ? null : true }
+ Users.createIndexAsync({ meetingId: 1, userId: 1 });
+}
+
+export default Users;
diff --git a/src/2.7.12/imports/api/users/server/eventHandlers.js b/src/2.7.12/imports/api/users/server/eventHandlers.js
new file mode 100644
index 00000000..405261fe
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/eventHandlers.js
@@ -0,0 +1,30 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleRemoveUser from './handlers/removeUser';
+import handleUserJoined from './handlers/userJoined';
+import handleUserLeftFlagUpdated from './handlers/userLeftFlagUpdated';
+import handleValidateAuthToken from './handlers/validateAuthToken';
+import handlePresenterAssigned from './handlers/presenterAssigned';
+import handleEmojiStatus from './handlers/emojiStatus';
+import handleChangeRole from './handlers/changeRole';
+import handleUserPinChanged from './handlers/userPinChanged';
+import handleUserInactivityInspect from './handlers/userInactivityInspect';
+import handleChangeMobileFlag from './handlers/changeMobileFlag';
+import handleChangeRaiseHand from './handlers/changeRaiseHand';
+import handleAway from './handlers/changeAway';
+import handleClearUsersEmoji from './handlers/clearUsersEmoji';
+import handleUserSpeechLocaleChanged from './handlers/userSpeechLocaleChanged';
+
+RedisPubSub.on('PresenterAssignedEvtMsg', handlePresenterAssigned);
+RedisPubSub.on('UserJoinedMeetingEvtMsg', handleUserJoined);
+RedisPubSub.on('UserLeftMeetingEvtMsg', handleRemoveUser);
+RedisPubSub.on('ValidateAuthTokenRespMsg', handleValidateAuthToken);
+RedisPubSub.on('UserEmojiChangedEvtMsg', handleEmojiStatus);
+RedisPubSub.on('ClearedAllUsersEmojiEvtMsg', handleClearUsersEmoji);
+RedisPubSub.on('UserRaiseHandChangedEvtMsg', handleChangeRaiseHand);
+RedisPubSub.on('UserAwayChangedEvtMsg', handleAway);
+RedisPubSub.on('UserRoleChangedEvtMsg', handleChangeRole);
+RedisPubSub.on('UserMobileFlagChangedEvtMsg', handleChangeMobileFlag);
+RedisPubSub.on('UserLeftFlagUpdatedEvtMsg', handleUserLeftFlagUpdated);
+RedisPubSub.on('UserPinStateChangedEvtMsg', handleUserPinChanged);
+RedisPubSub.on('UserInactivityInspectMsg', handleUserInactivityInspect);
+RedisPubSub.on('UserSpeechLocaleChangedEvtMsg', handleUserSpeechLocaleChanged);
diff --git a/src/2.7.12/imports/api/users/server/handlers/changeAway.js b/src/2.7.12/imports/api/users/server/handlers/changeAway.js
new file mode 100644
index 00000000..eff0b91e
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/changeAway.js
@@ -0,0 +1,11 @@
+import { check } from 'meteor/check';
+import changeAway from '/imports/api/users/server/modifiers/changeAway';
+
+export default async function handleAway(payload, meetingId) {
+ check(payload.body, Object);
+ check(meetingId, String);
+
+ const { userId: requesterUserId, away } = payload.body;
+
+ await changeAway(meetingId, requesterUserId, away);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js b/src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js
new file mode 100644
index 00000000..3ac64656
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/changeMobileFlag.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import setMobile from '/imports/api/users/server/modifiers/setMobile';
+
+export default async function handleChangeMobileFlag(payload, meetingId) {
+ check(payload.body, Object);
+ check(meetingId, String);
+
+ const { userId: requesterUserId, mobile } = payload.body;
+
+ if (mobile) {
+ await setMobile(meetingId, requesterUserId);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js b/src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js
new file mode 100644
index 00000000..6cef5bee
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/changeRaiseHand.js
@@ -0,0 +1,11 @@
+import { check } from 'meteor/check';
+import changeRaiseHand from '/imports/api/users/server/modifiers/changeRaiseHand';
+
+export default async function handleChangeRaiseHand(payload, meetingId) {
+ check(payload.body, Object);
+ check(meetingId, String);
+
+ const { userId: requesterUserId, raiseHand } = payload.body;
+
+ await changeRaiseHand(meetingId, requesterUserId, raiseHand);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/changeRole.js b/src/2.7.12/imports/api/users/server/handlers/changeRole.js
new file mode 100644
index 00000000..3039313c
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/changeRole.js
@@ -0,0 +1,11 @@
+import { check } from 'meteor/check';
+import changeRole from '/imports/api/users/server/modifiers/changeRole';
+
+export default async function handleChangeRole(payload, meetingId) {
+ check(payload.body, Object);
+ check(meetingId, String);
+
+ const { userId, role, changedBy } = payload.body;
+
+ await changeRole(role, userId, meetingId, changedBy);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js b/src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js
new file mode 100644
index 00000000..3f9ef63c
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/clearUsersEmoji.js
@@ -0,0 +1,7 @@
+import { check } from 'meteor/check';
+import clearUsersEmoji from '../modifiers/clearUsersEmoji';
+
+export default function handleClearUsersEmoji({ body }, meetingId) {
+ check(meetingId, String);
+ clearUsersEmoji(meetingId);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/emojiStatus.js b/src/2.7.12/imports/api/users/server/handlers/emojiStatus.js
new file mode 100644
index 00000000..4f67a4b4
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/emojiStatus.js
@@ -0,0 +1,32 @@
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+
+export default async function handleEmojiStatus({ body }, meetingId) {
+ const { userId, emoji } = body;
+
+ check(userId, String);
+ check(emoji, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ emojiTime: (new Date()).getTime(),
+ emoji,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Assigned user emoji status ${emoji} id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Assigning user emoji status: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js b/src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js
new file mode 100644
index 00000000..a84dc87b
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/presenterAssigned.js
@@ -0,0 +1,64 @@
+import Users from '/imports/api/users';
+import PresentationPods from '/imports/api/presentation-pods';
+import changePresenter from '/imports/api/users/server/modifiers/changePresenter';
+import RedisPubSub from '/imports/startup/server/redis';
+
+function setPresenterInPodReqMsg(credentials) { // TODO-- switch to meetingId, etc
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetPresenterInPodReqMsg';
+
+ const { meetingId, requesterUserId, presenterId } = credentials;
+
+ const payload = {
+ podId: 'DEFAULT_PRESENTATION_POD',
+ nextPresenterId: presenterId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+}
+
+export default async function handlePresenterAssigned({ body }, meetingId) {
+ const { presenterId, assignedBy } = body;
+
+ await changePresenter(true, presenterId, meetingId, assignedBy);
+
+ const selector = {
+ meetingId,
+ userId: { $ne: presenterId },
+ presenter: true,
+ };
+
+ const defaultPodSelector = {
+ meetingId,
+ podId: 'DEFAULT_PRESENTATION_POD',
+ };
+
+ const currentDefaultPod = await PresentationPods.findOneAsync(defaultPodSelector);
+
+ const setPresenterPayload = {
+ meetingId,
+ requesterUserId: assignedBy,
+ presenterId,
+ };
+
+ const prevPresenter = await Users.findOneAsync(selector);
+
+ if (prevPresenter) {
+ await changePresenter(false, prevPresenter.userId, meetingId, assignedBy);
+ }
+
+ /**
+ * In the cases where the first moderator joins the meeting or
+ * the current presenter left the meeting, akka-apps doesn't assign the new presenter
+ * to the default presentation pod. This step is done manually here.
+ */
+
+ if (currentDefaultPod.currentPresenterId !== presenterId) {
+ const presenterToBeAssigned = await Users.findOneAsync({ userId: presenterId });
+
+ if (!presenterToBeAssigned) setPresenterPayload.presenterId = '';
+
+ setPresenterInPodReqMsg(setPresenterPayload);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js b/src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js
new file mode 100644
index 00000000..bff5d4d2
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/reactionEmoji.js
@@ -0,0 +1,32 @@
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+
+export default async function handleReactionEmoji({ body }, meetingId) {
+ const { userId, reactionEmoji } = body;
+aaa
+ check(userId, String);
+ check(reactionEmoji, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ reactionEmojiTime: (new Date()).getTime(),
+ reactionEmoji,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Assigned user rectionEmoji ${reactionEmoji} id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Assigning user reactionEmoji: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/removeUser.js b/src/2.7.12/imports/api/users/server/handlers/removeUser.js
new file mode 100644
index 00000000..60ba2242
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/removeUser.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+
+import removeUser from '../modifiers/removeUser';
+
+export default async function handleRemoveUser({ body }, meetingId) {
+ const { intId } = body;
+
+ check(meetingId, String);
+ check(intId, String);
+
+ const result = await removeUser(body, meetingId);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js b/src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js
new file mode 100644
index 00000000..b94efe5a
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/userInactivityInspect.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import userInactivityInspect from '../modifiers/userInactivityInspect';
+
+export default async function handleUserInactivityInspect({ header, body }, meetingId) {
+ const { userId } = header;
+ const { responseDelay } = body;
+
+ check(userId, String);
+ check(responseDelay, Match.Integer);
+ check(meetingId, String);
+
+ await userInactivityInspect(userId, responseDelay);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/userJoin.js b/src/2.7.12/imports/api/users/server/handlers/userJoin.js
new file mode 100644
index 00000000..93ecd8bd
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/userJoin.js
@@ -0,0 +1,22 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+
+export default function userJoin(meetingId, userId, authToken) {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserJoinMeetingReqMsg';
+
+ check(authToken, String);
+
+ const payload = {
+ userId,
+ authToken,
+ clientType: 'HTML5',
+ };
+
+ Logger.info(`User='${userId}' is joining meeting='${meetingId}' authToken='${authToken}'`);
+
+ return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/userJoined.js b/src/2.7.12/imports/api/users/server/handlers/userJoined.js
new file mode 100644
index 00000000..a0e237c0
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/userJoined.js
@@ -0,0 +1,11 @@
+import { check } from 'meteor/check';
+
+import addUser from '../modifiers/addUser';
+
+export default async function handleUserJoined({ body }, meetingId) {
+ const user = body;
+
+ check(user, Object);
+
+ await addUser(meetingId, user);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js b/src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js
new file mode 100644
index 00000000..8041c10a
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/userLeftFlagUpdated.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+
+import userLeftFlag from '../modifiers/userLeftFlagUpdated';
+
+export default async function handleUserLeftFlag({ body }, meetingId) {
+ const user = body;
+ check(user, {
+ intId: String,
+ userLeftFlag: Boolean,
+ });
+
+ await userLeftFlag(meetingId, user.intId, user.userLeftFlag);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/userPinChanged.js b/src/2.7.12/imports/api/users/server/handlers/userPinChanged.js
new file mode 100644
index 00000000..15e8b30c
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/userPinChanged.js
@@ -0,0 +1,14 @@
+import { check } from 'meteor/check';
+import changePin from '../modifiers/changePin';
+
+export default async function handlePinAssigned({ body }, meetingId) {
+ const { userId, pin, changedBy } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(pin, Boolean);
+ check(changedBy, String);
+
+ const result = await changePin(meetingId, userId, pin, changedBy);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js b/src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js
new file mode 100644
index 00000000..205cfee3
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/userSpeechLocaleChanged.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import updateSpeechLocale from '../modifiers/updateSpeechLocale';
+
+export default function handleUserSpeechLocaleChanged({ body, header }, meetingId) {
+ const { locale } = body;
+ const { userId } = header;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(locale, String);
+
+ return updateSpeechLocale(meetingId, userId, locale);
+}
diff --git a/src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js b/src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js
new file mode 100644
index 00000000..d7c8d9eb
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/handlers/validateAuthToken.js
@@ -0,0 +1,157 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import userJoin from './userJoin';
+import pendingAuthenticationsStore from '../store/pendingAuthentications';
+import createDummyUser from '../modifiers/createDummyUser';
+import updateUserConnectionId from '../modifiers/updateUserConnectionId';
+import ClientConnections from '/imports/startup/server/ClientConnections';
+
+import upsertValidationState from '/imports/api/auth-token-validation/server/modifiers/upsertValidationState';
+import { ValidationStates } from '/imports/api/auth-token-validation';
+
+const clearOtherSessions = (sessionUserId, current = false) => {
+ const serverSessions = Meteor.server.sessions;
+ Object.keys(serverSessions)
+ .filter(i => serverSessions[i].userId === sessionUserId)
+ .filter(i => i !== current)
+ .forEach(i => serverSessions[i].close());
+};
+
+export default async function handleValidateAuthToken({ body }, meetingId) {
+ const {
+ userId,
+ valid,
+ authToken,
+ waitForApproval,
+ registeredOn,
+ authTokenValidatedOn,
+ reasonCode,
+ } = body;
+
+ check(userId, String);
+ check(authToken, String);
+ check(valid, Boolean);
+ check(waitForApproval, Boolean);
+ check(registeredOn, Number);
+ check(authTokenValidatedOn, Number);
+ check(reasonCode, String);
+
+ const pendingAuths = pendingAuthenticationsStore.take(meetingId, userId, authToken);
+ const printablePendingAuthStore = pendingAuths.map((a) => {
+ return { meetingId: a.meetingId, userId: a.userId, authToken: a.authToken }
+ })
+ Logger.info(`PendingAuths length [${pendingAuths.length}]: ${JSON.stringify(printablePendingAuthStore)}`);
+ if (pendingAuths.length === 0) return;
+
+ if (!valid) {
+ await Promise.all(pendingAuths.map(
+ async (pendingAuth) => {
+ try {
+ const { methodInvocationObject } = pendingAuth;
+ const connectionId = methodInvocationObject.connection.id;
+
+ await upsertValidationState(
+ meetingId,
+ userId,
+ ValidationStates.INVALID,
+ connectionId,
+ reasonCode,
+ );
+
+ // Schedule socket disconnection for this user
+ // giving some time for client receiving the reason of disconnection
+ Logger.info(`Scheduling socket disconnection for user ${userId} ${connectionId} due to invalid auth token`);
+ new Promise((resolve) => {
+ Meteor.setTimeout(() => {
+ methodInvocationObject.connection.close();
+ Logger.info(`Closed connection ${connectionId} due to invalid auth token.`);
+ resolve();
+ }, 2000);
+ });
+ } catch (e) {
+ Logger.error(`Error closing socket for meetingId '${meetingId}', userId '${userId}', authToken ${authToken}`);
+ }
+ },
+ ));
+
+ return;
+ }
+
+ // Define user ID on connections
+ await Promise.all(pendingAuths.map(
+ async (pendingAuth) => {
+ const { methodInvocationObject } = pendingAuth;
+
+ /* Logic migrated from validateAuthToken method
+ ( postponed to only run in case of success response ) - Begin */
+ const sessionId = `${meetingId}--${userId}`;
+
+ methodInvocationObject.setUserId(sessionId);
+
+ const User = await Users.findOneAsync({
+ meetingId,
+ userId,
+ });
+
+ if (!User) {
+ await createDummyUser(meetingId, userId, authToken);
+ } else {
+ await updateUserConnectionId(meetingId, userId, methodInvocationObject.connection.id);
+ }
+
+ ClientConnections.add(sessionId, methodInvocationObject.connection);
+ await upsertValidationState(
+ meetingId,
+ userId,
+ ValidationStates.VALIDATED,
+ methodInvocationObject.connection.id,
+ );
+
+ /* End of logic migrated from validateAuthToken */
+ },
+ ));
+
+ const selector = {
+ meetingId,
+ userId,
+ clientType: 'HTML5',
+ };
+
+ const User = await Users.findOneAsync(selector);
+
+ // If we dont find the user on our collection is a flash user and we can skip
+ if (!User) return;
+
+ // Publish user join message
+ if (!waitForApproval) {
+ Logger.info('User=', User);
+ userJoin(meetingId, userId, User.authToken);
+ }
+
+ const modifier = {
+ $set: {
+ validated: valid,
+ approved: !waitForApproval,
+ loginTime: registeredOn,
+ authTokenValidatedTime: authTokenValidatedOn,
+ inactivityCheck: false,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ const sessionUserId = `${meetingId}-${userId}`;
+ const currentConnectionId = User.connectionId ? User.connectionId : false;
+ clearOtherSessions(sessionUserId, currentConnectionId);
+
+ Logger.info(`Validated auth token as ${valid} user=${userId} meeting=${meetingId}`);
+ } else {
+ Logger.info('No auth to validate');
+ }
+ } catch (err) {
+ Logger.error(`Validating auth token: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/index.js b/src/2.7.12/imports/api/users/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/users/server/methods.js b/src/2.7.12/imports/api/users/server/methods.js
new file mode 100644
index 00000000..f6f7303f
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods.js
@@ -0,0 +1,42 @@
+import { Meteor } from 'meteor/meteor';
+import validateAuthToken from './methods/validateAuthToken';
+import setSpeechLocale from './methods/setSpeechLocale';
+import setSpeechOptions from './methods/setSpeechOptions';
+import setMobileUser from './methods/setMobileUser';
+import setEmojiStatus from './methods/setEmojiStatus';
+import changeAway from './methods/changeAway';
+import changeRaiseHand from './methods/changeRaiseHand';
+import assignPresenter from './methods/assignPresenter';
+import changeRole from './methods/changeRole';
+import removeUser from './methods/removeUser';
+import toggleUserLock from './methods/toggleUserLock';
+import toggleUserChatLock from './methods/toggleUserChatLock';
+import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
+import userActivitySign from './methods/userActivitySign';
+import userLeftMeeting from './methods/userLeftMeeting';
+import changePin from './methods/changePin';
+import setRandomUser from './methods/setRandomUser';
+import setExitReason from './methods/setExitReason';
+import clearAllUsersEmoji from './methods/clearAllUsersEmoji';
+
+Meteor.methods({
+ setSpeechLocale,
+ setSpeechOptions,
+ setMobileUser,
+ setEmojiStatus,
+ clearAllUsersEmoji,
+ changeAway,
+ changeRaiseHand,
+ assignPresenter,
+ changeRole,
+ removeUser,
+ validateAuthToken,
+ toggleUserLock,
+ toggleUserChatLock,
+ setUserEffectiveConnectionType,
+ userActivitySign,
+ userLeftMeeting,
+ changePin,
+ setRandomUser,
+ setExitReason,
+});
diff --git a/src/2.7.12/imports/api/users/server/methods/assignPresenter.js b/src/2.7.12/imports/api/users/server/methods/assignPresenter.js
new file mode 100644
index 00000000..eb6284fd
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/assignPresenter.js
@@ -0,0 +1,41 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default async function assignPresenter(userId) { // TODO-- send username from client side
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'AssignPresenterReqMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(userId, String);
+
+ const User = await Users.findOneAsync({
+ meetingId,
+ userId,
+ });
+
+ if (!User) {
+ throw new Meteor.Error('user-not-found', 'You need a valid user to be able to set presenter');
+ }
+
+ const payload = {
+ newPresenterId: userId,
+ newPresenterName: User.name,
+ assignedBy: requesterUserId,
+ requesterId: requesterUserId,
+ };
+
+ Logger.verbose('User set as presenter', { userId, meetingId, setBy: requesterUserId });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method assignPresenter ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/changeAway.js b/src/2.7.12/imports/api/users/server/methods/changeAway.js
new file mode 100644
index 00000000..929dfad5
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/changeAway.js
@@ -0,0 +1,31 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import RedisPubSub from '/imports/startup/server/redis';
+
+export default async function changeAway(away) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserAwayReqMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(away, Boolean);
+
+ const payload = {
+ userId: requesterUserId,
+ away,
+ };
+
+ Logger.verbose('Updated away status for user', {
+ meetingId, requesterUserId, away,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method changeAway ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/changePin.js b/src/2.7.12/imports/api/users/server/methods/changePin.js
new file mode 100644
index 00000000..7c387136
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/changePin.js
@@ -0,0 +1,33 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function changePin(userId, pin) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserPinStateReqMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(userId, String);
+ check(pin, Boolean);
+
+ const payload = {
+ pin,
+ changedBy: requesterUserId,
+ userId,
+ };
+
+ Logger.verbose('User pin requested', {
+ userId, meetingId, changedBy: requesterUserId, pin,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method changePin ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js b/src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js
new file mode 100644
index 00000000..77d7d8b7
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/changeRaiseHand.js
@@ -0,0 +1,31 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import RedisPubSub from '/imports/startup/server/redis';
+
+export default async function changeRaiseHand(raiseHand, userId = undefined) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserRaiseHandReqMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(raiseHand, Boolean);
+
+ const payload = {
+ userId: userId || requesterUserId,
+ raiseHand,
+ };
+
+ Logger.verbose('Updated raiseHand status for user', {
+ meetingId, requesterUserId, raiseHand,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method changeRaiseHand ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/changeRole.js b/src/2.7.12/imports/api/users/server/methods/changeRole.js
new file mode 100644
index 00000000..65cd2fbc
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/changeRole.js
@@ -0,0 +1,34 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function changeRole(userId, role) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserRoleCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(userId, String);
+ check(role, String);
+
+ const payload = {
+ userId,
+ role,
+ changedBy: requesterUserId,
+ };
+
+ Logger.verbose('Changed user role', {
+ userId, role, changedBy: requesterUserId, meetingId,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method changeRole ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js b/src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js
new file mode 100644
index 00000000..916f31db
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/clearAllUsersEmoji.js
@@ -0,0 +1,30 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function clearAllUsersEmoji() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ClearAllUsersEmojiCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ userId: requesterUserId,
+ };
+
+ Logger.verbose('Sending clear all users emojis', {
+ requesterUserId, meetingId,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setUserReaction ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/removeUser.js b/src/2.7.12/imports/api/users/server/methods/removeUser.js
new file mode 100644
index 00000000..7a8c222b
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/removeUser.js
@@ -0,0 +1,30 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function removeUser(userId, banUser) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'EjectUserFromMeetingCmdMsg';
+
+ const { meetingId, requesterUserId: ejectedBy } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(ejectedBy, String);
+ check(userId, String);
+ check(banUser, Boolean);
+
+ const payload = {
+ userId,
+ ejectedBy,
+ banUser,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, ejectedBy, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method removeUser ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js b/src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js
new file mode 100644
index 00000000..d2c082d7
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/sendAwayStatusChatMsg.js
@@ -0,0 +1,72 @@
+import { Meteor } from 'meteor/meteor';
+
+import Meetings from '/imports/api/meetings';
+import Users from '/imports/api/users';
+import addSystemMsg from '/imports/api/group-chat-msg/server/modifiers/addSystemMsg';
+
+const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+const CHAT_USER_STATUS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_status_message;
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+
+export default function sendAwayStatusChatMsg(meetingId, userId, newAwayStatus) {
+ const user = Users.findOne(
+ { meetingId, userId },
+ {
+ fields: {
+ name: 1,
+ role: 1,
+ locked: 1,
+ away: 1,
+ },
+ },
+ );
+
+ if (!user) return null;
+
+ // Check for viewer permissions
+ if (user.role === ROLE_VIEWER && user.locked) {
+ const meeting = Meetings.findOne(
+ { meetingId },
+ { fields: { 'lockSettingsProps.disablePublicChat': 1 } },
+ );
+
+ if (!meeting) return null;
+
+ // Return if viewer has his public chat disabled
+ const { lockSettingsProps } = meeting;
+ if (lockSettingsProps && lockSettingsProps.disablePublicChat) {
+ return null;
+ }
+ }
+
+ // Send message if previous emoji or actual emoji is 'away'
+ let status;
+ if (user.away && !newAwayStatus) {
+ status = 'notAway';
+ } else if (!user.away && newAwayStatus) {
+ status = 'away';
+ } else {
+ return null;
+ }
+
+ const extra = {
+ type: 'status',
+ status,
+ };
+
+ const payload = {
+ id: `${SYSTEM_CHAT_TYPE}-${CHAT_USER_STATUS_MESSAGE}`,
+ timestamp: Date.now(),
+ correlationId: `${userId}-${Date.now()}`,
+ sender: {
+ id: userId,
+ name: user.name,
+ },
+ message: '',
+ extra,
+ };
+
+ return addSystemMsg(meetingId, PUBLIC_GROUP_CHAT_ID, payload);
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js b/src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js
new file mode 100644
index 00000000..dd799886
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setEmojiStatus.js
@@ -0,0 +1,33 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function setEmojiStatus(userId, status) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserEmojiCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(userId, String);
+ check(status, String);
+
+ const payload = {
+ emoji: status,
+ userId,
+ };
+
+ Logger.verbose('User emoji status updated', {
+ userId, status, requesterUserId, meetingId,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setEmojiStatus ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setExitReason.js b/src/2.7.12/imports/api/users/server/methods/setExitReason.js
new file mode 100644
index 00000000..335a31ba
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setExitReason.js
@@ -0,0 +1,21 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import setUserExitReason from '/imports/api/users/server/modifiers/setUserExitReason';
+
+export default async function setExitReason(reason) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ // Unauthenticated user, just ignore and go ahead.
+ if (!meetingId || !requesterUserId) return;
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(reason, String);
+
+ await setUserExitReason(meetingId, requesterUserId, reason);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setExitReason ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setMobileUser.js b/src/2.7.12/imports/api/users/server/methods/setMobileUser.js
new file mode 100644
index 00000000..cc329598
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setMobileUser.js
@@ -0,0 +1,28 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import RedisPubSub from '/imports/startup/server/redis';
+
+export default async function setMobileUser() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserMobileFlagReqMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ userId: requesterUserId,
+ mobile: true,
+ };
+
+ Logger.verbose(`Mobile user ${requesterUserId} from meeting ${meetingId}`);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setMobileUser ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setRandomUser.js b/src/2.7.12/imports/api/users/server/methods/setRandomUser.js
new file mode 100644
index 00000000..aa751daa
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setRandomUser.js
@@ -0,0 +1,26 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default function setRandomUser() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SelectRandomViewerReqMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ requestedBy: requesterUserId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setRandomUser ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js b/src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js
new file mode 100644
index 00000000..0076f853
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setSpeechLocale.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+const LANGUAGES = Meteor.settings.public.app.audioCaptions.language.available;
+
+export default function setSpeechLocale(locale, provider) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetUserSpeechLocaleReqMsg';
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(locale, String);
+ check(provider, String);
+
+ const payload = {
+ locale,
+ provider: provider !== 'webspeech' ? provider : '',
+ };
+
+ if (LANGUAGES.includes(locale) || locale === '' || locale === 'auto') {
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method setSpeechLocale ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js b/src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js
new file mode 100644
index 00000000..6b8b5835
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setSpeechOptions.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default async function setSpeechOptions(partialUtterances, minUtteranceLength) {
+ try {
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'SetUserSpeechOptionsReqMsg';
+
+ Logger.info(`Setting speech options for ${meetingId} ${requesterUserId} ${partialUtterances} ${minUtteranceLength}`);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(partialUtterances, Boolean);
+ check(minUtteranceLength, Number);
+
+ const payload = {
+ partialUtterances,
+ minUtteranceLength,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (e) {
+ Logger.error(e);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js b/src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js
new file mode 100644
index 00000000..6826d66d
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/setUserEffectiveConnectionType.js
@@ -0,0 +1,33 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import setEffectiveConnectionType from '../modifiers/setUserEffectiveConnectionType';
+
+export default async function setUserEffectiveConnectionType(effectiveConnectionType) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ChangeUserEffectiveConnectionMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(effectiveConnectionType, String);
+
+ const payload = {
+ meetingId,
+ userId: requesterUserId,
+ effectiveConnectionType,
+ };
+
+ await setEffectiveConnectionType(meetingId, requesterUserId, effectiveConnectionType);
+
+ Logger.verbose('Updated user effective connection', { requesterUserId, effectiveConnectionType });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method setUserEffectiveConnectionType ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js b/src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js
new file mode 100644
index 00000000..65942109
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/toggleUserChatLock.js
@@ -0,0 +1,34 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function toggleUserChatLock(userId, isLocked) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'LockUserChatInMeetingCmdMsg';
+
+ const { meetingId, requesterUserId: lockedBy } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(lockedBy, String);
+ check(userId, String);
+ check(isLocked, Boolean);
+
+ const payload = {
+ lockedBy,
+ userId,
+ isLocked,
+ };
+
+ Logger.verbose('Updated chat lock status for user', {
+ meetingId, userId, isLocked, lockedBy,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, lockedBy, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method toggleUserChatLock ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/toggleUserLock.js b/src/2.7.12/imports/api/users/server/methods/toggleUserLock.js
new file mode 100644
index 00000000..ccb9efc2
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/toggleUserLock.js
@@ -0,0 +1,34 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function toggleUserLock(userId, lock) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'LockUserInMeetingCmdMsg';
+
+ const { meetingId, requesterUserId: lockedBy } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(lockedBy, String);
+ check(userId, String);
+ check(lock, Boolean);
+
+ const payload = {
+ lockedBy,
+ userId,
+ lock,
+ };
+
+ Logger.verbose('Updated lock status for user', {
+ meetingId, userId, lock, lockedBy,
+ });
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, lockedBy, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method toggleUserLock ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/userActivitySign.js b/src/2.7.12/imports/api/users/server/methods/userActivitySign.js
new file mode 100644
index 00000000..75722dbf
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/userActivitySign.js
@@ -0,0 +1,40 @@
+import { Meteor } from 'meteor/meteor';
+import Users from '/imports/api/users';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+export default async function userActivitySign() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserActivitySignCmdMsg';
+ const { meetingId, requesterUserId: userId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(userId, String);
+
+ const payload = {
+ userId,
+ };
+
+ const selector = {
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ inactivityCheck: false,
+ },
+ };
+
+ await Users.updateAsync(selector, modifier); // TODO-- we should move this to a modifier
+
+ Logger.info(`User ${userId} sent a activity sign for meeting ${meetingId}`);
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method userActivitySign ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/userLeaving.js b/src/2.7.12/imports/api/users/server/methods/userLeaving.js
new file mode 100644
index 00000000..b74044e3
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/userLeaving.js
@@ -0,0 +1,66 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation from '/imports/api/auth-token-validation';
+import Users from '/imports/api/users';
+import ClientConnections from '/imports/startup/server/ClientConnections';
+
+export default async function userLeaving(meetingId, userId, connectionId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserLeaveReqMsg';
+
+ check(userId, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const user = await Users.findOneAsync(selector);
+
+ if (!user) {
+ Logger.info(`Skipping userLeaving. Could not find ${userId} in ${meetingId}`);
+ return;
+ }
+
+ const auth = await AuthTokenValidation.findOneAsync({
+ meetingId,
+ userId,
+ }, { sort: { updatedAt: -1 } });
+
+ // If the current user connection is not the same that triggered the leave we skip
+ if (auth?.connectionId !== connectionId) {
+ Logger.info(`Skipping userLeaving. User connectionId=${user.connectionId} is different from requester connectionId=${connectionId}`);
+ return false;
+ }
+
+ const payload = {
+ userId,
+ sessionId: meetingId,
+ loggedOut: user.loggedOut || false,
+ };
+
+ ClientConnections.removeClientConnection(`${meetingId}--${userId}`, connectionId);
+
+ let reason;
+
+ if (user.loggedOut) {
+ // User explicitly requested logout.
+ reason = 'logout';
+ } else if (user.exitReason) {
+ // User didn't requested logout but exited graciously.
+ reason = user.exitReason;
+ } else {
+ // User didn't exit graciously (disconnection).
+ reason = 'disconnection';
+ }
+
+ Logger.info(`User '${userId}' is leaving meeting '${meetingId}' reason=${reason}`);
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method userLeaving ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js b/src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js
new file mode 100644
index 00000000..7d4e6166
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/userLeftMeeting.js
@@ -0,0 +1,32 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import ClientConnections from '/imports/startup/server/ClientConnections';
+import { check } from 'meteor/check';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+
+export default async function userLeftMeeting() {
+ // TODO-- spread the code to method/modifier/handler
+ try {
+ // so we don't update the db in a method
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const selector = {
+ meetingId,
+ userId: requesterUserId,
+ };
+
+ const numberAffected = await Users.updateAsync(selector, { $set: { loggedOut: true } });
+
+ if (numberAffected) {
+ await UsersPersistentData.updateAsync(selector, { $set: { loggedOut: true } });
+ Logger.info(`user left id=${requesterUserId} meeting=${meetingId}`);
+ ClientConnections.removeClientConnection(this.userId, this.connection.id);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method userLeftMeeting ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/methods/validateAuthToken.js b/src/2.7.12/imports/api/users/server/methods/validateAuthToken.js
new file mode 100644
index 00000000..28efd2c6
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/methods/validateAuthToken.js
@@ -0,0 +1,78 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import Logger from '/imports/startup/server/logger';
+import upsertValidationState from '/imports/api/auth-token-validation/server/modifiers/upsertValidationState';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+import pendingAuthenticationsStore from '../store/pendingAuthentications';
+
+const AUTH_TIMEOUT = 120000;
+
+async function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) {
+ let setTimeoutRef = null;
+ const userValidation = await new Promise(async (res, rej) => {
+ const observeFunc = (obj) => {
+ if (obj.validationStatus === ValidationStates.VALIDATED) {
+ Meteor.clearTimeout(setTimeoutRef);
+ return res(obj);
+ }
+ if (obj.validationStatus === ValidationStates.INVALID) {
+ Meteor.clearTimeout(setTimeoutRef);
+ return res(obj);
+ }
+ };
+ const authTokenValidationObserver = AuthTokenValidation.find({
+ connectionId: this.connection.id,
+ }).observe({
+ added: observeFunc,
+ changed: observeFunc,
+ });
+
+ setTimeoutRef = Meteor.setTimeout(() => {
+ authTokenValidationObserver.stop();
+ rej();
+ }, AUTH_TIMEOUT);
+
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ValidateAuthTokenReqMsg';
+
+ Logger.debug('ValidateAuthToken method called', { meetingId, requesterUserId, requesterToken, externalId });
+
+ if (!meetingId) return false;
+
+ // Store reference of methodInvocationObject ( to postpone the connection userId definition )
+ pendingAuthenticationsStore.add(meetingId, requesterUserId, requesterToken, this);
+ await upsertValidationState(
+ meetingId,
+ requesterUserId,
+ ValidationStates.VALIDATING,
+ this.connection.id,
+ );
+
+ const payload = {
+ userId: requesterUserId,
+ authToken: requesterToken,
+ };
+
+ Logger.info(`User '${requesterUserId}' is trying to validate auth token for meeting '${meetingId}' from connection '${this.connection.id}'`);
+
+ return RedisPubSub.publishUserMessage(
+ CHANNEL,
+ EVENT_NAME,
+ meetingId,
+ requesterUserId,
+ payload,
+ );
+ } catch (err) {
+ const errMsg = `Exception while invoking method validateAuthToken ${err}`;
+ Logger.error(errMsg);
+ rej(errMsg);
+ Meteor.clearTimeout(setTimeoutRef);
+ authTokenValidationObserver.stop();
+ }
+ });
+ return userValidation;
+}
+
+export default validateAuthToken;
diff --git a/src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js b/src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js
new file mode 100644
index 00000000..68d0d04e
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/addDialInUser.js
@@ -0,0 +1,36 @@
+import { check } from 'meteor/check';
+import addUser from '/imports/api/users/server/modifiers/addUser';
+
+export default async function addDialInUser(meetingId, voiceUser) {
+ check(meetingId, String);
+ check(voiceUser, Object);
+
+ const USER_CONFIG = Meteor.settings.public.user;
+ const ROLE_VIEWER = USER_CONFIG.role_viewer;
+
+ const { intId, callerName, color } = voiceUser;
+
+ const voiceOnlyUser = {
+ intId,
+ extId: intId, // TODO
+ name: callerName,
+ role: ROLE_VIEWER.toLowerCase(),
+ guest: true,
+ authed: true,
+ waitingForAcceptance: false,
+ guestStatus: 'ALLOW',
+ emoji: 'none',
+ reactionEmoji: 'none',
+ raiseHand: false,
+ away: false,
+ presenter: false,
+ locked: false, // TODO
+ avatar: '',
+ webcamBackground: '',
+ color,
+ pin: false,
+ clientType: 'dial-in-user',
+ };
+ const user = await addUser(meetingId, voiceOnlyUser);
+ return user;
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/addUser.js b/src/2.7.12/imports/api/users/server/modifiers/addUser.js
new file mode 100644
index 00000000..570dbc12
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/addUser.js
@@ -0,0 +1,112 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import Meetings from '/imports/api/meetings';
+import VoiceUsers from '/imports/api/voice-users/';
+import addUserPsersistentData from '/imports/api/users-persistent-data/server/modifiers/addUserPersistentData';
+import flat from 'flat';
+import { lowercaseTrim } from '/imports/utils/string-utils';
+
+import addVoiceUser from '/imports/api/voice-users/server/modifiers/addVoiceUser';
+
+export default async function addUser(meetingId, userData) {
+ const user = userData;
+
+ check(meetingId, String);
+
+ check(user, {
+ intId: String,
+ extId: String,
+ name: String,
+ role: String,
+ guest: Boolean,
+ authed: Boolean,
+ waitingForAcceptance: Match.Maybe(Boolean),
+ guestStatus: String,
+ emoji: String,
+ reactionEmoji: String,
+ raiseHand: Boolean,
+ away: Boolean,
+ presenter: Boolean,
+ locked: Boolean,
+ avatar: String,
+ webcamBackground: String,
+ color: String,
+ pin: Boolean,
+ clientType: String,
+ userCustomData: Match.Optional(Match.Any),
+ });
+
+ const userId = user.intId;
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+ const Meeting = await Meetings.findOneAsync({ meetingId });
+
+ const { userCustomData, ...restOfUser } = user;
+
+ const userInfos = {
+ meetingId,
+ sortName: lowercaseTrim(user.name),
+ speechLocale: '',
+ mobile: false,
+ breakoutProps: {
+ isBreakoutUser: Meeting.meetingProp.isBreakout,
+ parentId: Meeting.breakoutProps.parentId,
+ },
+ effectiveConnectionType: null,
+ inactivityCheck: false,
+ responseDelay: 0,
+ loggedOut: false,
+ left: false,
+ ...flat(restOfUser),
+ };
+
+ const modifier = {
+ $set: userInfos,
+ };
+ await addUserPsersistentData(userInfos);
+ // Only add an empty VoiceUser if there isn't one already and if the user coming in isn't a
+ // dial-in user. We want to avoid overwriting good data
+ const voiceUser = await VoiceUsers.findOneAsync({ meetingId, intId: userId });
+ if (user.clientType !== 'dial-in-user' && !voiceUser) {
+ await addVoiceUser(meetingId, {
+ voiceUserId: '',
+ intId: userId,
+ callerName: user.name,
+ callerNum: '',
+ color: user.color,
+ muted: false,
+ talking: false,
+ callingWith: '',
+ listenOnly: false,
+ voiceConf: '',
+ joined: false,
+ });
+ }
+
+ /**
+ * Add a verification to check if the user was set as presenter.
+ * In some cases the user information is set after the presenter is set
+ * causing the first moderator to join a meeting be marked as presenter: false
+ */
+ const partialUser = await Users.findOneAsync(selector);
+
+ if (partialUser?.presenter) {
+ modifier.$set.presenter = true;
+ }
+
+ try {
+ const { insertedId } = await Users.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Added user id=${userId} meeting=${meetingId}`);
+ } else {
+ Logger.info(`Upserted user id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Adding user to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/changeAway.js b/src/2.7.12/imports/api/users/server/modifiers/changeAway.js
new file mode 100644
index 00000000..c5ec077c
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/changeAway.js
@@ -0,0 +1,30 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import sendAwayStatusChatMsg from '../methods/sendAwayStatusChatMsg';
+
+export default async function changeAway(meetingId, userId, away) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ away,
+ awayTime: away ? (new Date()).getTime() : 0,
+ },
+ };
+
+ try {
+ // must be called before modifying the users collection, because it
+ // needs to be consulted in order to know the previous emoji
+ sendAwayStatusChatMsg(meetingId, userId, away);
+
+ const numberAffected = await Users.updateAsync(selector, modifier);
+ if (numberAffected) {
+ Logger.info(`Assigned away=${away} user id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Assigning away user: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/changePin.js b/src/2.7.12/imports/api/users/server/modifiers/changePin.js
new file mode 100644
index 00000000..14f3a26e
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/changePin.js
@@ -0,0 +1,25 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function changePin(meetingId, userId, pin, changedBy) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ pin,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Change pin=${pin} id=${userId} meeting=${meetingId} changedBy=${changedBy}`);
+ }
+ } catch (err) {
+ Logger.error(`Change pin error: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/changePresenter.js b/src/2.7.12/imports/api/users/server/modifiers/changePresenter.js
new file mode 100644
index 00000000..bd2253b8
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/changePresenter.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function changePresenter(presenter, userId, meetingId, changedBy) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ presenter,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Changed presenter=${presenter} id=${userId} meeting=${meetingId}`
+ + `${changedBy ? ` changedBy=${changedBy}` : ''}`);
+ }
+ } catch (err) {
+ Logger.error(`Change presenter error: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js b/src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js
new file mode 100644
index 00000000..543e1d7e
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/changeRaiseHand.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function changeRaiseHand(meetingId, userId, raiseHand) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ raiseHand,
+ raiseHandTime: raiseHand ? (new Date()).getTime() : 0,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Assigned raiseHand=${raiseHand} user id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Assigning raiseHand user: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/changeRole.js b/src/2.7.12/imports/api/users/server/modifiers/changeRole.js
new file mode 100644
index 00000000..6a175d89
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/changeRole.js
@@ -0,0 +1,28 @@
+import Logger from '/imports/startup/server/logger';
+import updateRole from '/imports/api/users-persistent-data/server/modifiers/updateRole';
+import Users from '/imports/api/users';
+
+export default async function changeRole(role, userId, meetingId, changedBy) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ role,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ await updateRole(userId, meetingId, role);
+ Logger.info(`Changed user role=${role} id=${userId} meeting=${meetingId}`
+ + `${changedBy ? ` changedBy=${changedBy}` : ''}`);
+ }
+ } catch (err) {
+ Logger.error(`Changed user role: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/clearUsers.js b/src/2.7.12/imports/api/users/server/modifiers/clearUsers.js
new file mode 100644
index 00000000..4025469a
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/clearUsers.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users/index';
+
+export default async function clearUsers(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await Users.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared Users (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error clearing Users (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await Users.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared Users (all)');
+ }
+ } catch (err) {
+ Logger.error(`Error clearing Users (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js b/src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js
new file mode 100644
index 00000000..ed9ad03e
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/clearUsersEmoji.js
@@ -0,0 +1,35 @@
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+
+export default function clearUsersEmoji(meetingId) {
+ const selector = {};
+
+ if (meetingId) {
+ selector.meetingId = meetingId;
+ }
+
+ try {
+ const numberAffected = Users.update(selector, {
+ $set: {
+ emojiTime: (new Date()).getTime(),
+ emoji: 'none',
+ awayTime: 0,
+ away: false,
+ raiseHandTime: 0,
+ raiseHand: false,
+ },
+ }, { multi: true });
+
+ if (numberAffected) {
+ if (meetingId) {
+ Logger.info(`Removed users emoji status (${meetingId})`);
+ } else {
+ Logger.info('Removed users emoji status (all)');
+ }
+ } else {
+ Logger.warn('Removing users emoji status nonaffected');
+ }
+ } catch (err) {
+ Logger.error(`Removing users emoji stautus: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js b/src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js
new file mode 100644
index 00000000..32ff22e6
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/createDummyUser.js
@@ -0,0 +1,34 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function createDummyUser(meetingId, userId, authToken) {
+ check(meetingId, String);
+ check(userId, String);
+ check(authToken, String);
+
+ const User = await Users.findOneAsync({ meetingId, userId });
+ if (User) {
+ throw new Meteor.Error('existing-user', 'Tried to create a dummy user for an existing user');
+ }
+
+ const doc = {
+ meetingId,
+ userId,
+ authToken,
+ clientType: 'HTML5',
+ validated: null,
+ left: false,
+ };
+
+ try {
+ const insertedId = await Users.insertAsync(doc);
+
+ if (insertedId) {
+ Logger.info(`Created dummy user id=${userId} token=${authToken} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Creating dummy user to collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/removeUser.js b/src/2.7.12/imports/api/users/server/modifiers/removeUser.js
new file mode 100644
index 00000000..c01b59bf
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/removeUser.js
@@ -0,0 +1,83 @@
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+import VideoStreams from '/imports/api/video-streams';
+import Logger from '/imports/startup/server/logger';
+import setloggedOutStatus from '/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus';
+import clearUserInfoForRequester from '/imports/api/users-infos/server/modifiers/clearUserInfoForRequester';
+import ClientConnections from '/imports/startup/server/ClientConnections';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import userEjected from '/imports/api/users/server/modifiers/userEjected';
+import clearVoiceUser from '/imports/api/voice-users/server/modifiers/clearVoiceUser';
+
+const disconnectUser = (meetingId, userId) => {
+ const sessionUserId = `${meetingId}--${userId}`;
+ ClientConnections.removeClientConnection(sessionUserId);
+
+ const serverSessions = Meteor.server.sessions;
+ const interable = serverSessions.values();
+
+ for (const session of interable) {
+ if (session.userId === sessionUserId) {
+ Logger.info(`Removed session id=${userId} meeting=${meetingId}`);
+ session.close();
+ }
+ }
+};
+
+export default async function removeUser(body, meetingId) {
+ const { intId: userId, reasonCode } = body;
+ check(meetingId, String);
+ check(userId, String);
+
+ try {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ // we don't want to fully process the redis message in frontend
+ // since the backend is supposed to update Mongo
+ if ((process.env.BBB_HTML5_ROLE !== 'frontend')) {
+ if (body.eject) {
+ await userEjected(meetingId, userId, reasonCode);
+ }
+
+ await setloggedOutStatus(userId, meetingId, true);
+ await VideoStreams.removeAsync({ meetingId, userId });
+
+ await clearUserInfoForRequester(meetingId, userId);
+
+ const currentUser = await UsersPersistentData.findOneAsync({ userId, meetingId });
+ const hasMessages = currentUser?.shouldPersist?.hasMessages?.public ||
+ currentUser?.shouldPersist?.hasMessages?.private;
+ const hasConnectionStatus = currentUser?.shouldPersist?.hasConnectionStatus;
+
+ if (!hasMessages && !hasConnectionStatus) {
+ await UsersPersistentData.removeAsync(selector);
+ }
+
+ await Users.removeAsync(selector);
+ await clearVoiceUser(meetingId, userId);
+ }
+
+ if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') {
+ // Wait for user removal and then kill user connections and sessions
+ const queryCurrentUser = Users.find(selector);
+ const countUser = await queryCurrentUser.countAsync();
+ if (countUser === 0) {
+ disconnectUser(meetingId, userId);
+ } else {
+ const queryUserObserver = queryCurrentUser.observeChanges({
+ removed() {
+ disconnectUser(meetingId, userId);
+ queryUserObserver.stop();
+ },
+ });
+ }
+ }
+
+ Logger.info(`Removed user id=${userId} meeting=${meetingId}`);
+ } catch (err) {
+ Logger.error(`Removing user from Users collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js b/src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js
new file mode 100644
index 00000000..72002ff8
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/setConnectionIdAndAuthToken.js
@@ -0,0 +1,37 @@
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setConnectionIdAndAuthToken(
+ meetingId,
+ userId,
+ connectionId,
+ authToken,
+) {
+ check(meetingId, String);
+ check(userId, String);
+ check(authToken, String);
+ check(connectionId, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ connectionId,
+ authToken,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updated connectionId and authToken user=${userId} connectionId=${connectionId} meeting=${meetingId} authToken=${authToken}`);
+ }
+ } catch (err) {
+ Logger.error(`Updating connectionId user=${userId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/setMobile.js b/src/2.7.12/imports/api/users/server/modifiers/setMobile.js
new file mode 100644
index 00000000..1634d110
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/setMobile.js
@@ -0,0 +1,25 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function setMobile(meetingId, userId) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ mobile: true,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Assigned mobile user id=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Assigning mobile user: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js b/src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js
new file mode 100644
index 00000000..8f926caf
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/setUserEffectiveConnectionType.js
@@ -0,0 +1,35 @@
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/server/logger';
+
+export default async function setUserEffectiveConnectionType(
+ meetingId,
+ userId,
+ effectiveConnectionType,
+) {
+ check(meetingId, String);
+ check(userId, String);
+ check(effectiveConnectionType, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ effectiveConnectionType: { $ne: effectiveConnectionType },
+ };
+
+ const modifier = {
+ $set: {
+ effectiveConnectionType,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updated user ${userId} effective connection to ${effectiveConnectionType} in meeting ${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Updating user ${userId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js b/src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js
new file mode 100644
index 00000000..d5e41dff
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/setUserExitReason.js
@@ -0,0 +1,25 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function setUserExitReason(meetingId, userId, reason) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ exitReason: reason,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Set exit reason userId=${userId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Setting user exit reason: ${err}`);
+ }
+};
diff --git a/src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js b/src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js
new file mode 100644
index 00000000..cad50ca9
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/updateSpeechLocale.js
@@ -0,0 +1,25 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function updateSpeechLocale(meetingId, userId, locale) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ speechLocale: locale,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updated speech locale=${locale} userId=${userId} meetingId=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Updating speech locale: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js b/src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js
new file mode 100644
index 00000000..aeda50b2
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/updateUserConnectionId.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function updateUserConnectionId(meetingId, userId, connectionId) {
+ check(meetingId, String);
+ check(userId, String);
+ check(connectionId, String);
+
+ const selector = { meetingId, userId };
+
+ const modifier = {
+ $set: {
+ currentConnectionId: connectionId,
+ connectionIdUpdateTime: new Date().getTime(),
+ },
+ };
+
+ const User = await Users.findOneAsync(selector);
+
+ if (User) {
+ try {
+ const updated = await Users.updateAsync(selector, modifier);
+
+ if (updated) {
+ Logger.info(`Updated connection user=${userId} connectionid=${connectionId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Updating user connection: ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/userEjected.js b/src/2.7.12/imports/api/users/server/modifiers/userEjected.js
new file mode 100644
index 00000000..c06de19e
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/userEjected.js
@@ -0,0 +1,33 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+import clearUserInfoForRequester from '/imports/api/users-infos/server/modifiers/clearUserInfoForRequester';
+
+export default async function userEjected(meetingId, userId, ejectedReason) {
+ check(meetingId, String);
+ check(userId, String);
+ check(ejectedReason, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ ejected: true,
+ ejectedReason,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ await clearUserInfoForRequester(meetingId, userId);
+ Logger.info(`Ejected user id=${userId} meeting=${meetingId} reason=${ejectedReason}`);
+ }
+ } catch (err) {
+ Logger.error(`Ejecting user from collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js b/src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js
new file mode 100644
index 00000000..f709d6d4
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/userInactivityInspect.js
@@ -0,0 +1,30 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function userInactivityInspect(userId, responseDelay) {
+ check(userId, String);
+ check(responseDelay, Match.Integer);
+
+ const selector = {
+ userId,
+ inactivityCheck: false,
+ };
+
+ const modifier = {
+ $set: {
+ inactivityCheck: true,
+ responseDelay,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Users.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Updated user ${userId} with inactivity inspect`);
+ }
+ } catch (err) {
+ Logger.error(`Inactivity check for user ${userId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js b/src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js
new file mode 100644
index 00000000..51e0f225
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/modifiers/userLeftFlagUpdated.js
@@ -0,0 +1,24 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+export default async function userLeftFlagUpdated(meetingId, userId, left) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ left,
+ },
+ };
+
+ try {
+ const numberAffected = await Users.updateAsync(selector, modifier);
+ if (numberAffected) {
+ Logger.info(`Updated user ${userId} with left flag as ${left} in meeting ${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Changed user role: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/users/server/publishers.js b/src/2.7.12/imports/api/users/server/publishers.js
new file mode 100644
index 00000000..562b40b9
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/publishers.js
@@ -0,0 +1,104 @@
+import Users from '/imports/api/users';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+import { extractCredentials, publicationSafeGuard } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+function currentUser() {
+ if (!this.userId) {
+ Mongo.Collection._publishCursor(Users.find({ meetingId: '' }), this, 'current-user');
+ return this.ready();
+ }
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const selector = {
+ meetingId,
+ userId: requesterUserId,
+ intId: { $exists: true },
+ };
+
+ const options = {
+ fields: {
+ user: false,
+ authToken: false, // Not asking for authToken from client side but also not exposing it
+ },
+ };
+ Mongo.Collection._publishCursor(Users.find(selector, options), this, 'current-user');
+ return this.ready();
+}
+
+function publishCurrentUser(...args) {
+ const boundUsers = currentUser.bind(this);
+ return boundUsers(...args);
+}
+
+Meteor.publish('current-user', publishCurrentUser);
+
+async function users() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing Users was requested by unauth connection ${this.connection.id}`);
+ return Users.find({ meetingId: '' });
+ }
+
+ if (!this.userId) {
+ return Users.find({ meetingId: '' });
+ }
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug(`Publishing Users for ${meetingId} ${userId}`);
+
+ const selector = {
+ $or: [
+ { meetingId },
+ ],
+ intId: { $exists: true },
+ left: false,
+ };
+
+ const User = await Users.findOneAsync({ userId, meetingId }, { fields: { role: 1 } });
+ if (!!User && User.role === ROLE_MODERATOR) {
+ selector.$or.push({
+ 'breakoutProps.isBreakoutUser': true,
+ 'breakoutProps.parentId': meetingId,
+ });
+ // Monitor this publication and stop it when user is not a moderator anymore
+ const comparisonFunc = async () => {
+ const user = await Users.findOneAsync({ userId, meetingId },
+ { fields: { role: 1, userId: 1 } });
+ const condition = user.role === ROLE_MODERATOR;
+ if (!condition) {
+ Logger.info(`conditions aren't filled anymore in publication ${this._name}:
+ user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`);
+ }
+
+ return condition;
+ };
+ publicationSafeGuard(comparisonFunc, this);
+ }
+
+ const options = {
+ fields: {
+ authToken: false,
+ },
+ };
+
+ Logger.debug('Publishing Users', { meetingId, userId });
+
+ return Users.find(selector, options);
+}
+
+function publish(...args) {
+ const boundUsers = users.bind(this);
+ return boundUsers(...args);
+}
+
+Meteor.publish('users', publish);
diff --git a/src/2.7.12/imports/api/users/server/store/pendingAuthentications.js b/src/2.7.12/imports/api/users/server/store/pendingAuthentications.js
new file mode 100644
index 00000000..ffc451ae
--- /dev/null
+++ b/src/2.7.12/imports/api/users/server/store/pendingAuthentications.js
@@ -0,0 +1,47 @@
+import Logger from '/imports/startup/server/logger';
+
+class PendingAuthentitcations {
+ constructor() {
+ Logger.debug('PendingAuthentitcations :: constructor');
+ this.store = [];
+ }
+
+ generateKey(meetingId, userId, authToken) {
+ // Protect against separator injection
+ meetingId = meetingId.replace(/ /g, '');
+ userId = userId.replace(/ /g, '');
+ authToken = authToken.replace(/ /g, '');
+
+ // Space separated key
+ return `${meetingId} ${userId} ${authToken}`;
+ }
+
+ add(meetingId, userId, authToken, methodInvocationObject) {
+ Logger.debug('PendingAuthentitcations :: add', { meetingId, userId, authToken });
+ this.store.push({
+ key: this.generateKey(meetingId, userId, authToken),
+ meetingId,
+ userId,
+ authToken,
+ methodInvocationObject,
+ });
+ }
+
+ take(meetingId, userId, authToken) {
+ const key = this.generateKey(meetingId, userId, authToken);
+ Logger.debug('PendingAuthentitcations :: take', {
+ key, meetingId, userId, authToken,
+ });
+
+ // find matches
+ const matches = this.store.filter(e => e.key === key);
+ // remove matches (if any)
+ if (matches.length) {
+ this.store = this.store.filter(e => e.key !== key);
+ }
+
+ // return matches
+ return matches;
+ }
+}
+export default new PendingAuthentitcations();
diff --git a/src/2.7.12/imports/api/video-streams/index.js b/src/2.7.12/imports/api/video-streams/index.js
new file mode 100644
index 00000000..b997043b
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/index.js
@@ -0,0 +1,16 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const VideoStreams = new Mongo.Collection('video-streams', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the video users:
+ // 2. meetingId
+
+ VideoStreams.createIndexAsync({ meetingId: 1 });
+}
+
+export default VideoStreams;
diff --git a/src/2.7.12/imports/api/video-streams/server/eventHandlers.js b/src/2.7.12/imports/api/video-streams/server/eventHandlers.js
new file mode 100644
index 00000000..8d5b932a
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/eventHandlers.js
@@ -0,0 +1,12 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleUserSharedHtml5Webcam from './handlers/userSharedHtml5Webcam';
+import handleUserUnsharedHtml5Webcam from './handlers/userUnsharedHtml5Webcam';
+import handleFloorChanged from './handlers/floorChanged';
+import handlePinnedChanged from './handlers/userPinChanged';
+import handleWebcamSync from './handlers/webcamSync';
+
+RedisPubSub.on('UserBroadcastCamStartedEvtMsg', handleUserSharedHtml5Webcam);
+RedisPubSub.on('UserBroadcastCamStoppedEvtMsg', handleUserUnsharedHtml5Webcam);
+RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChanged);
+RedisPubSub.on('UserPinStateChangedEvtMsg', handlePinnedChanged);
+RedisPubSub.on('SyncGetWebcamInfoRespMsg', handleWebcamSync);
diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js b/src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js
new file mode 100644
index 00000000..853b835e
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/handlers/floorChanged.js
@@ -0,0 +1,12 @@
+import { check } from 'meteor/check';
+import floorChanged from '../modifiers/floorChanged';
+
+export default async function handleFloorChanged({ header, body }, meetingId) {
+ const { intId, floor, lastFloorTime } = body;
+ check(meetingId, String);
+ check(intId, String);
+ check(floor, Boolean);
+ check(lastFloorTime, String);
+ const result = await floorChanged(meetingId, intId, floor, lastFloorTime);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js b/src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js
new file mode 100644
index 00000000..2b8d27a2
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/handlers/userPinChanged.js
@@ -0,0 +1,13 @@
+import { check } from 'meteor/check';
+import changePin from '../modifiers/changePin';
+
+export default async function userPinChanged({ body }, meetingId) {
+ const { userId, pin, changedBy } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(pin, Boolean);
+ check(changedBy, String);
+ const result = await changePin(meetingId, userId, pin, changedBy);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js b/src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js
new file mode 100644
index 00000000..1079efdd
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/handlers/userSharedHtml5Webcam.js
@@ -0,0 +1,16 @@
+import { check } from 'meteor/check';
+import sharedWebcam from '../modifiers/sharedWebcam';
+import { isValidStream } from '/imports/api/video-streams/server/helpers';
+
+export default async function handleUserSharedHtml5Webcam({ header, body }, meetingId) {
+ const { userId, stream } = body;
+
+ check(header, Object);
+ check(meetingId, String);
+ check(userId, String);
+ check(stream, String);
+
+ if (!isValidStream(stream)) return false;
+ const result = await sharedWebcam(meetingId, userId, stream);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js b/src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js
new file mode 100644
index 00000000..2af25054
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/handlers/userUnsharedHtml5Webcam.js
@@ -0,0 +1,16 @@
+import { check } from 'meteor/check';
+import unsharedWebcam from '../modifiers/unsharedWebcam';
+import { isValidStream } from '/imports/api/video-streams/server/helpers';
+
+export default async function handleUserUnsharedHtml5Webcam({ header, body }, meetingId) {
+ const { userId, stream } = body;
+
+ check(header, Object);
+ check(meetingId, String);
+ check(userId, String);
+ check(stream, String);
+
+ if (!isValidStream(stream)) return false;
+ const result = await unsharedWebcam(meetingId, userId, stream);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js b/src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js
new file mode 100644
index 00000000..cffa6b1b
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/handlers/webcamSync.js
@@ -0,0 +1,51 @@
+import { check } from 'meteor/check';
+import { addWebcamSync } from '../modifiers/sharedWebcam';
+import VideoStreams from '/imports/api/video-streams/';
+import updatedVideoStream from '../modifiers/updatedVideoStream';
+import unsharedWebcam from '../modifiers/unsharedWebcam';
+
+export default async function handleWebcamSync({ body }, meetingId) {
+ check(meetingId, String);
+ check(body, Object);
+ const { webcamListSync } = body;
+ check(webcamListSync, Array);
+
+ const streamsIds = webcamListSync.map((webcam) => webcam.stream);
+
+ const webcamStreams = VideoStreams.find({
+ meetingId,
+ stream: { $in: streamsIds },
+ }, {
+ fields: {
+ stream: 1,
+ },
+ }).fetchAsync();
+
+ const webcamStreamsToUpdate = webcamStreams.map((m) => m.stream);
+
+ await Promise.all(webcamListSync.map(async (webcam) => {
+ if (webcamStreamsToUpdate.indexOf(webcam.stream) >= 0) {
+ // stream already exist, then update
+ await updatedVideoStream(meetingId, webcam);
+ } else {
+ // stream doesn't exist yet, then add it
+ await addWebcamSync(meetingId, webcam);
+ }
+ }));
+
+ // removing extra video streams already existing in Mongo
+ const videoStreamsToRemove = await VideoStreams.find({
+ meetingId,
+ stream: { $nin: streamsIds },
+ }, {
+ fields: {
+ stream: 1,
+ userId: 1,
+ },
+ }).fetchAsynch();
+
+ await Promise.all(videoStreamsToRemove
+ .map(async (videoStream) => {
+ await unsharedWebcam(meetingId, videoStream.userId, videoStream.stream);
+ }));
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/helpers.js b/src/2.7.12/imports/api/video-streams/server/helpers.js
new file mode 100644
index 00000000..a1ec7cfa
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/helpers.js
@@ -0,0 +1,28 @@
+import Logger from '/imports/startup/server/logger';
+import Users from '/imports/api/users';
+
+const FLASH_STREAM_REGEX = /^([A-z0-9]+)-([A-z0-9]+)-([A-z0-9]+)(-recorded)?$/;
+const TOKEN = '_';
+
+const isValidStream = stream => !FLASH_STREAM_REGEX.test(stream);
+const getDeviceId = (stream) => {
+ const splitStream = stream.split(TOKEN);
+ if (splitStream.length === 3) return splitStream[2];
+ Logger.warn(`Could not get deviceId from stream=${stream}`);
+ return stream;
+};
+
+const getUserName = async (userId, meetingId) => {
+ const user = await Users.findOneAsync(
+ { userId, meetingId },
+ { fields: { name: 1 } },
+ );
+ if (user) return user.name;
+ return userId;
+};
+
+export {
+ isValidStream,
+ getDeviceId,
+ getUserName,
+};
diff --git a/src/2.7.12/imports/api/video-streams/server/index.js b/src/2.7.12/imports/api/video-streams/server/index.js
new file mode 100644
index 00000000..eff5e3f6
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publisher';
diff --git a/src/2.7.12/imports/api/video-streams/server/methods.js b/src/2.7.12/imports/api/video-streams/server/methods.js
new file mode 100644
index 00000000..a4e5900d
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/methods.js
@@ -0,0 +1,10 @@
+import { Meteor } from 'meteor/meteor';
+import userShareWebcam from './methods/userShareWebcam';
+import userUnshareWebcam from './methods/userUnshareWebcam';
+import ejectUserCameras from './methods/ejectUserCameras';
+
+Meteor.methods({
+ userShareWebcam,
+ userUnshareWebcam,
+ ejectUserCameras,
+});
diff --git a/src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js b/src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js
new file mode 100644
index 00000000..56f8c231
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/methods/ejectUserCameras.js
@@ -0,0 +1,28 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function ejectUserCameras(userId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'EjectUserCamerasCmdMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(userId, String);
+
+ Logger.info(`Requesting ejection of cameras: userToEject=${userId} requesterUserId=${requesterUserId}`);
+
+ const payload = {
+ userId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method ejectUserCameras ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js b/src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js
new file mode 100644
index 00000000..82098a5e
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/methods/userShareWebcam.js
@@ -0,0 +1,34 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function userShareWebcam(stream) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserBroadcastCamStartMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(stream, String);
+
+ Logger.info(`user sharing webcam: ${meetingId} ${requesterUserId}`);
+
+ // const actionName = 'joinVideo';
+ /* TODO throw an error if user has no permission to share webcam
+ if (!isAllowedTo(actionName, credentials)) {
+ throw new Meteor.Error('not-allowed', `You are not allowed to share webcam`);
+ } */
+
+ const payload = {
+ stream,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method userShareWebcam ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js b/src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js
new file mode 100644
index 00000000..afae00c4
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/methods/userUnshareWebcam.js
@@ -0,0 +1,34 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+
+export default function userUnshareWebcam(stream) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'UserBroadcastCamStopMsg';
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(stream, String);
+
+ Logger.info(`user unsharing webcam: ${meetingId} ${requesterUserId}`);
+
+ // const actionName = 'joinVideo';
+ /* TODO throw an error if user has no permission to share webcam
+ if (!isAllowedTo(actionName, credentials)) {
+ throw new Meteor.Error('not-allowed', `You are not allowed to share webcam`);
+ } */
+
+ const payload = {
+ stream,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method userUnshareWebcam ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js b/src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js
new file mode 100644
index 00000000..0143738b
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/modifiers/changePin.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+
+export default async function changePin(meetingId, userId, pin) {
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ pin,
+ },
+ };
+
+ try {
+ const numberAffected = await VideoStreams.updateAsync(selector, modifier, { multi: true });
+
+ if (numberAffected) {
+ Logger.info(`Updated user streams pinned userId=${userId} pinned=${pin}`);
+ }
+ } catch (error) {
+ return Logger.error(`Error updating stream pinned status: ${error}`);
+ }
+ return null;
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js b/src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js
new file mode 100644
index 00000000..7d806369
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/modifiers/clearVideoStreams.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+
+export default async function clearVideoStreams(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await VideoStreams.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared VideoStreams in (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing VideoStreams (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await VideoStreams.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared VideoStreams in all meetings');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing VideoStreams (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js b/src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js
new file mode 100644
index 00000000..47b2b9d1
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/modifiers/floorChanged.js
@@ -0,0 +1,37 @@
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+import { check } from 'meteor/check';
+
+export default async function floorChanged(
+ meetingId,
+ userId,
+ floor,
+ lastFloorTime,
+) {
+ check(meetingId, String);
+ check(userId, String);
+ check(floor, Boolean);
+ check(lastFloorTime, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ };
+
+ const modifier = {
+ $set: {
+ floor,
+ lastFloorTime: floor ? lastFloorTime : undefined,
+ },
+ };
+
+ try {
+ const numberAffected = await VideoStreams.updateAsync(selector, modifier, { multi: true });
+
+ if (numberAffected) {
+ Logger.info(`Updated user streams floor times userId=${userId} floor=${floor} lastFloorTime=${lastFloorTime}`);
+ }
+ } catch (error) {
+ return Logger.error(`Error updating stream floor status: ${error}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js b/src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js
new file mode 100644
index 00000000..4dabc98d
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/modifiers/sharedWebcam.js
@@ -0,0 +1,103 @@
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+import { check } from 'meteor/check';
+import {
+ getDeviceId,
+ getUserName,
+} from '/imports/api/video-streams/server/helpers';
+import VoiceUsers from '/imports/api/voice-users/';
+import Users from '/imports/api/users/';
+import { lowercaseTrim } from '/imports/utils/string-utils';
+
+const BASE_FLOOR_TIME = "0";
+
+export default async function sharedWebcam(meetingId, userId, stream) {
+ check(meetingId, String);
+ check(userId, String);
+ check(stream, String);
+
+ const deviceId = getDeviceId(stream);
+ const name = await getUserName(userId, meetingId);
+ const vu = await VoiceUsers.findOneAsync(
+ { meetingId, intId: userId },
+ { fields: { floor: 1, lastFloorTime: 1 }}
+ ) || {};
+ const u = await Users.findOneAsync(
+ { meetingId, intId: userId },
+ { fields: { pin: 1 } },
+ ) || {};
+ const floor = vu.floor || false;
+ const pin = u.pin || false;
+
+ const lastFloorTime = vu.lastFloorTime || BASE_FLOOR_TIME;
+
+ const selector = {
+ meetingId,
+ userId,
+ deviceId,
+ };
+
+ const modifier = {
+ $set: {
+ stream,
+ name,
+ sortName: lowercaseTrim(name),
+ lastFloorTime,
+ floor,
+ pin,
+ },
+ };
+
+ try {
+ const { insertedId } = await VideoStreams.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Updated stream=${stream} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error setting stream: ${err}`);
+ }
+}
+
+export async function addWebcamSync(meetingId, videoStream) {
+ check(videoStream, {
+ userId: String,
+ stream: String,
+ name: String,
+ pin: Boolean,
+ floor: Boolean,
+ lastFloorTime: String,
+ });
+
+ const {
+ stream, userId, name, pin, floor, lastFloorTime,
+ } = videoStream;
+
+ const deviceId = getDeviceId(stream);
+
+ const selector = {
+ meetingId,
+ userId,
+ deviceId,
+ };
+
+ const modifier = {
+ $set: {
+ stream,
+ name,
+ lastFloorTime,
+ floor,
+ pin,
+ },
+ };
+
+ try {
+ const { insertedId } = await VideoStreams.upsertAsync(selector, modifier);
+
+ if (insertedId) {
+ Logger.info(`Synced stream=${stream} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error setting sync stream: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js b/src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js
new file mode 100644
index 00000000..f4d42ba3
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/modifiers/unsharedWebcam.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+import { check } from 'meteor/check';
+import { getDeviceId } from '/imports/api/video-streams/server/helpers';
+
+export default async function unsharedWebcam(meetingId, userId, stream) {
+ check(meetingId, String);
+ check(userId, String);
+ check(stream, String);
+
+ const deviceId = getDeviceId(stream);
+
+ const selector = {
+ meetingId,
+ userId,
+ deviceId,
+ };
+
+ try {
+ await VideoStreams.removeAsync(selector);
+
+ Logger.info(`Removed stream=${stream} meeting=${meetingId}`);
+ } catch (err) {
+ Logger.error(`Error removing stream: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js b/src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js
new file mode 100644
index 00000000..3e994b5d
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/modifiers/updatedVideoStream.js
@@ -0,0 +1,40 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+import flat from 'flat';
+
+export default async function updateVideoStream(meetingId, videoStream) {
+ check(meetingId, String);
+ check(videoStream, {
+ userId: String,
+ stream: String,
+ name: String,
+ pin: Boolean,
+ floor: Boolean,
+ lastFloorTime: String,
+ });
+
+ const { userId, stream } = videoStream;
+
+ const selector = {
+ meetingId,
+ stream,
+ userId,
+ };
+
+ const modifier = {
+ $set: Object.assign(
+ flat(videoStream),
+ ),
+ };
+
+ try {
+ const numberAffected = await VideoStreams.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug(`Update videoStream ${stream} for user ${userId} in ${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error update videoStream ${stream} for user=${userId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/video-streams/server/publisher.js b/src/2.7.12/imports/api/video-streams/server/publisher.js
new file mode 100644
index 00000000..de5f5aad
--- /dev/null
+++ b/src/2.7.12/imports/api/video-streams/server/publisher.js
@@ -0,0 +1,31 @@
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import VideoStreams from '/imports/api/video-streams';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function videoStreams() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing VideoStreams was requested by unauth connection ${this.connection.id}`);
+ return VideoStreams.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing VideoStreams', { meetingId, userId });
+
+ const selector = {
+ meetingId,
+ };
+
+ return VideoStreams.find(selector);
+}
+
+function publish(...args) {
+ const boundVideoStreams = videoStreams.bind(this);
+ return boundVideoStreams(...args);
+}
+
+Meteor.publish('video-streams', publish);
diff --git a/src/2.7.12/imports/api/voice-call-states/index.js b/src/2.7.12/imports/api/voice-call-states/index.js
new file mode 100644
index 00000000..d3481a86
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/index.js
@@ -0,0 +1,13 @@
+import { Meteor } from 'meteor/meteor';
+
+const VoiceCallStates = new Mongo.Collection('voiceCallStates');
+
+if (Meteor.isServer) {
+ // types of queries for the voice users:
+ // 1. intId
+ // 2. meetingId, intId
+
+ VoiceCallStates.createIndexAsync({ meetingId: 1, userId: 1 });
+}
+
+export default VoiceCallStates;
diff --git a/src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js b/src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js
new file mode 100644
index 00000000..4d7a7397
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/server/eventHandlers.js
@@ -0,0 +1,4 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import handleVoiceCallStateEvent from './handlers/voiceCallStateEvent';
+
+RedisPubSub.on('VoiceCallStateEvtMsg', handleVoiceCallStateEvent);
diff --git a/src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js b/src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js
new file mode 100644
index 00000000..50d1d195
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/server/handlers/voiceCallStateEvent.js
@@ -0,0 +1,50 @@
+import { check } from 'meteor/check';
+import VoiceCallState from '/imports/api/voice-call-states';
+import Logger from '/imports/startup/server/logger';
+
+// "CALL_STARTED", "IN_ECHO_TEST", "IN_CONFERENCE", "CALL_ENDED"
+
+export default async function handleVoiceCallStateEvent({ body }, meetingId) {
+ const {
+ voiceConf,
+ clientSession,
+ userId,
+ callerName,
+ callState,
+ } = body;
+
+ check(meetingId, String);
+ check(voiceConf, String);
+ check(clientSession, String);
+ check(userId, String);
+ check(callerName, String);
+ check(callState, String);
+
+ const selector = {
+ meetingId,
+ userId,
+ clientSession,
+ };
+
+ const modifier = {
+ $set: {
+ meetingId,
+ userId,
+ voiceConf,
+ clientSession,
+ callState,
+ },
+ };
+
+ try {
+ const { numberAffected } = await VoiceCallState.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug('Update voice call', {
+ state: userId, meetingId, clientSession, callState,
+ });
+ }
+ } catch (err) {
+ Logger.error(`Update voice call state=${userId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-call-states/server/index.js b/src/2.7.12/imports/api/voice-call-states/server/index.js
new file mode 100644
index 00000000..f993f38e
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/server/index.js
@@ -0,0 +1,2 @@
+import './eventHandlers';
+import './publishers';
diff --git a/src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js b/src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js
new file mode 100644
index 00000000..679d5d07
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import VoiceCallStates from '/imports/api/voice-call-states';
+
+export default async function clearVoiceCallStates(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await VoiceCallStates.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared VoiceCallStates in (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.info(`Error on clearing VoiceCallStates in (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await VoiceCallStates.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared VoiceCallStates in all meetings');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing VoiceCallStates in all meetings. ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-call-states/server/publishers.js b/src/2.7.12/imports/api/voice-call-states/server/publishers.js
new file mode 100644
index 00000000..eedd18c2
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/server/publishers.js
@@ -0,0 +1,27 @@
+import VoiceCallStates from '/imports/api/voice-call-states';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function voiceCallStates() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing VoiceCallStates was requested by unauth connection ${this.connection.id}`);
+ return VoiceCallStates.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing Voice Call States', { meetingId, userId });
+
+ return VoiceCallStates.find({ meetingId, userId });
+}
+
+function publish(...args) {
+ const boundVoiceCallStates = voiceCallStates.bind(this);
+ return boundVoiceCallStates(...args);
+}
+
+Meteor.publish('voice-call-states', publish);
diff --git a/src/2.7.12/imports/api/voice-call-states/utils/callStates.js b/src/2.7.12/imports/api/voice-call-states/utils/callStates.js
new file mode 100644
index 00000000..332ad303
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-call-states/utils/callStates.js
@@ -0,0 +1,8 @@
+const CallStateOptions = {
+ CALL_STARTED: 'CALL_STARTED',
+ IN_ECHO_TEST: 'IN_ECHO_TEST',
+ IN_CONFERENCE: 'IN_CONFERENCE',
+ CALL_ENDED: 'CALL_ENDED',
+};
+
+export default CallStateOptions;
diff --git a/src/2.7.12/imports/api/voice-users/index.js b/src/2.7.12/imports/api/voice-users/index.js
new file mode 100644
index 00000000..2bfa1611
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/index.js
@@ -0,0 +1,18 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const VoiceUsers = new Mongo.Collection('voiceUsers', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the voice users:
+ // 1. intId
+ // 2. meetingId, intId
+
+ VoiceUsers.createIndexAsync({ intId: 1 });
+ VoiceUsers.createIndexAsync({ meetingId: 1, intId: 1 });
+}
+
+export default VoiceUsers;
diff --git a/src/2.7.12/imports/api/voice-users/server/eventHandlers.js b/src/2.7.12/imports/api/voice-users/server/eventHandlers.js
new file mode 100644
index 00000000..1155ace8
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/eventHandlers.js
@@ -0,0 +1,19 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers';
+import handleJoinVoiceUser from './handlers/joinVoiceUser';
+import handleLeftVoiceUser from './handlers/leftVoiceUser';
+import handleTalkingVoiceUser from './handlers/talkingVoiceUser';
+import handleMutedVoiceUser from './handlers/mutedVoiceUser';
+import handleGetVoiceUsers from './handlers/getVoiceUsers';
+import handleVoiceUsers from './handlers/voiceUsers';
+import handleMeetingMuted from './handlers/meetingMuted';
+import handleFloorChange from './handlers/floorChanged';
+
+RedisPubSub.on('UserLeftVoiceConfToClientEvtMsg', handleLeftVoiceUser);
+RedisPubSub.on('UserJoinedVoiceConfToClientEvtMsg', handleJoinVoiceUser);
+RedisPubSub.on('UserTalkingVoiceEvtMsg', handleTalkingVoiceUser);
+RedisPubSub.on('UserMutedVoiceEvtMsg', handleMutedVoiceUser);
+RedisPubSub.on('GetVoiceUsersMeetingRespMsg', processForHTML5ServerOnly(handleGetVoiceUsers));
+RedisPubSub.on('SyncGetVoiceUsersRespMsg', handleVoiceUsers);
+RedisPubSub.on('MeetingMutedEvtMsg', handleMeetingMuted);
+RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChange);
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js b/src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js
new file mode 100644
index 00000000..88326cd1
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/floorChanged.js
@@ -0,0 +1,10 @@
+import { check } from 'meteor/check';
+import updateVoiceUser from '../modifiers/updateVoiceUser';
+
+export default function handleFloorChange({ header, body }, meetingId) {
+ const voiceUser = body;
+
+ check(meetingId, String);
+
+ return updateVoiceUser(meetingId, voiceUser);
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js b/src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js
new file mode 100644
index 00000000..d121d76b
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/getVoiceUsers.js
@@ -0,0 +1,64 @@
+import { check } from 'meteor/check';
+import VoiceUsers from '/imports/api/voice-users/';
+import Meetings from '/imports/api/meetings';
+import addVoiceUser from '../modifiers/addVoiceUser';
+import removeVoiceUser from '../modifiers/removeVoiceUser';
+import updateVoiceUser from '../modifiers/updateVoiceUser';
+
+export default async function handleGetVoiceUsers({ body }, meetingId) {
+ const { users } = body;
+
+ check(meetingId, String);
+ check(users, Array);
+
+ const meeting = await Meetings.findOneAsync({ meetingId }, { fields: { 'voiceProp.voiceConf': 1 } });
+ const usersIds = users.map((m) => m.intId);
+
+ const voiceUsersFetch = await VoiceUsers.find({
+ meetingId,
+ intId: { $in: usersIds },
+ }, { fields: { intId: 1 } }).fetchAsync();
+
+ const voiceUsersIdsToUpdate = voiceUsersFetch.map((m) => m.intId);
+
+ await Promise.all(users.map(async (user) => {
+ if (voiceUsersIdsToUpdate.indexOf(user.intId) >= 0) {
+ // user already exist, then update
+ await updateVoiceUser(meetingId, {
+ intId: user.intId,
+ voiceUserId: user.voiceUserId,
+ talking: user.talking,
+ muted: user.muted,
+ voiceConf: meeting.voiceProp.voiceConf,
+ joined: true,
+ });
+ } else {
+ // user doesn't exist yet, then add it
+ await addVoiceUser(meetingId, {
+ voiceUserId: user.voiceUserId,
+ intId: user.intId,
+ callerName: user.callerName,
+ callerNum: user.callerNum,
+ muted: user.muted,
+ color: user.color,
+ talking: user.talking,
+ callingWith: user.callingWith,
+ listenOnly: user.listenOnly,
+ voiceConf: meeting.voiceProp.voiceConf,
+ joined: true,
+ });
+ }
+ }));
+ // removing extra users already existing in Mongo
+ const voiceUsersToRemove = await VoiceUsers.find({
+ meetingId,
+ intId: { $nin: usersIds },
+ }).fetchAsync();
+ await Promise.all(voiceUsersToRemove.map(async (user) => {
+ await removeVoiceUser(meetingId, {
+ voiceConf: meeting.voiceProp.voiceConf,
+ voiceUserId: user.voiceUserId,
+ intId: user.intId,
+ });
+ }));
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js
new file mode 100644
index 00000000..2781e3d2
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/joinVoiceUser.js
@@ -0,0 +1,41 @@
+import { check } from 'meteor/check';
+import Users from '/imports/api/users';
+import addDialInUser from '/imports/api/users/server/modifiers/addDialInUser';
+import addVoiceUser from '../modifiers/addVoiceUser';
+
+export default async function handleJoinVoiceUser({ body }, meetingId) {
+ const voiceUser = body;
+ voiceUser.joined = true;
+
+ check(meetingId, String);
+ check(voiceUser, {
+ voiceConf: String,
+ intId: String,
+ voiceUserId: String,
+ callerName: String,
+ callerNum: String,
+ color: String,
+ muted: Boolean,
+ talking: Boolean,
+ callingWith: String,
+ listenOnly: Boolean,
+ joined: Boolean,
+ });
+
+ const {
+ intId,
+ } = voiceUser;
+
+ const User = await Users.findOneAsync({
+ meetingId,
+ intId,
+ });
+
+ if (!User) {
+ /* voice-only user - called into the conference */
+ await addDialInUser(meetingId, voiceUser);
+ }
+
+ const result = await addVoiceUser(meetingId, voiceUser);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js
new file mode 100644
index 00000000..fadfab1a
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/leftVoiceUser.js
@@ -0,0 +1,32 @@
+import { check } from 'meteor/check';
+
+import removeVoiceUser from '/imports/api/voice-users/server/modifiers/removeVoiceUser';
+import removeUser from '/imports/api/users/server/modifiers/removeUser';
+import Users from '/imports/api/users';
+
+export default async function handleVoiceUpdate({ body }, meetingId) {
+ const voiceUser = body;
+
+ check(meetingId, String);
+ check(voiceUser, {
+ voiceConf: String,
+ intId: String,
+ voiceUserId: String,
+ });
+
+ const {
+ intId,
+ voiceUserId,
+ } = voiceUser;
+
+ const isDialInUser = async (userId, meetingID) => {
+ const user = await Users.findOneAsync({ meetingId: meetingID, userId, clientType: 'dial-in-user' });
+ return !!user;
+ };
+
+ // if the user is dial-in, leaving voice also means leaving userlist
+ if (await isDialInUser(voiceUserId, meetingId)) removeUser(voiceUser, meetingId);
+
+ const result = await removeVoiceUser(meetingId, voiceUser);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js b/src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js
new file mode 100644
index 00000000..a508d148
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/meetingMuted.js
@@ -0,0 +1,5 @@
+import changeMuteMeeting from '../modifiers/changeMuteMeeting';
+
+export default async function handleMeetingMuted({ body }, meetingId) {
+ await changeMuteMeeting(meetingId, body);
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js
new file mode 100644
index 00000000..01e9689a
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/mutedVoiceUser.js
@@ -0,0 +1,17 @@
+import { check } from 'meteor/check';
+
+import updateVoiceUser from '../modifiers/updateVoiceUser';
+
+export default async function handleVoiceUpdate({ body }, meetingId) {
+ const voiceUser = body;
+
+ check(meetingId, String);
+
+ // If a person is muted we have to force them to not talking
+ if (voiceUser.muted) {
+ voiceUser.talking = false;
+ }
+
+ const result = await updateVoiceUser(meetingId, voiceUser);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js
new file mode 100644
index 00000000..79413ea7
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/talkingVoiceUser.js
@@ -0,0 +1,12 @@
+import { check } from 'meteor/check';
+
+import updateVoiceUser from '../modifiers/updateVoiceUser';
+
+export default async function handleVoiceUpdate({ body }, meetingId) {
+ const voiceUser = body;
+
+ check(meetingId, String);
+
+ const result = await updateVoiceUser(meetingId, voiceUser);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js b/src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js
new file mode 100644
index 00000000..e8c83621
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/handlers/voiceUsers.js
@@ -0,0 +1,66 @@
+import VoiceUsers from '/imports/api/voice-users/';
+import Meetings from '/imports/api/meetings';
+import addDialInUser from '/imports/api/users/server/modifiers/addDialInUser';
+import removeVoiceUser from '../modifiers/removeVoiceUser';
+import updateVoiceUser from '../modifiers/updateVoiceUser';
+import addVoiceUser from '../modifiers/addVoiceUser';
+
+export default async function handleVoiceUsers({ header, body }) {
+ const { voiceUsers } = body;
+ const { meetingId } = header;
+
+ const meeting = await Meetings.findOneAsync({ meetingId }, { fields: { 'voiceProp.voiceConf': 1 } });
+ const usersIds = voiceUsers.map((m) => m.intId);
+
+ const voiceUsersFetch = await VoiceUsers.find({
+ meetingId,
+ intId: { $in: usersIds },
+ }, { fields: { intId: 1 } }).fetchAsync();
+
+ const voiceUsersIdsToUpdate = voiceUsersFetch.map((m) => m.intId);
+
+ await Promise.all(voiceUsers.map(async (voice) => {
+ if (voiceUsersIdsToUpdate.indexOf(voice.intId) >= 0) {
+ // user already exist, then update
+ await updateVoiceUser(meetingId, {
+ intId: voice.intId,
+ voiceUserId: voice.voiceUserId,
+ talking: voice.talking,
+ muted: voice.muted,
+ voiceConf: meeting.voiceProp.voiceConf,
+ joined: true,
+ });
+ } else {
+ // user doesn't exist yet, then add it
+ await addVoiceUser(meetingId, {
+ voiceUserId: voice.voiceUserId,
+ intId: voice.intId,
+ callerName: voice.callerName,
+ callerNum: voice.callerNum,
+ color: voice.color,
+ muted: voice.muted,
+ talking: voice.talking,
+ callingWith: voice.callingWith,
+ listenOnly: voice.listenOnly,
+ voiceConf: meeting.voiceProp.voiceConf,
+ joined: true,
+ });
+
+ await addDialInUser(meetingId, voice);
+ }
+ }));
+
+ // removing extra users already existing in Mongo
+ const voiceUsersToRemove = await VoiceUsers.find({
+ meetingId,
+ intId: { $nin: usersIds },
+ }, { fields: { voiceUserId: 1, intId: 1 } }).fetchAsync();
+
+ await Promise.all(voiceUsersToRemove.map(async (user) => {
+ await removeVoiceUser(meetingId, {
+ voiceConf: meeting.voiceProp.voiceConf,
+ voiceUserId: user.voiceUserId,
+ intId: user.intId,
+ });
+ }));
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/index.js b/src/2.7.12/imports/api/voice-users/server/index.js
new file mode 100644
index 00000000..af6a7345
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './publishers';
+import './methods';
diff --git a/src/2.7.12/imports/api/voice-users/server/methods.js b/src/2.7.12/imports/api/voice-users/server/methods.js
new file mode 100644
index 00000000..6e40c349
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/methods.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import muteToggle from './methods/muteToggle';
+import muteAllToggle from './methods/muteAllToggle';
+import muteAllExceptPresenterToggle from './methods/muteAllExceptPresenterToggle';
+import ejectUserFromVoice from './methods/ejectUserFromVoice';
+
+Meteor.methods({
+ toggleVoice: muteToggle,
+ muteAllUsers: muteAllToggle,
+ muteAllExceptPresenter: muteAllExceptPresenterToggle,
+ ejectUserFromVoice,
+});
diff --git a/src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js b/src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js
new file mode 100644
index 00000000..5705adf9
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/methods/ejectUserFromVoice.js
@@ -0,0 +1,30 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import RedisPubSub from '/imports/startup/server/redis';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function ejectUserFromVoice(userId, banUser) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'EjectUserFromVoiceCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+ check(userId, String);
+ check(banUser, Boolean);
+
+ const payload = {
+ userId,
+ ejectedBy: requesterUserId,
+ banUser,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method ejectUserFromVoice ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js b/src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js
new file mode 100644
index 00000000..c0cf474c
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/methods/muteAllExceptPresenterToggle.js
@@ -0,0 +1,31 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import Meetings from '/imports/api/meetings';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default async function muteAllExceptPresenterToggle() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'MuteAllExceptPresentersCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const meeting = await Meetings.findOneAsync({ meetingId });
+ const toggleMeetingMuted = !meeting.voiceProp.muteOnStart;
+
+ const payload = {
+ mutedBy: requesterUserId,
+ mute: toggleMeetingMuted,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method muteAllExceptPresenterToggle ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js b/src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js
new file mode 100644
index 00000000..bb65d3b5
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/methods/muteAllToggle.js
@@ -0,0 +1,31 @@
+import { Meteor } from 'meteor/meteor';
+import RedisPubSub from '/imports/startup/server/redis';
+import Meetings from '/imports/api/meetings';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default async function muteAllToggle() {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'MuteMeetingCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const meeting = await Meetings.findOneAsync({ meetingId });
+ const toggleMeetingMuted = !meeting.voiceProp.muteOnStart;
+
+ const payload = {
+ mutedBy: requesterUserId,
+ mute: toggleMeetingMuted,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method muteAllToggle ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js b/src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js
new file mode 100644
index 00000000..7c8101a0
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/methods/muteToggle.js
@@ -0,0 +1,66 @@
+import { Meteor } from 'meteor/meteor';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import RedisPubSub from '/imports/startup/server/redis';
+import Users from '/imports/api/users';
+import VoiceUsers from '/imports/api/voice-users';
+import Meetings from '/imports/api/meetings';
+import Logger from '/imports/startup/server/logger';
+import { check } from 'meteor/check';
+
+export default async function muteToggle(uId, toggle) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'MuteUserCmdMsg';
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const userToMute = uId || requesterUserId;
+
+ const requester = await Users.findOneAsync({
+ meetingId,
+ userId: requesterUserId,
+ });
+
+ const voiceUser = await VoiceUsers.findOneAsync({
+ intId: userToMute,
+ meetingId,
+ });
+
+ if (!requester || !voiceUser) return;
+
+ const { listenOnly, muted } = voiceUser;
+ if (listenOnly) return;
+
+ // if allowModsToUnmuteUsers is false, users will be kicked out for attempting to unmute others
+ if (requesterUserId !== userToMute && muted) {
+ const meeting = await Meetings.findOneAsync({ meetingId },
+ { fields: { 'usersProp.allowModsToUnmuteUsers': 1 } });
+ if (meeting.usersProp && !meeting.usersProp.allowModsToUnmuteUsers) {
+ Logger.warn(`Attempted unmuting by another user meetingId:${meetingId} requester: ${requesterUserId} userId: ${userToMute}`);
+ return;
+ }
+ }
+
+ let _muted;
+
+ if ((toggle === undefined) || (toggle === null)) {
+ _muted = !muted;
+ } else {
+ _muted = !!toggle;
+ }
+
+ const payload = {
+ userId: userToMute,
+ mutedBy: requesterUserId,
+ mute: _muted,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method muteToggle ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js
new file mode 100644
index 00000000..2f5dc626
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/modifiers/addVoiceUser.js
@@ -0,0 +1,46 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import VoiceUsers from '/imports/api/voice-users';
+import flat from 'flat';
+
+export default async function addVoiceUser(meetingId, voiceUser) {
+ check(meetingId, String);
+ check(voiceUser, {
+ voiceUserId: String,
+ intId: String,
+ callerName: String,
+ callerNum: String,
+ color: String,
+ muted: Boolean,
+ talking: Boolean,
+ callingWith: String,
+ listenOnly: Boolean,
+ voiceConf: String,
+ joined: Boolean, // This is a HTML5 only param.
+ });
+
+ const { intId, talking } = voiceUser;
+
+ const selector = {
+ meetingId,
+ intId,
+ };
+
+ const modifier = {
+ $set: {
+ meetingId,
+ spoke: talking,
+ ...flat(voiceUser),
+ },
+ };
+
+ try {
+ const { numberAffected } = await VoiceUsers.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Add voice user=${intId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Add voice user=${intId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js b/src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js
new file mode 100644
index 00000000..f67caef1
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/modifiers/changeMuteMeeting.js
@@ -0,0 +1,31 @@
+import Meetings from '/imports/api/meetings';
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+
+export default async function changeMuteMeeting(meetingId, payload) {
+ check(meetingId, String);
+ check(payload, {
+ muted: Boolean,
+ mutedBy: String,
+ });
+
+ const selector = {
+ meetingId,
+ };
+
+ const modifier = {
+ $set: {
+ 'voiceProp.muteOnStart': payload.muted,
+ },
+ };
+
+ try {
+ const { numberAffected } = await Meetings.upsertAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Changed meeting mute status meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Changing meeting mute status meeting={${meetingId}} ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js
new file mode 100644
index 00000000..b34db7f2
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUser.js
@@ -0,0 +1,20 @@
+import Logger from '/imports/startup/server/logger';
+import VoiceUsers from '/imports/api/voice-users';
+
+export default async function clearVoiceUser(meetingId, intId) {
+ try {
+ check(meetingId, String);
+ check(intId, String);
+
+ const numberAffected = await VoiceUsers.removeAsync({ meetingId, intId });
+
+ if (numberAffected) {
+ Logger.info(`Remove voiceUser=${intId} meeting=${meetingId} (clear)`);
+ }
+
+ return numberAffected;
+ } catch (error) {
+ Logger.error(`Error on clearing voiceUser=${intId} meeting=${meetingId}. ${error}`);
+ return 0;
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js
new file mode 100644
index 00000000..9c0c28d7
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/modifiers/clearVoiceUsers.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import VoiceUsers from '/imports/api/voice-users';
+
+export default async function clearVoiceUser(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = await VoiceUsers.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared VoiceUsers in (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing VoiceUsers in ${meetingId}. ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await VoiceUsers.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared VoiceUsers in all meetings');
+ }
+ } catch (err) {
+ Logger.error(`Error on clearing VoiceUsers. ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js
new file mode 100644
index 00000000..bb5f30e9
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/modifiers/removeVoiceUser.js
@@ -0,0 +1,41 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import VoiceUsers from '/imports/api/voice-users';
+import { clearSpokeTimeout } from '/imports/api/common/server/helpers';
+
+export default async function removeVoiceUser(meetingId, voiceUser) {
+ check(meetingId, String);
+ check(voiceUser, {
+ voiceConf: String,
+ voiceUserId: String,
+ intId: String,
+ });
+
+ const { intId } = voiceUser;
+
+ const selector = {
+ meetingId,
+ intId,
+ };
+
+ const modifier = {
+ $set: {
+ muted: false,
+ talking: false,
+ listenOnly: false,
+ joined: false,
+ spoke: false,
+ },
+ };
+
+ try {
+ await clearSpokeTimeout(meetingId, intId);
+ const numberAffected = await VoiceUsers.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.info(`Remove voiceUser=${intId} meeting=${meetingId}`);
+ }
+ } catch (err) {
+ Logger.error(`Remove voiceUser=${intId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js b/src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js
new file mode 100644
index 00000000..4d0b6548
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/modifiers/updateVoiceUser.js
@@ -0,0 +1,88 @@
+import { Match, check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import VoiceUsers from '/imports/api/voice-users';
+import flat from 'flat';
+import { spokeTimeoutHandles, clearSpokeTimeout } from '/imports/api/common/server/helpers';
+
+const TALKING_TIMEOUT = 6000;
+
+export default async function updateVoiceUser(meetingId, voiceUser) {
+ check(meetingId, String);
+ check(voiceUser, {
+ intId: String,
+ voiceUserId: String,
+ talking: Match.Maybe(Boolean),
+ muted: Match.Maybe(Boolean),
+ voiceConf: String,
+ joined: Match.Maybe(Boolean),
+ floor: Match.Maybe(Boolean),
+ lastFloorTime: Match.Maybe(String),
+ });
+
+ const { intId } = voiceUser;
+
+ const selector = {
+ meetingId,
+ intId,
+ };
+
+ const modifier = {
+ $set: Object.assign(
+ flat(voiceUser),
+ ),
+ };
+
+ if (voiceUser.talking) {
+ const user = await VoiceUsers.findOneAsync({ meetingId, intId }, {
+ fields: {
+ startTime: 1,
+ },
+ });
+
+ if (user && !user.startTime) modifier.$set.startTime = Date.now();
+ modifier.$set.spoke = true;
+ modifier.$set.endTime = null;
+ clearSpokeTimeout(meetingId, intId);
+ }
+
+ if (!voiceUser.talking) {
+ const timeoutHandle = Meteor.setTimeout(async () => {
+ const user = await VoiceUsers.findOneAsync({ meetingId, intId }, {
+ fields: {
+ endTime: 1,
+ talking: 1,
+ },
+ });
+
+ if (user) {
+ const { endTime, talking } = user;
+ const spokeDelay = ((Date.now() - endTime) < TALKING_TIMEOUT);
+ if (talking || spokeDelay) return;
+ modifier.$set.spoke = false;
+ modifier.$set.startTime = null;
+ try {
+ const numberAffected = await VoiceUsers.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug('Update voiceUser', { voiceUser: intId, meetingId });
+ }
+ } catch (err) {
+ Logger.error(`Update voiceUser=${intId}: ${err}`);
+ }
+ }
+ }, TALKING_TIMEOUT);
+
+ spokeTimeoutHandles[`${meetingId}-${intId}`] = timeoutHandle;
+ modifier.$set.endTime = Date.now();
+ }
+
+ try {
+ const numberAffected = await VoiceUsers.updateAsync(selector, modifier);
+
+ if (numberAffected) {
+ Logger.debug('Update voiceUser', { voiceUser: intId, meetingId });
+ }
+ } catch (err) {
+ Logger.error(`Update voiceUser=${intId}: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/voice-users/server/publishers.js b/src/2.7.12/imports/api/voice-users/server/publishers.js
new file mode 100644
index 00000000..942cf229
--- /dev/null
+++ b/src/2.7.12/imports/api/voice-users/server/publishers.js
@@ -0,0 +1,26 @@
+import VoiceUsers from '/imports/api/voice-users';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+async function voiceUser() {
+ const tokenValidation = await AuthTokenValidation
+ .findOneAsync({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing VoiceUsers was requested by unauth connection ${this.connection.id}`);
+ return VoiceUsers.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId: requesterUserId } = tokenValidation;
+ Logger.debug('Publishing Voice User', { meetingId, requesterUserId });
+
+ return VoiceUsers.find({ meetingId });
+}
+
+function publish(...args) {
+ const boundVoiceUser = voiceUser.bind(this);
+ return boundVoiceUser(...args);
+}
+
+Meteor.publish('voiceUsers', publish);
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/index.js b/src/2.7.12/imports/api/whiteboard-multi-user/index.js
new file mode 100644
index 00000000..1ab39312
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/index.js
@@ -0,0 +1,16 @@
+import { Meteor } from 'meteor/meteor';
+
+const collectionOptions = Meteor.isClient ? {
+ connection: null,
+} : {};
+
+const WhiteboardMultiUser = new Mongo.Collection('whiteboard-multi-user', collectionOptions);
+
+if (Meteor.isServer) {
+ // types of queries for the whiteboard-multi-user:
+ // 1. meetingId
+
+ WhiteboardMultiUser._ensureIndex({ meetingId: 1 });
+}
+
+export default WhiteboardMultiUser;
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js
new file mode 100644
index 00000000..7aca7caa
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/eventHandlers.js
@@ -0,0 +1,7 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers';
+import handleGetWhiteboardAccess from './handlers/modifyWhiteboardAccess';
+
+RedisPubSub.on('GetWhiteboardAccessRespMsg', processForHTML5ServerOnly(handleGetWhiteboardAccess));
+RedisPubSub.on('SyncGetWhiteboardAccessRespMsg', handleGetWhiteboardAccess);
+RedisPubSub.on('ModifyWhiteboardAccessEvtMsg', handleGetWhiteboardAccess);
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js
new file mode 100644
index 00000000..5d9b93fe
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/handlers/modifyWhiteboardAccess.js
@@ -0,0 +1,12 @@
+import { check } from 'meteor/check';
+import modifyWhiteboardAccess from '../modifiers/modifyWhiteboardAccess';
+
+export default async function handleModifyWhiteboardAccess({ body }, meetingId) {
+ const { multiUser, whiteboardId } = body;
+
+ check(multiUser, Array);
+ check(whiteboardId, String);
+ check(meetingId, String);
+ const result = await modifyWhiteboardAccess(meetingId, whiteboardId, multiUser);
+ return result;
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js
new file mode 100644
index 00000000..52e6be82
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/helpers.js
@@ -0,0 +1,31 @@
+import Users from '/imports/api/users';
+import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/';
+
+const getMultiUser = async (meetingId, whiteboardId) => {
+ const data = await WhiteboardMultiUser.findOneAsync(
+ {
+ meetingId,
+ whiteboardId,
+ }, { fields: { multiUser: 1 } },
+ );
+
+ if (!data || !data.multiUser || !Array.isArray(data.multiUser)) return [];
+
+ return data.multiUser;
+};
+
+const getUsers = async (meetingId) => {
+ const data = await Users.find(
+ { meetingId },
+ { fields: { userId: 1 } },
+ ).fetchAsync();
+
+ if (!data) return [];
+
+ return data.map(user => user.userId);
+};
+
+export {
+ getMultiUser,
+ getUsers,
+};
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/index.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/index.js
new file mode 100644
index 00000000..92451ac7
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/index.js
@@ -0,0 +1,3 @@
+import './eventHandlers';
+import './methods';
+import './publishers';
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js
new file mode 100644
index 00000000..4400535e
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import addGlobalAccess from './methods/addGlobalAccess';
+import addIndividualAccess from './methods/addIndividualAccess';
+import removeGlobalAccess from './methods/removeGlobalAccess';
+import removeIndividualAccess from './methods/removeIndividualAccess';
+
+Meteor.methods({
+ addGlobalAccess,
+ addIndividualAccess,
+ removeGlobalAccess,
+ removeIndividualAccess,
+});
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js
new file mode 100644
index 00000000..4c31959c
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addGlobalAccess.js
@@ -0,0 +1,32 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { getUsers } from '/imports/api/whiteboard-multi-user/server/helpers';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function addGlobalAccess(whiteboardId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg';
+
+ check(whiteboardId, String);
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const multiUser = await getUsers(meetingId);
+
+ const payload = {
+ multiUser,
+ whiteboardId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method addGlobalAccess ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js
new file mode 100644
index 00000000..3bd78929
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/addIndividualAccess.js
@@ -0,0 +1,37 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { getMultiUser } from '/imports/api/whiteboard-multi-user/server/helpers';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function addIndividualAccess(whiteboardId, userId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg';
+
+ check(whiteboardId, String);
+ check(userId, String);
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const multiUser = await getMultiUser(meetingId, whiteboardId);
+
+ if (!multiUser.includes(userId)) {
+ multiUser.push(userId);
+
+ const payload = {
+ multiUser,
+ whiteboardId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method addIndividualAccess ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js
new file mode 100644
index 00000000..f54659fa
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeGlobalAccess.js
@@ -0,0 +1,29 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default function removeGlobalAccess(whiteboardId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg';
+
+ check(whiteboardId, String);
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const payload = {
+ multiUser: [],
+ whiteboardId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ } catch (err) {
+ Logger.error(`Exception while invoking method removeGlobalAccess ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js
new file mode 100644
index 00000000..b7159664
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/methods/removeIndividualAccess.js
@@ -0,0 +1,35 @@
+import RedisPubSub from '/imports/startup/server/redis';
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { getMultiUser } from '/imports/api/whiteboard-multi-user/server/helpers';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import Logger from '/imports/startup/server/logger';
+
+export default async function removeIndividualAccess(whiteboardId, userId) {
+ try {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'ModifyWhiteboardAccessPubMsg';
+
+ check(whiteboardId, String);
+ check(userId, String);
+
+ const { meetingId, requesterUserId } = extractCredentials(this.userId);
+
+ check(meetingId, String);
+ check(requesterUserId, String);
+
+ const multiUser = await getMultiUser(meetingId, whiteboardId);
+
+ if (multiUser.includes(userId)) {
+ const payload = {
+ multiUser: multiUser.filter(id => id !== userId),
+ whiteboardId,
+ };
+
+ RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
+ }
+ } catch (err) {
+ Logger.error(`Exception while invoking method removeIndividualAccess ${err.stack}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js
new file mode 100644
index 00000000..34f97845
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/clearWhiteboardMultiUser.js
@@ -0,0 +1,26 @@
+import Logger from '/imports/startup/server/logger';
+import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user';
+
+export default async function clearWhiteboardMultiUser(meetingId) {
+ if (meetingId) {
+ try {
+ const numberAffected = WhiteboardMultiUser.removeAsync({ meetingId });
+
+ if (numberAffected) {
+ Logger.info(`Cleared WhiteboardMultiUser (${meetingId})`);
+ }
+ } catch (err) {
+ Logger.info(`Error clearing WhiteboardMultiUser (${meetingId}). ${err}`);
+ }
+ } else {
+ try {
+ const numberAffected = await WhiteboardMultiUser.removeAsync({});
+
+ if (numberAffected) {
+ Logger.info('Cleared WhiteboardMultiUser (all)');
+ }
+ } catch (err) {
+ Logger.info(`Error clearing WhiteboardMultiUser (all). ${err}`);
+ }
+ }
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js
new file mode 100644
index 00000000..d236611b
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/modifiers/modifyWhiteboardAccess.js
@@ -0,0 +1,31 @@
+import { check } from 'meteor/check';
+import Logger from '/imports/startup/server/logger';
+import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/';
+
+export default async function modifyWhiteboardAccess(meetingId, whiteboardId, multiUser) {
+ check(meetingId, String);
+ check(whiteboardId, String);
+ check(multiUser, Array);
+
+ const selector = {
+ meetingId,
+ whiteboardId,
+ };
+
+ const modifier = {
+ meetingId,
+ whiteboardId,
+ multiUser,
+ };
+
+ try {
+ const { insertedId } = await WhiteboardMultiUser.upsertAsync(selector, modifier);
+ if (insertedId) {
+ Logger.info(`Added multiUser flag=${multiUser} meetingId=${meetingId} whiteboardId=${whiteboardId}`);
+ } else {
+ Logger.info(`Upserted multiUser flag=${multiUser} meetingId=${meetingId} whiteboardId=${whiteboardId}`);
+ }
+ } catch (err) {
+ Logger.error(`Error while adding an entry to Multi-User collection: ${err}`);
+ }
+}
diff --git a/src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js b/src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js
new file mode 100644
index 00000000..5d4b8dd9
--- /dev/null
+++ b/src/2.7.12/imports/api/whiteboard-multi-user/server/publishers.js
@@ -0,0 +1,27 @@
+import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/';
+import { Meteor } from 'meteor/meteor';
+import Logger from '/imports/startup/server/logger';
+import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
+
+function whiteboardMultiUser() {
+ const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
+
+ if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
+ Logger.warn(`Publishing WhiteboardMultiUser was requested by unauth connection ${this.connection.id}`);
+ return WhiteboardMultiUser.find({ meetingId: '' });
+ }
+
+ const { meetingId, userId } = tokenValidation;
+
+ Logger.debug('Publishing WhiteboardMultiUser', { meetingId, userId });
+
+ return WhiteboardMultiUser.find({ meetingId });
+}
+
+
+function publish(...args) {
+ const boundMultiUser = whiteboardMultiUser.bind(this);
+ return boundMultiUser(...args);
+}
+
+Meteor.publish('whiteboard-multi-user', publish);
diff --git a/src/2.7.12/imports/startup/client/base.jsx b/src/2.7.12/imports/startup/client/base.jsx
new file mode 100644
index 00000000..dfc1bd09
--- /dev/null
+++ b/src/2.7.12/imports/startup/client/base.jsx
@@ -0,0 +1,438 @@
+import React, { Component } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import PropTypes from 'prop-types';
+import Auth from '/imports/ui/services/auth';
+import AppContainer from '/imports/ui/components/app/container';
+import ErrorScreen from '/imports/ui/components/error-screen/component';
+import MeetingEnded from '/imports/ui/components/meeting-ended/component';
+import LoadingScreen from '/imports/ui/components/common/loading-screen/component';
+import Settings from '/imports/ui/services/settings';
+import logger from '/imports/startup/client/logger';
+import Users from '/imports/api/users';
+import { Session } from 'meteor/session';
+import { Meteor } from 'meteor/meteor';
+import Meetings from '/imports/api/meetings';
+import AppService from '/imports/ui/components/app/service';
+import deviceInfo from '/imports/utils/deviceInfo';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import { layoutSelectInput, layoutDispatch } from '../../ui/components/layout/context';
+import VideoService from '/imports/ui/components/video-provider/service';
+import DebugWindow from '/imports/ui/components/debug-window/component';
+import { ACTIONS, PANELS } from '../../ui/components/layout/enums';
+import { isChatEnabled } from '/imports/ui/services/features';
+import { makeCall } from '/imports/ui/services/api';
+import BBBStorage from '/imports/ui/services/storage';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+const USER_WAS_EJECTED = 'userWasEjected';
+const CAPTIONS_ALWAYS_VISIBLE = Meteor.settings.public.app.audioCaptions.alwaysVisible;
+
+const HTML = document.getElementsByTagName('html')[0];
+
+let checkedUserSettings = false;
+
+const propTypes = {
+ subscriptionsReady: PropTypes.bool,
+ approved: PropTypes.bool,
+ meetingHasEnded: PropTypes.bool.isRequired,
+ meetingExist: PropTypes.bool,
+};
+
+const defaultProps = {
+ approved: false,
+ meetingExist: false,
+ subscriptionsReady: false,
+};
+
+const fullscreenChangedEvents = [
+ 'fullscreenchange',
+ 'webkitfullscreenchange',
+ 'mozfullscreenchange',
+ 'MSFullscreenChange',
+];
+
+class Base extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ loading: false,
+ meetingExisted: false,
+ userRemoved: false,
+ };
+ this.updateLoadingState = this.updateLoadingState.bind(this);
+ this.handleFullscreenChange = this.handleFullscreenChange.bind(this);
+ }
+
+ handleFullscreenChange() {
+ const { layoutContextDispatch } = this.props;
+
+ if (document.fullscreenElement
+ || document.webkitFullscreenElement
+ || document.mozFullScreenElement
+ || document.msFullscreenElement) {
+ Session.set('isFullscreen', true);
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_FULLSCREEN_ELEMENT,
+ value: {
+ element: '',
+ group: '',
+ },
+ });
+ Session.set('isFullscreen', false);
+ }
+ }
+
+ componentDidMount() {
+ const { animations, usersVideo, layoutContextDispatch } = this.props;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_NUM_CAMERAS,
+ value: usersVideo.length,
+ });
+
+ if (animations) HTML.classList.add('animationsEnabled');
+ if (!animations) HTML.classList.add('animationsDisabled');
+
+ fullscreenChangedEvents.forEach((event) => {
+ document.addEventListener(event, this.handleFullscreenChange);
+ });
+ Session.set('audioCaptions', CAPTIONS_ALWAYS_VISIBLE);
+ Session.set('isFullscreen', false);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ approved,
+ meetingExist,
+ animations,
+ ejected,
+ isMeteorConnected,
+ subscriptionsReady,
+ layoutContextDispatch,
+ sidebarContentPanel,
+ usersVideo,
+ User,
+ } = this.props;
+ const {
+ loading,
+ meetingExisted,
+ } = this.state;
+
+ if (usersVideo !== prevProps.usersVideo) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_NUM_CAMERAS,
+ value: usersVideo.length,
+ });
+ }
+
+ if (!prevProps.subscriptionsReady && subscriptionsReady) {
+ logger.info({ logCode: 'startup_client_subscriptions_ready' }, 'Subscriptions are ready');
+ }
+
+ if (prevProps.meetingExist && !meetingExist && !meetingExisted) {
+ this.setMeetingExisted(true);
+ }
+
+ // In case the meteor restart avoid error log
+ if (isMeteorConnected && (prevState.meetingExisted !== meetingExisted) && meetingExisted) {
+ this.setMeetingExisted(false);
+ }
+
+ // In case the meeting delayed to load
+ if (!subscriptionsReady || !meetingExist) return;
+
+ if (approved && loading) this.updateLoadingState(false);
+
+ if (prevProps.ejected || ejected) {
+ Session.set('codeError', '403');
+ Session.set('isMeetingEnded', true);
+ }
+
+ if (prevProps.User && !User) {
+ this.setUserRemoved(true);
+ }
+
+ // In case the meteor restart avoid error log
+ if (isMeteorConnected && (prevState.meetingExisted !== meetingExisted)) {
+ this.setMeetingExisted(false);
+ }
+
+ if ((prevProps.isMeteorConnected !== isMeteorConnected) && !isMeteorConnected) {
+ Session.set('globalIgnoreDeletes', true);
+ }
+
+ const enabled = HTML.classList.contains('animationsEnabled');
+ const disabled = HTML.classList.contains('animationsDisabled');
+
+ if (animations && animations !== prevProps.animations) {
+ if (disabled) HTML.classList.remove('animationsDisabled');
+ HTML.classList.add('animationsEnabled');
+ } else if (!animations && animations !== prevProps.animations) {
+ if (enabled) HTML.classList.remove('animationsEnabled');
+ HTML.classList.add('animationsDisabled');
+ }
+
+ if (Session.equals('layoutReady', true) && (sidebarContentPanel === PANELS.NONE || Session.equals('subscriptionsReady', true))) {
+ if (!checkedUserSettings) {
+ const showAnimationsDefault = getFromUserSettings(
+ 'bbb_show_animations_default',
+ Meteor.settings.public.app.defaultSettings.application.animations
+ );
+
+ Settings.application.animations = showAnimationsDefault;
+ Settings.save();
+
+ if (getFromUserSettings('bbb_show_participants_on_login', Meteor.settings.public.layout.showParticipantsOnLogin) && !deviceInfo.isPhone) {
+ if (isChatEnabled() && getFromUserSettings('bbb_show_public_chat_on_login', !Meteor.settings.public.chat.startClosed)) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: PUBLIC_CHAT_ID,
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ }
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ }
+
+ if (Session.equals('subscriptionsReady', true)) {
+ checkedUserSettings = true;
+ }
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ fullscreenChangedEvents.forEach((event) => {
+ document.removeEventListener(event, this.handleFullscreenChange);
+ });
+ }
+
+ setMeetingExisted(meetingExisted) {
+ this.setState({ meetingExisted });
+ }
+
+ setUserRemoved(userRemoved) {
+ this.setState({ userRemoved });
+ }
+
+ updateLoadingState(loading = false) {
+ this.setState({
+ loading,
+ });
+ }
+
+ static async setExitReason(reason) {
+ return await makeCall('setExitReason', reason);
+ }
+
+ renderByState() {
+ const { loading, userRemoved } = this.state;
+ const {
+ codeError,
+ ejected,
+ ejectedReason,
+ meetingExist,
+ meetingHasEnded,
+ meetingEndedReason,
+ meetingIsBreakout,
+ subscriptionsReady,
+ userWasEjected,
+ } = this.props;
+
+ if ((loading || !subscriptionsReady) && !meetingHasEnded && meetingExist) {
+ return ({loading} );
+ }
+
+ if (( meetingHasEnded || ejected || userRemoved ) && meetingIsBreakout) {
+ Base.setExitReason('breakoutEnded').finally(() => {
+ Meteor.disconnect();
+ window.close();
+ });
+ return null;
+ }
+
+ if (ejected || userWasEjected) {
+ return (
+ Base.setExitReason('ejected')}
+ />
+ );
+ }
+
+ if ((meetingHasEnded && !meetingIsBreakout) || userWasEjected) {
+ return (
+ Base.setExitReason('meetingEnded')}
+ />
+ );
+ }
+
+ if ((codeError && !meetingHasEnded) || userWasEjected) {
+ // 680 is set for the codeError when the user requests a logout.
+ if (codeError !== '680') {
+ return ( Base.setExitReason('error')} />);
+ }
+ return ( Base.setExitReason('logout')} />);
+ }
+
+ return ( );
+ }
+
+ render() {
+ const {
+ meetingExist,
+ codeError,
+ } = this.props;
+ const { meetingExisted } = this.state;
+
+ return (
+ <>
+ {meetingExist && Auth.loggedIn && }
+ {
+ (!meetingExisted && !meetingExist && Auth.loggedIn && !codeError)
+ ?
+ : this.renderByState()
+ }
+ >
+ );
+ }
+}
+
+Base.propTypes = propTypes;
+Base.defaultProps = defaultProps;
+
+const BaseContainer = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+
+ return ;
+};
+
+export default withTracker(() => {
+ const {
+ animations,
+ } = Settings.application;
+
+ const {
+ credentials,
+ loggedIn,
+ } = Auth;
+
+ const { meetingId } = credentials;
+ let breakoutRoomSubscriptionHandler;
+ let meetingModeratorSubscriptionHandler;
+
+ const fields = {
+ approved: 1,
+ authed: 1,
+ ejected: 1,
+ ejectedReason: 1,
+ color: 1,
+ effectiveConnectionType: 1,
+ extId: 1,
+ guest: 1,
+ intId: 1,
+ locked: 1,
+ loggedOut: 1,
+ meetingId: 1,
+ userId: 1,
+ inactivityCheck: 1,
+ responseDelay: 1,
+ currentConnectionId: 1,
+ connectionIdUpdateTime: 1,
+ };
+ const User = Users.findOne({ intId: credentials.requesterUserId }, { fields });
+ const meeting = Meetings.findOne({ meetingId }, {
+ fields: {
+ meetingEnded: 1,
+ meetingEndedReason: 1,
+ meetingProp: 1,
+ },
+ });
+
+ if (meeting && meeting.meetingEnded) {
+ Session.set('codeError', '410');
+ }
+
+ const approved = User?.approved && User?.guest;
+ const ejected = User?.ejected;
+ const ejectedReason = User?.ejectedReason;
+ const meetingEndedReason = meeting?.meetingEndedReason;
+ const currentConnectionId = User?.currentConnectionId;
+ const { connectionID, connectionAuthTime } = Auth;
+ const connectionIdUpdateTime = User?.connectionIdUpdateTime;
+
+ if (ejected) {
+ // use the connectionID to block users, so we can detect if the user was
+ // blocked by the current connection. This is the case when a a user is
+ // ejected from a meeting but not permanently ejected. Permanent ejects are
+ // managed by the server, not by the client.
+ BBBStorage.setItem(USER_WAS_EJECTED, connectionID);
+ }
+
+ if (currentConnectionId && currentConnectionId !== connectionID && connectionIdUpdateTime > connectionAuthTime) {
+ Session.set('codeError', '409');
+ Session.set('errorMessageDescription', 'joined_another_window_reason')
+ }
+
+ let userSubscriptionHandler;
+
+ const codeError = Session.get('codeError');
+ const { streams: usersVideo } = VideoService.getVideoStreams();
+
+ return {
+ userWasEjected: (BBBStorage.getItem(USER_WAS_EJECTED) == connectionID),
+ approved,
+ ejected,
+ ejectedReason,
+ userSubscriptionHandler,
+ breakoutRoomSubscriptionHandler,
+ meetingModeratorSubscriptionHandler,
+ animations,
+ User,
+ isMeteorConnected: Meteor.status().connected,
+ meetingExist: !!meeting,
+ meetingHasEnded: !!meeting && meeting.meetingEnded,
+ meetingEndedReason,
+ meetingIsBreakout: AppService.meetingIsBreakout(),
+ subscriptionsReady: Session.get('subscriptionsReady'),
+ loggedIn,
+ codeError,
+ usersVideo,
+ };
+})(BaseContainer);
diff --git a/src/2.7.12/imports/startup/client/intl.jsx b/src/2.7.12/imports/startup/client/intl.jsx
new file mode 100644
index 00000000..37eb639a
--- /dev/null
+++ b/src/2.7.12/imports/startup/client/intl.jsx
@@ -0,0 +1,198 @@
+import React, { Component } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import PropTypes from 'prop-types';
+import { IntlProvider } from 'react-intl';
+import Settings from '/imports/ui/services/settings';
+import LoadingScreen from '/imports/ui/components/common/loading-screen/component';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import { Session } from 'meteor/session';
+import Logger from '/imports/startup/client/logger';
+import { formatLocaleCode } from '/imports/utils/string-utils';
+import Intl from '/imports/ui/services/locale';
+import { isEmpty } from 'radash';
+
+const propTypes = {
+ locale: PropTypes.string,
+ overrideLocaleFromPassedParameter: PropTypes.string,
+ children: PropTypes.element.isRequired,
+};
+
+const DEFAULT_LANGUAGE = Meteor.settings.public.app.defaultSettings.application.fallbackLocale;
+const CLIENT_VERSION = Meteor.settings.public.app.html5ClientBuild;
+const FALLBACK_ON_EMPTY_STRING = Meteor.settings.public.app.fallbackOnEmptyLocaleString;
+
+const RTL_LANGUAGES = ['ar', 'dv', 'fa', 'he'];
+const LARGE_FONT_LANGUAGES = ['te', 'km'];
+
+const defaultProps = {
+ locale: DEFAULT_LANGUAGE,
+ overrideLocaleFromPassedParameter: null,
+};
+
+class IntlStartup extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ messages: {},
+ normalizedLocale: null,
+ fetching: true,
+ };
+
+ if (RTL_LANGUAGES.includes(props.locale)) {
+ document.body.parentNode.setAttribute('dir', 'rtl');
+ }
+
+ this.fetchLocalizedMessages = this.fetchLocalizedMessages.bind(this);
+ }
+
+ componentDidMount() {
+ const { locale, overrideLocaleFromPassedParameter } = this.props;
+ this.fetchLocalizedMessages(overrideLocaleFromPassedParameter || locale, true);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { fetching, messages, normalizedLocale } = this.state;
+ const { locale, overrideLocaleFromPassedParameter } = this.props;
+
+ if (overrideLocaleFromPassedParameter !== prevProps.overrideLocaleFromPassedParameter) {
+ this.fetchLocalizedMessages(overrideLocaleFromPassedParameter);
+ } else {
+ const shouldFetch = (!fetching && isEmpty(messages)) || ((locale !== prevProps.locale) && (normalizedLocale && (locale !== normalizedLocale)));
+ if (shouldFetch) this.fetchLocalizedMessages(locale);
+ }
+ }
+
+ fetchLocalizedMessages(locale, init = false) {
+ const url = `./locale?locale=${locale}&init=${init}`;
+ const localesPath = 'locales';
+
+ Intl.fetching = true;
+ this.setState({ fetching: true }, () => {
+ fetch(url)
+ .then((response) => {
+ if (!response.ok) {
+ return false;
+ }
+ return response.json();
+ })
+ .then(({ normalizedLocale, regionDefaultLocale }) => {
+ const fetchFallbackMessages = new Promise((resolve, reject) => {
+ fetch(`${localesPath}/${DEFAULT_LANGUAGE}.json?v=${CLIENT_VERSION}`)
+ .then((response) => {
+ if (!response.ok) {
+ return reject();
+ }
+ return resolve(response.json());
+ });
+ });
+
+ const fetchRegionMessages = new Promise((resolve) => {
+ if (!regionDefaultLocale) {
+ return resolve(false);
+ }
+ fetch(`${localesPath}/${regionDefaultLocale}.json?v=${CLIENT_VERSION}`)
+ .then((response) => {
+ if (!response.ok) {
+ return resolve(false);
+ }
+ return response.json()
+ .then((jsonResponse) => resolve(jsonResponse))
+ .catch(() => {
+ Logger.error({ logCode: 'intl_parse_locale_SyntaxError' }, `Could not parse locale file ${regionDefaultLocale}.json, invalid json`);
+ resolve(false);
+ });
+ });
+ });
+
+ const fetchSpecificMessages = new Promise((resolve) => {
+ if (!normalizedLocale || normalizedLocale === DEFAULT_LANGUAGE || normalizedLocale === regionDefaultLocale) {
+ return resolve(false);
+ }
+ fetch(`${localesPath}/${normalizedLocale}.json?v=${CLIENT_VERSION}`)
+ .then((response) => {
+ if (!response.ok) {
+ return resolve(false);
+ }
+ return response.json()
+ .then((jsonResponse) => resolve(jsonResponse))
+ .catch(() => {
+ Logger.error({ logCode: 'intl_parse_locale_SyntaxError' }, `Could not parse locale file ${normalizedLocale}.json, invalid json`);
+ resolve(false);
+ });
+ });
+ });
+
+ Promise.all([fetchFallbackMessages, fetchRegionMessages, fetchSpecificMessages])
+ .then((values) => {
+ let mergedMessages = Object.assign({}, values[0]);
+
+ if (!values[1] && !values[2]) {
+ normalizedLocale = DEFAULT_LANGUAGE;
+ } else {
+ if (values[1]) {
+ mergedMessages = Object.assign(mergedMessages, values[1]);
+ }
+ if (values[2]) {
+ mergedMessages = Object.assign(mergedMessages, values[2]);
+ }
+ }
+
+ const dasherizedLocale = normalizedLocale.replace('_', '-');
+ const { language, formattedLocale } = formatLocaleCode(dasherizedLocale);
+ Intl.setLocale(formattedLocale, mergedMessages);
+
+ this.setState({ messages: mergedMessages, fetching: false, normalizedLocale: dasherizedLocale }, () => {
+ Settings.application.locale = dasherizedLocale;
+ if (RTL_LANGUAGES.includes(dasherizedLocale.substring(0, 2))) {
+ document.body.parentNode.setAttribute('dir', 'rtl');
+ Settings.application.isRTL = true;
+ } else {
+ document.body.parentNode.setAttribute('dir', 'ltr');
+ Settings.application.isRTL = false;
+ }
+ Session.set('isLargeFont', LARGE_FONT_LANGUAGES.includes(dasherizedLocale.substring(0, 2)));
+ window.dispatchEvent(new Event('localeChanged'));
+ document.getElementsByTagName('html')[0].lang = formattedLocale;
+ document.body.classList.add(`lang-${language}`);
+ Settings.save();
+ });
+ });
+ });
+ });
+ }
+
+ render() {
+ const { fetching, normalizedLocale, messages } = this.state;
+ const { children } = this.props;
+ const { formattedLocale } = formatLocaleCode(normalizedLocale);
+
+ return (
+ <>
+ {(fetching || !normalizedLocale) && }
+
+ {normalizedLocale
+ && (
+
+ {children}
+
+ )
+ }
+ >
+ );
+ }
+}
+
+const IntlStartupContainer = withTracker(() => {
+ const { locale } = Settings.application;
+ const overrideLocaleFromPassedParameter = getFromUserSettings('bbb_override_default_locale', null);
+ return {
+ locale,
+ overrideLocaleFromPassedParameter,
+ };
+})(IntlStartup);
+
+export default IntlStartupContainer;
+
+IntlStartup.propTypes = propTypes;
+IntlStartup.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/startup/client/logger.js b/src/2.7.12/imports/startup/client/logger.js
new file mode 100644
index 00000000..c08ce869
--- /dev/null
+++ b/src/2.7.12/imports/startup/client/logger.js
@@ -0,0 +1,126 @@
+import Auth from '/imports/ui/services/auth';
+import { Meteor } from 'meteor/meteor';
+import { createLogger, stdSerializers } from 'browser-bunyan';
+import { ConsoleFormattedStream } from '@browser-bunyan/console-formatted-stream';
+import { ConsoleRawStream } from '@browser-bunyan/console-raw-stream';
+import { ServerStream } from '@browser-bunyan/server-stream';
+import { nameFromLevel } from '@browser-bunyan/levels';
+
+// The logger accepts "console","server", and "external" as targets
+// Multiple targets can be set as an array in the settings under public.log
+// To add more targets use the format { "target": "server", "level": "info" },
+// and add it to the public.log array
+// The accepted levels are "debug", "info", "warn", "error"
+// To send to URL, use the format {"target": "external","level": "info",
+// "url": "","method": ""}
+// externalURL is the end-point that logs will be sent to
+// Call the logger by doing a function call with the level name, I.e, logger.warn('Hi on warn')
+
+const LOG_CONFIG = Meteor.settings.public.clientLog || { console: { enabled: true, level: 'info' } };
+
+// Custom stream that logs to an end-point
+class ServerLoggerStream extends ServerStream {
+ constructor(params) {
+ super(params);
+
+ if (params.logTag) {
+ this.logTagString = params.logTag;
+ }
+ }
+
+ write(rec) {
+ const { fullInfo } = Auth;
+
+ this.rec = rec;
+ if (fullInfo.meetingId != null) {
+ this.rec.userInfo = fullInfo;
+ }
+ this.rec.clientBuild = Meteor.settings.public.app.html5ClientBuild;
+ this.rec.connectionId = Meteor.connection._lastSessionId;
+ if (this.logTagString) {
+ this.rec.logTag = this.logTagString;
+ }
+ return super.write(this.rec);
+ }
+}
+
+// Custom stream to log to the meteor server
+class MeteorStream {
+ write(rec) {
+ const { fullInfo } = Auth;
+ const clientURL = window.location.href;
+
+ this.rec = rec;
+ if (fullInfo.meetingId != null) {
+ if (!this.rec.extraInfo) {
+ this.rec.extraInfo = {};
+ }
+
+ this.rec.extraInfo.clientURL = clientURL;
+
+ Meteor.call(
+ 'logClient',
+ nameFromLevel[this.rec.level],
+ this.rec.msg,
+ this.rec.logCode,
+ this.rec.extraInfo,
+ fullInfo,
+ );
+ } else {
+ Meteor.call(
+ 'logClient',
+ nameFromLevel[this.rec.level],
+ this.rec.msg,
+ this.rec.logCode,
+ { ...rec.extraInfo, clientURL },
+ );
+ }
+ }
+}
+
+
+function createStreamForTarget(target, options) {
+ const TARGET_EXTERNAL = 'external';
+ const TARGET_CONSOLE = 'console';
+ const TARGET_SERVER = 'server';
+
+ let Stream = ConsoleRawStream;
+ switch (target) {
+ case TARGET_EXTERNAL:
+ Stream = ServerLoggerStream;
+ break;
+ case TARGET_CONSOLE:
+ Stream = ConsoleFormattedStream;
+ break;
+ case TARGET_SERVER:
+ Stream = MeteorStream;
+ break;
+ default:
+ Stream = ConsoleFormattedStream;
+ }
+
+ return new Stream(options);
+}
+
+function generateLoggerStreams(config) {
+ let result = [];
+ Object.keys(config).forEach((key) => {
+ const logOption = config[key];
+ if (logOption && logOption.enabled) {
+ const { level, ...streamOptions } = logOption;
+ result = result.concat({ level, stream: createStreamForTarget(key, streamOptions) });
+ }
+ });
+ return result;
+}
+
+// Creates the logger with the array of streams of the chosen targets
+const logger = createLogger({
+ name: 'clientLogger',
+ streams: generateLoggerStreams(LOG_CONFIG),
+ serializers: stdSerializers,
+ src: true,
+});
+
+
+export default logger;
diff --git a/src/2.7.12/imports/startup/server/ClientConnections.js b/src/2.7.12/imports/startup/server/ClientConnections.js
new file mode 100644
index 00000000..2e575bee
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/ClientConnections.js
@@ -0,0 +1,180 @@
+import Logger from './logger';
+import userLeaving from '/imports/api/users/server/methods/userLeaving';
+import { extractCredentials } from '/imports/api/common/server/helpers';
+import AuthTokenValidation from '/imports/api/auth-token-validation';
+import Users from '/imports/api/users';
+import { check } from 'meteor/check';
+
+const { enabled, syncInterval } = Meteor.settings.public.syncUsersWithConnectionManager;
+
+class ClientConnections {
+ constructor() {
+ Logger.debug('Initializing client connections structure', { logCode: 'client_connections_init' });
+ this.connections = new Map();
+
+ setInterval(() => {
+ this.print();
+ }, 30000);
+
+ if (enabled) {
+ const syncConnections = Meteor.bindEnvironment(() => {
+ this.syncConnectionsWithServer();
+ });
+
+ setInterval(() => {
+ syncConnections();
+ }, syncInterval);
+ }
+ }
+
+ add(sessionId, connection) {
+ Logger.info('Client connections add called', { logCode: 'client_connections_add', extraInfo: { sessionId, connection } });
+ if (!sessionId || !connection) {
+ Logger.error(`Error on add new client connection. sessionId=${sessionId} connection=${connection.id}`,
+ { logCode: 'client_connections_add_error', extraInfo: { sessionId, connection } }
+ );
+
+ return;
+ }
+
+ const { meetingId, requesterUserId: userId } = extractCredentials(sessionId);
+
+ check(meetingId, String);
+ check(userId, String);
+
+ if (!meetingId) {
+ Logger.error(`Error on add new client connection. sessionId=${sessionId} connection=${connection.id}`,
+ { logCode: 'client_connections_add_error_meeting_id_null', extraInfo: { meetingId, userId } }
+ );
+ return false;
+ }
+
+ if (!this.exists(meetingId)) {
+ Logger.info(`Meeting not found in connections: meetingId=${meetingId}`);
+ this.createMeetingConnections(meetingId);
+ }
+
+ const sessionConnections = this.connections.get(meetingId);
+
+ if (sessionConnections.has(userId) && sessionConnections.get(userId).includes(connection.id)) {
+ Logger.info(`Connection already exists for user. userId=${userId} connectionId=${connection.id}`);
+
+ return false;
+ }
+
+ connection.onClose(Meteor.bindEnvironment(() => {
+ userLeaving(meetingId, userId, connection.id);
+ }));
+
+ Logger.info(`Adding new connection for sessionId=${sessionId} connection=${connection.id}`);
+
+ if (!sessionConnections.has(userId)) {
+ Logger.info(`Creating connections poll for ${userId}`);
+
+ sessionConnections.set(userId, []);
+ return sessionConnections.get(userId).push(connection.id);
+ } else {
+ return sessionConnections.get(userId).push(connection.id);
+ }
+ }
+
+ createMeetingConnections(meetingId) {
+ Logger.info(`Creating meeting in connections. meetingId=${meetingId}`);
+
+ if (!this.exists(meetingId))
+ return this.connections.set(meetingId, new Map());
+ }
+
+ exists(meetingId) {
+ return this.connections.has(meetingId);
+ }
+
+ getConnectionsForClient(sessionId) {
+ const { meetingId, requesterUserId: userId } = extractCredentials(sessionId);
+
+ check(meetingId, String);
+ check(userId, String);
+
+ return this.connections.get(meetingId)?.get(userId);
+ }
+
+ print() {
+ const mapConnectionsObj = {};
+ this.connections.forEach((value, key) => {
+ mapConnectionsObj[key] = {};
+
+ value.forEach((v, k) => {
+ mapConnectionsObj[key][k] = v;
+ });
+
+ });
+ Logger.info('Active connections', mapConnectionsObj);
+ }
+
+ removeClientConnection(sessionId, connectionId = null) {
+ Logger.info(`Removing connectionId for user. sessionId=${sessionId} connectionId=${connectionId}`);
+ const { meetingId, requesterUserId: userId } = extractCredentials(sessionId);
+
+ check(meetingId, String);
+ check(userId, String);
+
+ const meetingConnections = this.connections.get(meetingId);
+
+ if (meetingConnections?.has(userId)) {
+ const filteredConnections = meetingConnections.get(userId).filter(c => c !== connectionId);
+
+ return connectionId && filteredConnections.length ? meetingConnections.set(userId, filteredConnections) : meetingConnections.delete(userId);
+ }
+
+ return false;
+ }
+
+ removeMeeting(meetingId) {
+ Logger.debug(`Removing connections for meeting=${meetingId}`);
+ return this.connections.delete(meetingId);
+ }
+
+ syncConnectionsWithServer() {
+ Logger.info('Syncing ClientConnections with server');
+ const activeConnections = Array.from(Meteor.server.sessions.keys());
+
+ Logger.debug(`Found ${activeConnections.length} active connections in server`);
+
+ const onlineUsers = AuthTokenValidation
+ .find(
+ { connectionId: { $in: activeConnections } },
+ { fields: { meetingId: 1, userId: 1 } }
+ )
+ .fetch();
+
+ const onlineUsersId = onlineUsers.map(({ userId }) => userId);
+
+ const usersQuery = { userId: { $nin: onlineUsersId } };
+
+ const userWithoutConnectionIds = Users.find(usersQuery, { fields: { meetingId: 1, userId: 1 } }).fetch();
+
+ const removedUsersWithoutConnection = Users.remove(usersQuery);
+
+ if (removedUsersWithoutConnection) {
+ Logger.info(`Removed ${removedUsersWithoutConnection} users that are not connected`);
+ Logger.info(`Clearing connections`);
+ try {
+ userWithoutConnectionIds
+ .forEach(({ meetingId, userId }) => {
+ this.removeClientConnection(`${meetingId}--${userId}`);
+ });
+ } catch (err) {
+ Logger.error('Error on sync ClientConnections', err);
+ }
+ }
+ }
+
+}
+
+if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') {
+ Logger.info("ClientConnectionsSingleton was created")
+
+ const ClientConnectionsSingleton = new ClientConnections();
+
+ export default ClientConnectionsSingleton;
+}
diff --git a/src/2.7.12/imports/startup/server/index.js b/src/2.7.12/imports/startup/server/index.js
new file mode 100644
index 00000000..00c37184
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/index.js
@@ -0,0 +1,360 @@
+import { Meteor } from 'meteor/meteor';
+import { WebAppInternals } from 'meteor/webapp';
+import Langmap from 'langmap';
+import fs from 'fs';
+import Users from '/imports/api/users';
+import './settings';
+import { check } from 'meteor/check';
+import Logger from './logger';
+import Redis from './redis';
+
+import setMinBrowserVersions from './minBrowserVersion';
+import { PrometheusAgent, METRIC_NAMES } from './prom-metrics/index.js'
+
+let guestWaitHtml = '';
+
+const env = Meteor.isDevelopment ? 'development' : 'production';
+
+const meteorRoot = fs.realpathSync(`${process.cwd()}/../`);
+
+const applicationRoot = (env === 'development')
+ ? fs.realpathSync(`${meteorRoot}'/../../../../public/locales/`)
+ : fs.realpathSync(`${meteorRoot}/../programs/web.browser/app/locales/`);
+
+const AVAILABLE_LOCALES = fs.readdirSync(`${applicationRoot}`);
+const FALLBACK_LOCALES = JSON.parse(Assets.getText('config/fallbackLocales.json'));
+
+process.on('uncaughtException', (err) => {
+ Logger.error(`uncaughtException: ${err}`);
+ process.exit(1);
+});
+
+const formatMemoryUsage = (data) => `${Math.round(data / 1024 / 1024 * 100) / 100} MB`
+
+const serverHealth = () => {
+ const memoryData = process.memoryUsage();
+ const memoryUsage = {
+ rss: formatMemoryUsage(memoryData.rss),
+ heapTotal: formatMemoryUsage(memoryData.heapTotal),
+ heapUsed: formatMemoryUsage(memoryData.heapUsed),
+ external: formatMemoryUsage(memoryData.external),
+ }
+
+ const cpuData = process.cpuUsage();
+ const cpuUsage = {
+ system: formatMemoryUsage(cpuData.system),
+ user: formatMemoryUsage(cpuData.user),
+ }
+
+ Logger.info('Server health', {memoryUsage, cpuUsage});
+};
+
+Meteor.startup(() => {
+ const APP_CONFIG = Meteor.settings.public.app;
+ const CDN_URL = APP_CONFIG.cdn;
+ const instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1;
+
+ Logger.warn(`Started bbb-html5 process with instanceId=${instanceId}`);
+
+ const LOG_CONFIG = Meteor.settings.private.serverLog;
+ const { healthChecker } = LOG_CONFIG;
+ const { enable: enableHealthCheck, intervalMs: healthCheckInterval } = healthChecker;
+
+ if (enableHealthCheck) {
+ Meteor.setInterval(() => {
+ serverHealth();
+ }, healthCheckInterval);
+ }
+
+ const { customHeartbeat, customHeartbeatUseDataFrames } = APP_CONFIG;
+
+ if (customHeartbeat) {
+ Logger.warn('Custom heartbeat functions are enabled');
+ // https://github.com/sockjs/sockjs-node/blob/1ef08901f045aae7b4df0f91ef598d7a11e82897/lib/transport/websocket.js#L74-L82
+ const heartbeatFactory = function ({ heartbeatTimeoutCallback }) {
+ return function () {
+ const currentTime = new Date().getTime();
+
+ if (customHeartbeatUseDataFrames) {
+ // Skipping heartbeat, because websocket is sending data
+ if (currentTime - this.ws.lastSentFrameTimestamp < 10000) {
+ try {
+ Logger.debug('Skipping heartbeat, because websocket is sending data', {
+ currentTime,
+ lastSentFrameTimestamp: this.ws.lastSentFrameTimestamp,
+ userId: this.session?.connection?._meteorSession?.userId,
+ });
+ return;
+ } catch (err) {
+ Logger.error(`Skipping heartbeat error: ${err}`);
+ }
+ }
+ }
+
+ const supportsHeartbeats = this.ws.ping(null, () => {
+ clearTimeout(this.hto_ref);
+ });
+
+ if (supportsHeartbeats) {
+ this.hto_ref = setTimeout(() => {
+ try {
+ Logger.warn('Heartbeat timeout', { userId: this.session?.connection?._meteorSession?.userId, sentAt: currentTime, now: new Date().getTime() });
+ } catch (err) {
+ Logger.error(`Heartbeat timeout error: ${err}`);
+ } finally {
+ if (typeof heartbeatTimeoutCallback === 'function') {
+ heartbeatTimeoutCallback();
+ }
+ }
+ }, Meteor.server.options.heartbeatTimeout);
+ } else {
+ Logger.error('Unexpected error supportsHeartbeats=false');
+ }
+ };
+ };
+
+ // https://github.com/davhani/hagty/blob/6a5c78e9ae5a5e4ade03e747fb4cc8ea2df4be0c/faye-websocket/lib/faye/websocket/api.js#L84-L88
+ const newSend = function send(data) {
+ try {
+ this.lastSentFrameTimestamp = new Date().getTime();
+
+ if (this.meteorHeartbeat) {
+ // Call https://github.com/meteor/meteor/blob/1e7e56eec8414093cd0c1c70750b894069fc972a/packages/ddp-common/heartbeat.js#L80-L88
+ this.meteorHeartbeat._seenPacket = true;
+ if (this.meteorHeartbeat._heartbeatTimeoutHandle) {
+ this.meteorHeartbeat._clearHeartbeatTimeoutTimer();
+ }
+ }
+
+ if (this.readyState > 1/* API.OPEN = 1 */) return false;
+ if (!(data instanceof Buffer)) data = String(data);
+ return this._driver.messages.write(data);
+ } catch (err) {
+ console.error('Error on send data', err);
+ return false;
+ }
+ };
+
+ Meteor.setInterval(() => {
+ for (const session of Meteor.server.sessions.values()) {
+ const { socket } = session;
+ const recv = socket._session.recv;
+
+ if (session.bbbFixApplied || !recv || !recv.ws) {
+ continue;
+ }
+
+ recv.ws.meteorHeartbeat = session.heartbeat;
+ recv.heartbeat = heartbeatFactory({
+ heartbeatTimeoutCallback: recv.heartbeat_cb
+ });
+ recv.ws.send = newSend;
+ session.bbbFixApplied = true;
+ }
+ }, 5000);
+ }
+ if (CDN_URL.trim()) {
+ // Add CDN
+ BrowserPolicy.content.disallowEval();
+ BrowserPolicy.content.allowInlineScripts();
+ BrowserPolicy.content.allowInlineStyles();
+ BrowserPolicy.content.allowImageDataUrl(CDN_URL);
+ BrowserPolicy.content.allowFontDataUrl(CDN_URL);
+ BrowserPolicy.content.allowOriginForAll(CDN_URL);
+ WebAppInternals.setBundledJsCssPrefix(CDN_URL + APP_CONFIG.basename + Meteor.settings.public.app.instanceId);
+
+ const fontRegExp = /\.(eot|ttf|otf|woff|woff2)$/;
+
+ WebApp.rawConnectHandlers.use('/', (req, res, next) => {
+ if (fontRegExp.test(req._parsedUrl.pathname)) {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Vary', 'Origin');
+ res.setHeader('Pragma', 'public');
+ res.setHeader('Cache-Control', '"public"');
+ }
+ return next();
+ });
+ }
+
+ setMinBrowserVersions();
+
+ Meteor.onMessage(event => {
+ const { method } = event;
+ if (method) {
+ const methodName = method.includes('stream-cursor') ? 'stream-cursor' : method;
+ PrometheusAgent.increment(METRIC_NAMES.METEOR_METHODS, { methodName });
+ }
+ });
+
+ Logger.warn(`SERVER STARTED.
+ ENV=${env}
+ nodejs version=${process.version}
+ BBB_HTML5_ROLE=${process.env.BBB_HTML5_ROLE}
+ INSTANCE_ID=${instanceId}
+ PORT=${process.env.PORT}
+ CDN=${CDN_URL}\n`, APP_CONFIG);
+});
+
+
+const generateLocaleOptions = () => {
+ try {
+ Logger.warn('Calculating aggregateLocales (heavy)');
+
+
+ // remove duplicated locales (always remove more generic if same name)
+ const tempAggregateLocales = AVAILABLE_LOCALES
+ .map(file => file.replace('.json', ''))
+ .map(file => file.replace('_', '-'))
+ .map((locale) => {
+ const localeName = (Langmap[locale] || {}).nativeName
+ || (FALLBACK_LOCALES[locale] || {}).nativeName
+ || locale;
+ return {
+ locale,
+ name: localeName,
+ };
+ }).reverse()
+ .filter((item, index, self) => index === self.findIndex(i => (
+ i.name === item.name
+ )))
+ .reverse();
+
+ Logger.warn(`Total locales: ${tempAggregateLocales.length}`, tempAggregateLocales);
+
+ return tempAggregateLocales;
+ } catch (e) {
+ Logger.error(`'Could not process locales error: ${e}`);
+ return [];
+ }
+};
+
+let avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions());
+
+WebApp.connectHandlers.use('/check', (req, res) => {
+ const payload = { html5clientStatus: 'running' };
+
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ res.end(JSON.stringify(payload));
+});
+
+WebApp.connectHandlers.use('/locale', (req, res) => {
+ const APP_CONFIG = Meteor.settings.public.app;
+ const fallback = APP_CONFIG.defaultSettings.application.fallbackLocale;
+ const override = APP_CONFIG.defaultSettings.application.overrideLocale;
+ const browserLocale = override && req.query.init === 'true'
+ ? override.split(/[-_]/g) : req.query.locale.split(/[-_]/g);
+
+ let localeFile = fallback;
+
+ const usableLocales = AVAILABLE_LOCALES
+ .map(file => file.replace('.json', ''))
+ .reduce((locales, locale) => (locale.match(browserLocale[0])
+ ? [...locales, locale]
+ : locales), []);
+
+ let normalizedLocale;
+
+ const regionDefault = usableLocales.find(locale => browserLocale[0] === locale);
+
+ if (browserLocale.length > 1) {
+ // browser asks for specific locale
+ normalizedLocale = `${browserLocale[0]}_${browserLocale[1]?.toUpperCase()}`;
+
+ const normDefault = usableLocales.find(locale => normalizedLocale === locale);
+ if (normDefault) {
+ localeFile = normDefault;
+ } else {
+ if (regionDefault) {
+ localeFile = regionDefault;
+ } else {
+ const specFallback = usableLocales.find(locale => browserLocale[0] === locale.split("_")[0]);
+ if (specFallback) localeFile = specFallback;
+ }
+ }
+ } else {
+ // browser asks for region default locale
+ if (regionDefault && localeFile === fallback && regionDefault !== localeFile) {
+ localeFile = regionDefault;
+ } else {
+ const normFallback = usableLocales.find(locale => browserLocale[0] === locale.split("_")[0]);
+ if (normFallback) localeFile = normFallback;
+ }
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.end(JSON.stringify({
+ normalizedLocale: localeFile,
+ regionDefaultLocale: (regionDefault && regionDefault !== localeFile) ? regionDefault : '',
+ }));
+});
+
+WebApp.connectHandlers.use('/locale-list', (req, res) => {
+ if (!avaibleLocalesNamesJSON) {
+ avaibleLocalesNamesJSON = JSON.stringify(generateLocaleOptions());
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ res.end(avaibleLocalesNamesJSON);
+});
+
+WebApp.connectHandlers.use('/feedback', (req, res) => {
+ req.on('data', Meteor.bindEnvironment((data) => {
+ const body = JSON.parse(data);
+ const {
+ meetingId,
+ userId,
+ authToken,
+ userName: reqUserName,
+ comment,
+ rating,
+ } = body;
+
+ check(meetingId, String);
+ check(userId, String);
+ check(authToken, String);
+ check(reqUserName, String);
+ check(comment, String);
+ check(rating, Number);
+
+ const user = Users.findOne({
+ meetingId,
+ userId,
+ authToken,
+ });
+
+ if (!user) {
+ Logger.warn('Couldn\'t find user for feedback');
+ }
+
+ res.setHeader('Content-Type', 'application/json');
+ res.writeHead(200);
+ res.end(JSON.stringify({ status: 'ok' }));
+
+ body.userName = user ? user.name : `[unconfirmed] ${reqUserName}`;
+
+ const feedback = {
+ ...body,
+ };
+ Logger.info('FEEDBACK LOG:', feedback);
+ }));
+});
+
+WebApp.connectHandlers.use('/guestWait', (req, res) => {
+ if (!guestWaitHtml) {
+ try {
+ guestWaitHtml = Assets.getText('static/guest-wait/guest-wait.html');
+ } catch (e) {
+ Logger.warn(`Could not process guest wait html file: ${e}`);
+ }
+ }
+
+ res.setHeader('Content-Type', 'text/html');
+ res.writeHead(200);
+ res.end(guestWaitHtml);
+});
+
+export const eventEmitter = Redis.emitter;
+
+export const redisPubSub = Redis;
diff --git a/src/2.7.12/imports/startup/server/logger.js b/src/2.7.12/imports/startup/server/logger.js
new file mode 100644
index 00000000..7241b3db
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/logger.js
@@ -0,0 +1,45 @@
+import { Meteor } from 'meteor/meteor';
+import { createLogger, format, transports } from 'winston';
+import WinstonPromTransport from './prom-metrics/winstonPromTransport';
+
+const LOG_CONFIG = Meteor?.settings?.private?.serverLog || {};
+const { level, includeServerInfo } = LOG_CONFIG;
+
+const serverInfoFormat = format.printf(({ level, message, timestamp, ...metadata }) => {
+ const instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1;
+ const role = process.env.BBB_HTML5_ROLE;
+ const server = includeServerInfo && !Meteor?.isDevelopment ? `${role}-${instanceId} ` : "";
+
+ const msg = `${timestamp} ${server}[${level}] : ${message}`;
+ const meta = Object.keys(metadata).length ? JSON.stringify(metadata) : '';
+ return `${msg} ${meta}`;
+});
+
+const Logger = createLogger({
+ level,
+ format: format.combine(
+ format.colorize({ level: true }),
+ format.splat(),
+ format.simple(),
+ format.timestamp(),
+ serverInfoFormat,
+ ),
+ transports: [
+ // console logging
+ new transports.Console({
+ prettyPrint: false,
+ humanReadableUnhandledException: true,
+ colorize: true,
+ handleExceptions: true,
+ level,
+ }),
+ // export error logs to prometheus
+ new WinstonPromTransport({
+ level: 'error',
+ }),
+ ],
+});
+
+export default Logger;
+
+export const logger = Logger;
diff --git a/src/2.7.12/imports/startup/server/metrics.js b/src/2.7.12/imports/startup/server/metrics.js
new file mode 100644
index 00000000..b9ce7252
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/metrics.js
@@ -0,0 +1,146 @@
+/* eslint-disable no-prototype-builtins */
+
+import fs from 'fs';
+import path from 'path';
+import { Meteor } from 'meteor/meteor';
+import Logger from './logger';
+
+const {
+ metricsDumpIntervalMs,
+ metricsFolderPath,
+ removeMeetingOnEnd,
+} = Meteor.settings.private.redis.metrics;
+
+class Metrics {
+ constructor() {
+ this.metrics = {};
+ }
+
+ addEvent(meetingId, eventName, messageLength) {
+ if (!this.metrics.hasOwnProperty(meetingId)) {
+ this.metrics[meetingId] = {
+ currentlyInQueue: {},
+ wasInQueue: {},
+ };
+ }
+
+ const { currentlyInQueue } = this.metrics[meetingId];
+
+ if (!currentlyInQueue.hasOwnProperty(eventName)) {
+ currentlyInQueue[eventName] = {
+ count: 1,
+ payloadSize: messageLength,
+ };
+ } else {
+ currentlyInQueue[eventName].count += 1;
+ currentlyInQueue[eventName].payloadSize += messageLength;
+ }
+ }
+
+ processEvent(meetingId, eventName, size, processingStartTimestamp) {
+ const currentProcessingTimestamp = Date.now();
+ const processTime = currentProcessingTimestamp - processingStartTimestamp;
+
+ this.addEvent(meetingId, eventName, size);
+
+ if (!this.metrics[meetingId].wasInQueue.hasOwnProperty(eventName)) {
+ this.metrics[meetingId].wasInQueue[eventName] = {
+ count: 1,
+ payloadSize: {
+ min: size,
+ max: size,
+ last: size,
+ total: size,
+ avg: size,
+ },
+ processingTime: {
+ min: processTime,
+ max: processTime,
+ last: processTime,
+ total: processTime,
+ avg: processTime,
+ },
+ };
+ this.metrics[meetingId].currentlyInQueue[eventName].count -= 1;
+
+ if (!this.metrics[meetingId].currentlyInQueue[eventName].count) {
+ delete this.metrics[meetingId].currentlyInQueue[eventName];
+ }
+ } else {
+ const { currentlyInQueue, wasInQueue } = this.metrics[meetingId];
+
+ currentlyInQueue[eventName].count -= 1;
+
+ if (!currentlyInQueue[eventName].count) {
+ delete currentlyInQueue[eventName];
+ }
+
+ const { payloadSize, processingTime } = wasInQueue[eventName];
+
+ wasInQueue[eventName].count += 1;
+
+ payloadSize.last = size;
+ payloadSize.total += size;
+
+ if (payloadSize.min > size) payloadSize.min = size;
+ if (payloadSize.max < size) payloadSize.max = size;
+
+ payloadSize.avg = payloadSize.total / wasInQueue[eventName].count;
+
+ if (processingTime.min > processTime) processingTime.min = processTime;
+ if (processingTime.max < processTime) processingTime.max = processTime;
+
+ processingTime.last = processTime;
+ processingTime.total += processTime;
+ processingTime.avg = processingTime.total / wasInQueue[eventName].count;
+ }
+ }
+
+ setAnnotationQueueLength(meetingId, size) {
+ this.metrics[meetingId].annotationQueueLength = size;
+ }
+
+ startDumpFile() {
+ Meteor.setInterval(() => {
+ try {
+ const fileDate = new Date();
+ const fullYear = fileDate.getFullYear();
+ const month = (fileDate.getMonth() + 1).toString().padStart(2, '0');
+ const day = fileDate.getDate().toString().padStart(2, '0');
+ const hour = fileDate.getHours().toString().padStart(2, '0');
+ const minutes = fileDate.getMinutes().toString().padStart(2, '0');
+ const seconds = fileDate.getSeconds().toString().padStart(2, '0');
+
+ const folderName = `${fullYear}${month}${day}_${hour}`;
+ const fileName = `${folderName}${minutes}${seconds}_metrics.json`;
+
+ const folderPath = path.join(metricsFolderPath, folderName);
+ const fullFilePath = path.join(folderPath, fileName);
+
+ if (!fs.existsSync(folderPath)) {
+ Logger.debug(`Creating folder: ${folderPath}`);
+ fs.mkdirSync(folderPath);
+ }
+
+ fs.writeFileSync(fullFilePath, JSON.stringify(this.metrics));
+
+ Logger.info('Metric file successfully written');
+ } catch (err) {
+ Logger.error('Error on writing metrics to disk.', err);
+ }
+ }, metricsDumpIntervalMs);
+ }
+
+ removeMeeting(meetingId) {
+ if (removeMeetingOnEnd) {
+ Logger.info(`Removing meeting ${meetingId} from metrics`);
+ delete this.metrics[meetingId];
+ } else {
+ Logger.info(`Skipping remove of meeting ${meetingId} from metrics`);
+ }
+ }
+}
+
+const metricsSingleton = new Metrics();
+
+export default metricsSingleton;
diff --git a/src/2.7.12/imports/startup/server/minBrowserVersion.js b/src/2.7.12/imports/startup/server/minBrowserVersion.js
new file mode 100644
index 00000000..1bcec1ad
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/minBrowserVersion.js
@@ -0,0 +1,19 @@
+import { Meteor } from 'meteor/meteor';
+import { setMinimumBrowserVersions } from 'meteor/modern-browsers';
+
+const setMinBrowserVersions = () => {
+ const { minBrowserVersions } = Meteor.settings.private;
+
+ const versions = {};
+
+ minBrowserVersions.forEach((elem) => {
+ let { version } = elem;
+ if (version === 'Infinity') version = Infinity;
+
+ versions[elem.browser] = version;
+ });
+
+ setMinimumBrowserVersions(versions, 'bbb-min');
+};
+
+export default setMinBrowserVersions;
diff --git a/src/2.7.12/imports/startup/server/prom-metrics/index.js b/src/2.7.12/imports/startup/server/prom-metrics/index.js
new file mode 100644
index 00000000..76d2efad
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/prom-metrics/index.js
@@ -0,0 +1,35 @@
+import Agent from './promAgent.js';
+
+import {
+ METRICS_PREFIX,
+ METRIC_NAMES,
+ buildMetrics
+} from './metrics.js';
+
+const {
+ enabled: METRICS_ENABLED,
+ path: METRICS_PATH,
+ collectDefaultMetrics: COLLECT_DEFAULT_METRICS,
+ } = Meteor.settings.private.prometheus
+ ? Meteor.settings.private.prometheus
+ : { enabled: false };
+
+const PrometheusAgent = new Agent({
+ path: METRICS_PATH,
+ prefix: METRICS_PREFIX,
+ collectDefaultMetrics: COLLECT_DEFAULT_METRICS,
+ role: process.env.BBB_HTML5_ROLE,
+ instanceId: parseInt(process.env.INSTANCE_ID, 10) || 1,
+});
+
+if (METRICS_ENABLED) {
+ PrometheusAgent.injectMetrics(buildMetrics());
+ PrometheusAgent.start();
+}
+
+export {
+ METRIC_NAMES,
+ METRICS_PREFIX,
+ Agent,
+ PrometheusAgent,
+};
diff --git a/src/2.7.12/imports/startup/server/prom-metrics/metrics.js b/src/2.7.12/imports/startup/server/prom-metrics/metrics.js
new file mode 100644
index 00000000..903cbe52
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/prom-metrics/metrics.js
@@ -0,0 +1,68 @@
+const {
+ Counter,
+ Gauge,
+ Histogram
+} = require('prom-client');
+
+const METRICS_PREFIX = 'html5_'
+const METRIC_NAMES = {
+ METEOR_METHODS: 'meteorMethods',
+ METEOR_ERRORS_TOTAL: 'meteorErrorsTotal',
+ METEOR_RTT: 'meteorRtt',
+ REDIS_MESSAGE_QUEUE: 'redisMessageQueue',
+ REDIS_PAYLOAD_SIZE: 'redisPayloadSize',
+ REDIS_PROCESSING_TIME: 'redisProcessingTime'
+}
+
+let METRICS;
+const buildMetrics = () => {
+ if (METRICS == null) {
+ METRICS = {
+ [METRIC_NAMES.METEOR_METHODS]: new Counter({
+ name: `${METRICS_PREFIX}meteor_methods`,
+ help: 'Total number of meteor methods processed in html5',
+ labelNames: ['methodName', 'role', 'instanceId'],
+ }),
+
+ [METRIC_NAMES.METEOR_ERRORS_TOTAL]: new Counter({
+ name: `${METRICS_PREFIX}meteor_errors_total`,
+ help: 'Total number of errors logs in meteor',
+ labelNames: ['errorMessage', 'role', 'instanceId'],
+ }),
+
+ [METRIC_NAMES.METEOR_RTT]: new Histogram({
+ name: `${METRICS_PREFIX}meteor_rtt_seconds`,
+ help: 'Round-trip time of meteor client-server connections in seconds',
+ buckets: [0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.75, 1, 1.5, 2, 2.5, 5],
+ labelNames: ['role', 'instanceId'],
+ }),
+
+ [METRIC_NAMES.REDIS_MESSAGE_QUEUE]: new Gauge({
+ name: `${METRICS_PREFIX}redis_message_queue`,
+ help: 'Message queue size in redis',
+ labelNames: ['meetingId', 'role', 'instanceId'],
+ }),
+
+ [METRIC_NAMES.REDIS_PAYLOAD_SIZE]: new Histogram({
+ name: `${METRICS_PREFIX}redis_payload_size`,
+ help: 'Redis events payload size',
+ labelNames: ['eventName', 'role', 'instanceId'],
+ }),
+
+ [METRIC_NAMES.REDIS_PROCESSING_TIME]: new Histogram({
+ name: `${METRICS_PREFIX}redis_processing_time`,
+ help: 'Redis events processing time in milliseconds',
+ labelNames: ['eventName', 'role', 'instanceId'],
+ }),
+ }
+ }
+
+ return METRICS;
+};
+
+export {
+ METRICS_PREFIX,
+ METRIC_NAMES,
+ METRICS,
+ buildMetrics,
+};
diff --git a/src/2.7.12/imports/startup/server/prom-metrics/promAgent.js b/src/2.7.12/imports/startup/server/prom-metrics/promAgent.js
new file mode 100644
index 00000000..b7c668a8
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/prom-metrics/promAgent.js
@@ -0,0 +1,96 @@
+import {
+ register,
+ collectDefaultMetrics,
+} from 'prom-client';
+
+import Logger from '../logger';
+const LOG_PREFIX = '[prom-scrape-agt]';
+
+class PrometheusScrapeAgent {
+ constructor(options) {
+ this.metrics = {};
+ this.started = false;
+
+ this.path = options.path || '/metrics';
+ this.collectDefaultMetrics = options.collectDefaultMetrics || false;
+ this.metricsPrefix = options.prefix || '';
+ this.collectionTimeout = options.collectionTimeout || 10000;
+ this.roleAndInstanceLabels =
+ options.role
+ ? { role: options.role, instanceId: options.instanceId }
+ : {};
+ }
+
+ async collect(response) {
+ try {
+ response.writeHead(200, { 'Content-Type': register.contentType });
+ const content = await register.metrics();
+ response.end(content);
+ Logger.debug(`${LOG_PREFIX} Collected prometheus metrics:\n${content}`);
+ } catch (error) {
+ response.writeHead(500)
+ response.end(error.message);
+ Logger.error(`${LOG_PREFIX} Collecting prometheus metrics: ${error.message}`);
+ }
+ }
+
+ start() {
+ if (this.collectDefaultMetrics) collectDefaultMetrics({
+ prefix: this.metricsPrefix,
+ timeout: this.collectionTimeout,
+ labels: this.roleAndInstanceLabels,
+ });
+
+ WebApp.connectHandlers.use(this.path, (req, res) => {
+ return this.collect(res);
+ });
+
+ this.started = true;
+ };
+
+ injectMetrics(metricsDictionary) {
+ this.metrics = { ...this.metrics, ...metricsDictionary }
+ }
+
+ increment(metricName, labelsObject) {
+ if (!this.started) return;
+
+ const metric = this.metrics[metricName];
+ if (metric) {
+ labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
+ metric.inc(labelsObject)
+ }
+ }
+
+ decrement(metricName, labelsObject) {
+ if (!this.started) return;
+
+ const metric = this.metrics[metricName];
+ if (metric) {
+ labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
+ metric.dec(labelsObject)
+ }
+ }
+
+ set(metricName, value, labelsObject) {
+ if (!this.started) return;
+
+ const metric = this.metrics[metricName];
+ if (metric) {
+ labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
+ metric.set(labelsObject, value)
+ }
+ }
+
+ observe(metricName, value, labelsObject) {
+ if (!this.started) return;
+
+ const metric = this.metrics[metricName];
+ if (metric) {
+ labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
+ metric.observe(labelsObject, value)
+ }
+ }
+}
+
+export default PrometheusScrapeAgent;
diff --git a/src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js b/src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js
new file mode 100644
index 00000000..44669b19
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/prom-metrics/winstonPromTransport.js
@@ -0,0 +1,19 @@
+const Transport = require('winston-transport');
+import { PrometheusAgent, METRIC_NAMES } from './index.js'
+
+module.exports = class WinstonPromTransport extends Transport {
+ constructor(opts) {
+ super(opts);
+
+ }
+
+ log(info, callback) {
+ setImmediate(() => {
+ this.emit('logged', info);
+ });
+
+ PrometheusAgent.increment(METRIC_NAMES.METEOR_ERRORS_TOTAL, { errorMessage: info.message });
+
+ callback();
+ }
+};
diff --git a/src/2.7.12/imports/startup/server/redis.js b/src/2.7.12/imports/startup/server/redis.js
new file mode 100644
index 00000000..0e3c7740
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/redis.js
@@ -0,0 +1,416 @@
+import Redis from 'redis';
+import { Meteor } from 'meteor/meteor';
+import { EventEmitter2 } from 'eventemitter2';
+import { check } from 'meteor/check';
+import Logger from './logger';
+import Metrics from './metrics';
+import queue from 'queue';
+import { PrometheusAgent, METRIC_NAMES } from './prom-metrics/index.js'
+
+// Fake meetingId used for messages that have no meetingId
+const NO_MEETING_ID = '_';
+
+const { queueMetrics } = Meteor.settings.private.redis.metrics;
+const { collectRedisMetrics: PROM_METRICS_ENABLED } = Meteor.settings.private.prometheus;
+
+const makeEnvelope = (channel, eventName, header, body, routing) => {
+ const envelope = {
+ envelope: {
+ name: eventName,
+ routing: routing || {
+ sender: 'html5-server',
+ },
+ timestamp: Date.now(),
+ },
+ core: {
+ header,
+ body,
+ },
+ };
+
+ return JSON.stringify(envelope);
+};
+
+const getInstanceIdFromMessage = (parsedMessage) => {
+ // End meeting message does not seem to have systemProps
+ let instanceIdFromMessage = parsedMessage.core.body.props?.systemProps?.html5InstanceId;
+
+ return instanceIdFromMessage;
+};
+
+class MeetingMessageQueue {
+ constructor(eventEmitter, asyncMessages = [], redisDebugEnabled = false) {
+ this.asyncMessages = asyncMessages;
+ this.emitter = eventEmitter;
+ this.queue = queue({ autostart: true, concurrency: 1 });
+ this.redisDebugEnabled = redisDebugEnabled;
+
+ this.handleTask = this.handleTask.bind(this);
+ this.queue.taskHandler = this.handleTask;
+ }
+
+ handleTask(data, next) {
+ const { channel } = data;
+ const { envelope } = data.parsedMessage;
+ const { header } = data.parsedMessage.core;
+ const { body } = data.parsedMessage.core;
+ const { meetingId } = header;
+ const eventName = header.name;
+ const isAsync = this.asyncMessages.includes(channel)
+ || this.asyncMessages.includes(eventName);
+
+ const beginHandleTimestamp = Date.now();
+ let called = false;
+
+ check(eventName, String);
+ check(body, Object);
+
+ const callNext = () => {
+ if (called) return;
+ if (this.redisDebugEnabled) {
+ Logger.debug(`Redis: ${eventName} completed ${isAsync ? 'async' : 'sync'}`);
+ }
+ called = true;
+
+ if (queueMetrics) {
+ const queueId = meetingId || NO_MEETING_ID;
+ const dataLength = JSON.stringify(data).length;
+
+ Metrics.processEvent(queueId, eventName, dataLength, beginHandleTimestamp);
+ }
+
+ const queueLength = this.queue.length;
+
+ if (PROM_METRICS_ENABLED) {
+ const dataLength = JSON.stringify(data).length;
+ const currentTimestamp = Date.now();
+ const processTime = currentTimestamp - beginHandleTimestamp;
+ PrometheusAgent.observe(METRIC_NAMES.REDIS_PROCESSING_TIME, processTime, { eventName });
+ PrometheusAgent.observe(METRIC_NAMES.REDIS_PAYLOAD_SIZE, dataLength, { eventName });
+ meetingId && PrometheusAgent.set(METRIC_NAMES.REDIS_MESSAGE_QUEUE, queueLength, { meetingId });
+ }
+
+ if (queueLength > 100) {
+ Logger.warn(`Redis: MeetingMessageQueue for meetingId=${meetingId} has queue size=${queueLength} `);
+ }
+ next();
+ };
+
+ const onError = (reason) => {
+ Logger.error(`${eventName}: ${reason.stack ? reason.stack : reason}`);
+ callNext();
+ };
+
+ try {
+ if (this.redisDebugEnabled) {
+ if (!Meteor.settings.private.analytics.includeChat && eventName === 'GroupChatMessageBroadcastEvtMsg') {
+ return;
+ }
+ Logger.debug(`Redis: ${JSON.stringify(data.parsedMessage.core)} emitted`);
+ }
+
+ if (isAsync) {
+ callNext();
+ }
+
+ this.emitter
+ .emitAsync(eventName, { envelope, header, body }, meetingId)
+ .then(callNext)
+ .catch(onError);
+ } catch (reason) {
+ onError(reason);
+ }
+ }
+
+ add(...args) {
+ const { taskHandler } = this.queue;
+
+ this.queue.push(function (next) {
+ taskHandler(...args, next);
+ })
+
+ }
+}
+
+class RedisPubSub {
+ static handlePublishError(err) {
+ if (err) {
+ Logger.error(err);
+ }
+ }
+
+ constructor(config = {}) {
+ this.config = config;
+
+ this.didSendRequestEvent = false;
+ const host = process.env.REDIS_HOST || Meteor.settings.private.redis.host;
+ const redisConf = Meteor.settings.private.redis;
+ this.instanceId = parseInt(process.env.INSTANCE_ID, 10) || 1; // 1 also handles running in dev mode
+ this.role = process.env.BBB_HTML5_ROLE;
+ this.customRedisChannel = `to-html5-redis-channel${this.instanceId}`;
+
+ const { password, port } = redisConf;
+
+ if (password) {
+ this.pub = Redis.createClient({ host, port, password });
+ this.sub = Redis.createClient({ host, port, password });
+ this.pub.auth(password);
+ this.sub.auth(password);
+ } else {
+ this.pub = Redis.createClient({ host, port });
+ this.sub = Redis.createClient({ host, port });
+ }
+
+ if (queueMetrics) {
+ Metrics.startDumpFile();
+ }
+
+ this.emitter = new EventEmitter2();
+ this.meetingsQueues = {};
+ // We create this _ meeting queue because we need to be able to handle system messages (no meetingId in core.header)
+ this.meetingsQueues[NO_MEETING_ID] = new MeetingMessageQueue(this.emitter, this.config.async, this.config.debug);
+
+ this.handleSubscribe = this.handleSubscribe.bind(this);
+ this.handleMessage = this.handleMessage.bind(this);
+ }
+
+ init() {
+ this.sub.on('psubscribe', Meteor.bindEnvironment(this.handleSubscribe));
+ this.sub.on('pmessage', Meteor.bindEnvironment(this.handleMessage));
+
+ const channelsToSubscribe = this.config.subscribeTo;
+
+ channelsToSubscribe.push(this.customRedisChannel);
+
+ switch (this.role) {
+ case 'frontend':
+ this.sub.psubscribe('from-akka-apps-frontend-redis-channel');
+ if (this.redisDebugEnabled) {
+ Logger.debug(`Redis: NodeJSPool:${this.instanceId} Role: frontend. Subscribed to 'from-akka-apps-frontend-redis-channel'`);
+ }
+ break;
+ case 'backend':
+ channelsToSubscribe.forEach((channel) => {
+ this.sub.psubscribe(channel);
+ if (this.redisDebugEnabled) {
+ Logger.debug(`Redis: NodeJSPool:${this.instanceId} Role: backend. Subscribed to '${channelsToSubscribe}'`);
+ }
+ });
+ break;
+ default:
+ this.sub.psubscribe('from-akka-apps-frontend-redis-channel');
+ channelsToSubscribe.forEach((channel) => {
+ this.sub.psubscribe(channel);
+ if (this.redisDebugEnabled) {
+ Logger.debug(`Redis: NodeJSPool:${this.instanceId} Role:${this.role} (likely only one nodejs running, doing both frontend and backend. Dev env? ). Subscribed to '${channelsToSubscribe}'`);
+ }
+ });
+
+ break;
+ }
+ }
+
+ updateConfig(config) {
+ this.config = Object.assign({}, this.config, config);
+ this.redisDebugEnabled = this.config.debug;
+ }
+
+
+ // TODO: Move this out of this class, maybe pass as a callback to init?
+ handleSubscribe() {
+ if (this.didSendRequestEvent || this.role === 'frontend') return;
+
+ // populate collections with pre-existing data
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+ const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
+ const EVENT_NAME = 'GetAllMeetingsReqMsg';
+
+ const body = {
+ requesterId: 'nodeJSapp',
+ html5InstanceId: this.instanceId,
+ };
+
+ this.publishSystemMessage(CHANNEL, EVENT_NAME, body);
+ this.didSendRequestEvent = true;
+ }
+
+ handleMessage(pattern, channel, message) {
+ const parsedMessage = JSON.parse(message);
+ const { ignored: ignoredMessages, async } = this.config;
+ const eventName = parsedMessage.core.header.name;
+
+ if (ignoredMessages.includes(channel)
+ || ignoredMessages.includes(eventName)) {
+ if (eventName === 'CheckAlivePongSysMsg') {
+ return;
+ }
+ if (this.redisDebugEnabled) {
+ Logger.debug(`Redis: ${eventName} skipped`);
+ }
+ return;
+ }
+
+ if (this.redisDebugEnabled) {
+ Logger.warn('Received event to handle', { date: new Date().toISOString(), eventName });
+ }
+
+ // System messages like Create / Destroy Meeting, etc do not have core.header.meetingId.
+ // Process them in MeetingQueue['_'] --- the NO_MEETING queueId
+ const meetingIdFromMessageCoreHeader = parsedMessage.core.header.meetingId || NO_MEETING_ID;
+
+ if (this.role === 'frontend') {
+ // receiving this message means we need to look at it. Frontends do not have instanceId.
+ if (meetingIdFromMessageCoreHeader === NO_MEETING_ID) { // if this is a system message
+ if (eventName === 'MeetingCreatedEvtMsg' || eventName === 'SyncGetMeetingInfoRespMsg') {
+ const meetingIdFromMessageMeetingProp = parsedMessage.core.body.props.meetingProp.intId;
+ this.meetingsQueues[meetingIdFromMessageMeetingProp] = new MeetingMessageQueue(this.emitter, async, this.redisDebugEnabled);
+ if (this.redisDebugEnabled) {
+ Logger.warn('Created frontend queue for meeting', { date: new Date().toISOString(), eventName, meetingIdFromMessageMeetingProp });
+ }
+ }
+ }
+
+ if (eventName === 'SendWhiteboardAnnotationsEvtMsg') {
+ // we need the instanceId in the handler to avoid calling the same upsert on the
+ // Annotations collection multiple times
+ parsedMessage.core.body.myInstanceId = this.instanceId;
+ }
+
+ if (!this.meetingsQueues[meetingIdFromMessageCoreHeader]) {
+ Logger.warn(`Frontend meeting queue had not been initialized ${message}`, { eventName, meetingIdFromMessageCoreHeader });
+ this.meetingsQueues[NO_MEETING_ID].add({
+ pattern,
+ channel,
+ eventName,
+ parsedMessage,
+ });
+ } else {
+ // process the event - whether it's a system message or not, the meetingIdFromMessageCoreHeader value is adjusted
+ this.meetingsQueues[meetingIdFromMessageCoreHeader].add({
+ pattern,
+ channel,
+ eventName,
+ parsedMessage,
+ });
+ }
+ } else { // backend
+ if (meetingIdFromMessageCoreHeader === NO_MEETING_ID) { // if this is a system message
+ const meetingIdFromMessageMeetingProp = parsedMessage.core.body.props?.meetingProp?.intId;
+ const instanceIdFromMessage = getInstanceIdFromMessage(parsedMessage);
+
+ if (this.instanceId === instanceIdFromMessage) {
+ // create queue or destroy queue
+ if (eventName === 'MeetingCreatedEvtMsg' || eventName === 'SyncGetMeetingInfoRespMsg') {
+ this.meetingsQueues[meetingIdFromMessageMeetingProp] = new MeetingMessageQueue(this.emitter, async, this.redisDebugEnabled);
+ if (this.redisDebugEnabled) {
+ Logger.warn('Created backend queue for meeting', { date: new Date().toISOString(), eventName, meetingIdFromMessageMeetingProp });
+ }
+ }
+ this.meetingsQueues[NO_MEETING_ID].add({
+ pattern,
+ channel,
+ eventName,
+ parsedMessage,
+ });
+ } else {
+ if (eventName === 'MeetingEndedEvtMsg' || eventName === 'MeetingDestroyedEvtMsg') {
+ // MeetingEndedEvtMsg does not follow the system message pattern for meetingId
+ // but we still need to process it on the backend which is processing the rest of the events
+ // for this meetingId (it does not contain instanceId either, so we cannot compare that)
+ const meetingIdForMeetingEnded = parsedMessage.core.body.meetingId;
+ if (!!this.meetingsQueues[meetingIdForMeetingEnded]) {
+ this.meetingsQueues[NO_MEETING_ID].add({
+ pattern,
+ channel,
+ eventName,
+ parsedMessage,
+ });
+ }
+ }
+ // ignore
+ }
+ } else {
+ // add to existing queue
+ if (!!this.meetingsQueues[meetingIdFromMessageCoreHeader]) {
+ // only handle message if we have a queue for the meeting. If we don't have a queue, it means it's for a different instanceId
+ this.meetingsQueues[meetingIdFromMessageCoreHeader].add({
+ pattern,
+ channel,
+ eventName,
+ parsedMessage,
+ });
+ } else {
+ // If we reach this line, this means that there is no existing queue for this redis "backend" message
+ // which means that the meeting is fully handled by another bbb-html5-backend.
+ // Logger.warn('Backend meeting queue had not been initialized', { eventName, meetingIdFromMessageCoreHeader })
+ }
+ }
+ }
+ }
+
+ destroyMeetingQueue(id) {
+ delete this.meetingsQueues[id];
+ }
+
+ on(...args) {
+ return this.emitter.on(...args);
+ }
+
+ publishVoiceMessage(channel, eventName, voiceConf, payload) {
+ const header = {
+ name: eventName,
+ voiceConf,
+ };
+
+ const envelope = makeEnvelope(channel, eventName, header, payload);
+
+ return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError);
+ }
+
+ publishSystemMessage(channel, eventName, payload) {
+ const header = {
+ name: eventName,
+ };
+
+ const envelope = makeEnvelope(channel, eventName, header, payload);
+
+ return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError);
+ }
+
+ publishMeetingMessage(channel, eventName, meetingId, payload) {
+ const header = {
+ name: eventName,
+ meetingId,
+ };
+
+ const envelope = makeEnvelope(channel, eventName, header, payload);
+
+ return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError);
+ }
+
+ publishUserMessage(channel, eventName, meetingId, userId, payload) {
+ const header = {
+ name: eventName,
+ meetingId,
+ userId,
+ };
+
+ if (!meetingId || !userId) {
+ Logger.warn(`Publishing ${eventName} with potentially missing data userId=${userId} meetingId=${meetingId}`);
+ }
+ const envelope = makeEnvelope(channel, eventName, header, payload, { meetingId, userId });
+
+ return this.pub.publish(channel, envelope, RedisPubSub.handlePublishError);
+ }
+}
+
+const RedisPubSubSingleton = new RedisPubSub();
+
+Meteor.startup(() => {
+ const REDIS_CONFIG = Meteor.settings.private.redis;
+
+ RedisPubSubSingleton.updateConfig(REDIS_CONFIG);
+ RedisPubSubSingleton.init();
+});
+
+export default RedisPubSubSingleton;
diff --git a/src/2.7.12/imports/startup/server/settings.js b/src/2.7.12/imports/startup/server/settings.js
new file mode 100644
index 00000000..bdab48a5
--- /dev/null
+++ b/src/2.7.12/imports/startup/server/settings.js
@@ -0,0 +1,35 @@
+/* global __meteor_runtime_config__ */
+import { Meteor } from 'meteor/meteor';
+import fs from 'fs';
+import YAML from 'yaml';
+import { defaultsDeep } from '/imports/utils/array-utils';
+
+const DEFAULT_SETTINGS_FILE_PATH = process.env.BBB_HTML5_SETTINGS || 'assets/app/config/settings.yml';
+const LOCAL_SETTINGS_FILE_PATH = process.env.BBB_HTML5_LOCAL_SETTINGS || '/etc/bigbluebutton/bbb-html5.yml';
+
+try {
+ if (fs.existsSync(DEFAULT_SETTINGS_FILE_PATH)) {
+ let SETTINGS = YAML.parse(fs.readFileSync(DEFAULT_SETTINGS_FILE_PATH, 'utf-8'));
+
+ if (fs.existsSync(LOCAL_SETTINGS_FILE_PATH)) {
+ console.log('Local configuration found! Merging with default configuration...');
+ const LOCAL_CONFIG = YAML.parse(fs.readFileSync(LOCAL_SETTINGS_FILE_PATH, 'utf-8'));
+ SETTINGS = defaultsDeep(LOCAL_CONFIG, SETTINGS);
+ } else console.log('Local Configuration not found! Loading default configuration...');
+
+ Meteor.settings = SETTINGS;
+ Meteor.settings.public.app.instanceId = ''; // no longer use instanceId in URLs. Likely permanent change
+ // Meteor.settings.public.app.instanceId = `/${INSTANCE_ID}`;
+
+ Meteor.settings.public.packages = {
+ 'dynamic-import': { useLocationOrigin: true },
+ };
+
+ __meteor_runtime_config__.PUBLIC_SETTINGS = SETTINGS.public;
+ } else {
+ throw new Error('File doesn\'t exists');
+ }
+} catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error on load configuration file.', error);
+}
diff --git a/src/2.7.12/imports/ui/components/about/component.jsx b/src/2.7.12/imports/ui/components/about/component.jsx
new file mode 100644
index 00000000..a240891d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/about/component.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.about.title',
+ description: 'About title label',
+ },
+ version: {
+ id: 'app.about.version',
+ description: 'Client version label',
+ },
+ copyright: {
+ id: 'app.about.copyright',
+ defaultMessage: (new Date().getFullYear()),
+ description: 'Client copyright label',
+ },
+ confirmLabel: {
+ id: 'app.about.confirmLabel',
+ description: 'Confirmation button label',
+ },
+ confirmDesc: {
+ id: 'app.about.confirmDesc',
+ description: 'adds descriptive context to confirmLabel',
+ },
+ dismissLabel: {
+ id: 'app.about.dismissLabel',
+ description: 'Dismiss button label',
+ },
+ dismissDesc: {
+ id: 'app.about.dismissDesc',
+ description: 'adds descriptive context to dissmissLabel',
+ },
+ version_label: {
+ id: 'app.about.version_label',
+ description: 'label for version bbb',
+ },
+});
+
+const AboutComponent = (props) => {
+ const { intl, settings, isOpen, onRequestClose, priority, } = props;
+ const {
+ html5ClientBuild,
+ copyright,
+ bbbServerVersion,
+ displayBbbServerVersion,
+ } = settings;
+
+ const showLabelVersion = () => (
+ <>
+
+ {`${intl.formatMessage(intlMessages.version_label)} ${bbbServerVersion}`}
+ >
+ );
+
+ return (
+
+ {`${intl.formatMessage(intlMessages.copyright)} ${copyright}`}
+
+ {`${intl.formatMessage(intlMessages.version)} ${html5ClientBuild}`}
+ {displayBbbServerVersion ? showLabelVersion() : null}
+
+
+ );
+};
+
+export default injectIntl(AboutComponent);
diff --git a/src/2.7.12/imports/ui/components/about/container.jsx b/src/2.7.12/imports/ui/components/about/container.jsx
new file mode 100644
index 00000000..70b0c54a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/about/container.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+
+import AboutComponent from './component';
+
+const AboutContainer = (props) => {
+ const { children } = props;
+ return (
+
+ {children}
+
+ );
+};
+
+const getClientBuildInfo = () => (
+ {
+ settings: Meteor.settings.public.app,
+
+ }
+);
+
+export default withTracker(() => getClientBuildInfo())(AboutContainer);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx
new file mode 100644
index 00000000..65efeb3b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/component.jsx
@@ -0,0 +1,490 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import ExternalVideoModal from '/imports/ui/components/external-video-player/modal/container';
+import RandomUserSelectContainer from '/imports/ui/components/common/modal/random-user/container';
+import LayoutModalContainer from '/imports/ui/components/layout/modal/container';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Styled from './styles';
+import TimerService from '/imports/ui/components/timer/service';
+import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+import { PANELS, ACTIONS, LAYOUT_TYPE } from '../../layout/enums';
+import { uniqueId } from '/imports/utils/string-utils';
+import { isPresentationEnabled, isLayoutsEnabled } from '/imports/ui/services/features';
+import VideoPreviewContainer from '/imports/ui/components/video-preview/container';
+import { screenshareHasEnded } from '/imports/ui/components/screenshare/service';
+
+const propTypes = {
+ amIPresenter: PropTypes.bool.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ amIModerator: PropTypes.bool.isRequired,
+ shortcuts: PropTypes.string,
+ handleTakePresenter: PropTypes.func.isRequired,
+ isTimerActive: PropTypes.bool.isRequired,
+ isTimerEnabled: PropTypes.bool.isRequired,
+ allowExternalVideo: PropTypes.bool.isRequired,
+ stopExternalVideoShare: PropTypes.func.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ setMeetingLayout: PropTypes.func.isRequired,
+ setPushLayout: PropTypes.func.isRequired,
+ showPushLayout: PropTypes.bool.isRequired,
+ isTimerFeatureEnabled: PropTypes.bool.isRequired,
+ isCameraAsContentEnabled: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ shortcuts: '',
+ settingsLayout: LAYOUT_TYPE.SMART_LAYOUT,
+};
+
+const intlMessages = defineMessages({
+ actionsLabel: {
+ id: 'app.actionsBar.actionsDropdown.actionsLabel',
+ description: 'Actions button label',
+ },
+ activateTimerStopwatchLabel: {
+ id: 'app.actionsBar.actionsDropdown.activateTimerStopwatchLabel',
+ description: 'Activate timer/stopwatch label',
+ },
+ deactivateTimerStopwatchLabel: {
+ id: 'app.actionsBar.actionsDropdown.deactivateTimerStopwatchLabel',
+ description: 'Deactivate timer/stopwatch label',
+ },
+ presentationLabel: {
+ id: 'app.actionsBar.actionsDropdown.presentationLabel',
+ description: 'Upload a presentation option label',
+ },
+ presentationDesc: {
+ id: 'app.actionsBar.actionsDropdown.presentationDesc',
+ description: 'adds context to upload presentation option',
+ },
+ desktopShareDesc: {
+ id: 'app.actionsBar.actionsDropdown.desktopShareDesc',
+ description: 'adds context to desktop share option',
+ },
+ stopDesktopShareDesc: {
+ id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc',
+ description: 'adds context to stop desktop share option',
+ },
+ pollBtnLabel: {
+ id: 'app.actionsBar.actionsDropdown.pollBtnLabel',
+ description: 'poll menu toggle button label',
+ },
+ pollBtnDesc: {
+ id: 'app.actionsBar.actionsDropdown.pollBtnDesc',
+ description: 'poll menu toggle button description',
+ },
+ takePresenter: {
+ id: 'app.actionsBar.actionsDropdown.takePresenter',
+ description: 'Label for take presenter role option',
+ },
+ takePresenterDesc: {
+ id: 'app.actionsBar.actionsDropdown.takePresenterDesc',
+ description: 'Description of take presenter role option',
+ },
+ startExternalVideoLabel: {
+ id: 'app.actionsBar.actionsDropdown.shareExternalVideo',
+ description: 'Start sharing external video button',
+ },
+ stopExternalVideoLabel: {
+ id: 'app.actionsBar.actionsDropdown.stopShareExternalVideo',
+ description: 'Stop sharing external video button',
+ },
+ selectRandUserLabel: {
+ id: 'app.actionsBar.actionsDropdown.selectRandUserLabel',
+ description: 'Label for selecting a random user',
+ },
+ selectRandUserDesc: {
+ id: 'app.actionsBar.actionsDropdown.selectRandUserDesc',
+ description: 'Description for select random user option',
+ },
+ layoutModal: {
+ id: 'app.actionsBar.actionsDropdown.layoutModal',
+ description: 'Label for layouts selection button',
+ },
+ shareCameraAsContent: {
+ id: 'app.actionsBar.actionsDropdown.shareCameraAsContent',
+ description: 'Label for share camera as content',
+ },
+ unshareCameraAsContent: {
+ id: 'app.actionsBar.actionsDropdown.unshareCameraAsContent',
+ description: 'Label for unshare camera as content',
+ },
+});
+
+const handlePresentationClick = () => Session.set('showUploadPresentationView', true);
+
+class ActionsDropdown extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.presentationItemId = uniqueId('action-item-');
+ this.pollId = uniqueId('action-item-');
+ this.takePresenterId = uniqueId('action-item-');
+ this.timerId = uniqueId('action-item-');
+ this.selectUserRandId = uniqueId('action-item-');
+ this.state = {
+ isExternalVideoModalOpen: false,
+ isRandomUserSelectModalOpen: false,
+ isLayoutModalOpen: false,
+ isCameraAsContentModalOpen: false,
+ };
+
+ this.handleExternalVideoClick = this.handleExternalVideoClick.bind(this);
+ this.makePresentationItems = this.makePresentationItems.bind(this);
+ this.setExternalVideoModalIsOpen = this.setExternalVideoModalIsOpen.bind(this);
+ this.setRandomUserSelectModalIsOpen = this.setRandomUserSelectModalIsOpen.bind(this);
+ this.setLayoutModalIsOpen = this.setLayoutModalIsOpen.bind(this);
+ this.setCameraAsContentModalIsOpen = this.setCameraAsContentModalIsOpen.bind(this);
+ this.setPropsToPassModal = this.setPropsToPassModal.bind(this);
+ this.setForceOpen = this.setForceOpen.bind(this);
+ this.handleTimerClick = this.handleTimerClick.bind(this);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { amIPresenter: wasPresenter } = prevProps;
+ const { amIPresenter: isPresenter } = this.props;
+ if (wasPresenter && !isPresenter) {
+ this.setExternalVideoModalIsOpen(false);
+ }
+ }
+
+ handleExternalVideoClick() {
+ this.setExternalVideoModalIsOpen(true);
+ }
+
+ handleTimerClick() {
+ const { isTimerActive, layoutContextDispatch } = this.props;
+ if (!isTimerActive) {
+ TimerService.activateTimer(layoutContextDispatch);
+ } else {
+ TimerService.deactivateTimer();
+ }
+ }
+
+ getAvailableActions() {
+ const {
+ intl,
+ amIPresenter,
+ allowExternalVideo,
+ handleTakePresenter,
+ isSharingVideo,
+ isPollingEnabled,
+ isSelectRandomUserEnabled,
+ stopExternalVideoShare,
+ isTimerActive,
+ isTimerEnabled,
+ layoutContextDispatch,
+ setMeetingLayout,
+ setPushLayout,
+ showPushLayout,
+ amIModerator,
+ isMobile,
+ hasCameraAsContent,
+ isCameraAsContentEnabled,
+ isTimerFeatureEnabled,
+ isPresentationManagementDisabled,
+ } = this.props;
+
+ const { pollBtnLabel, presentationLabel, takePresenter } = intlMessages;
+
+ const { formatMessage } = intl;
+
+ const actions = [];
+
+ if (amIPresenter && !isPresentationManagementDisabled && isPresentationEnabled()) {
+ actions.push({
+ icon: 'upload',
+ dataTest: 'managePresentations',
+ label: formatMessage(presentationLabel),
+ key: this.presentationItemId,
+ onClick: handlePresentationClick,
+ dividerTop: this.props?.presentations?.length > 1 ? true : false,
+ });
+ }
+
+ if (amIPresenter && isPollingEnabled) {
+ actions.push({
+ icon: 'polling',
+ dataTest: 'polling',
+ label: formatMessage(pollBtnLabel),
+ key: this.pollId,
+ onClick: () => {
+ if (Session.equals('pollInitiated', true)) {
+ Session.set('resetPollPanel', true);
+ }
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.POLL,
+ });
+ Session.set('forcePollOpen', true);
+ },
+ });
+ }
+
+ if (!amIPresenter && amIModerator) {
+ actions.push({
+ icon: 'presentation',
+ label: formatMessage(takePresenter),
+ key: this.takePresenterId,
+ onClick: () => handleTakePresenter(),
+ });
+ }
+
+ if (amIPresenter && allowExternalVideo) {
+ actions.push({
+ icon: !isSharingVideo ? 'external-video' : 'external-video_off',
+ label: !isSharingVideo
+ ? intl.formatMessage(intlMessages.startExternalVideoLabel)
+ : intl.formatMessage(intlMessages.stopExternalVideoLabel),
+ key: 'external-video',
+ onClick: isSharingVideo ? stopExternalVideoShare : this.handleExternalVideoClick,
+ dataTest: 'shareExternalVideo',
+ });
+ }
+
+ if (amIPresenter && isSelectRandomUserEnabled) {
+ actions.push({
+ icon: 'user',
+ label: intl.formatMessage(intlMessages.selectRandUserLabel),
+ key: this.selectUserRandId,
+ onClick: () => this.setRandomUserSelectModalIsOpen(true),
+ dataTest: 'selectRandomUser',
+ });
+ }
+
+ if (amIModerator && isTimerEnabled && isTimerFeatureEnabled) {
+ actions.push({
+ icon: 'time',
+ label: isTimerActive
+ ? intl.formatMessage(intlMessages.deactivateTimerStopwatchLabel)
+ : intl.formatMessage(intlMessages.activateTimerStopwatchLabel),
+ key: this.timerId,
+ onClick: () => this.handleTimerClick(),
+ });
+ }
+
+ if (isLayoutsEnabled() && (amIModerator || amIPresenter)) {
+ actions.push({
+ icon: 'manage_layout',
+ label: intl.formatMessage(intlMessages.layoutModal),
+ key: 'layoutModal',
+ onClick: () => this.setLayoutModalIsOpen(true),
+ dataTest: 'manageLayoutBtn',
+ });
+ }
+
+ if (isCameraAsContentEnabled && amIPresenter) {
+ actions.push({
+ icon: hasCameraAsContent ? 'video_off' : 'video',
+ label: hasCameraAsContent
+ ? intl.formatMessage(intlMessages.unshareCameraAsContent)
+ : intl.formatMessage(intlMessages.shareCameraAsContent),
+ key: 'camera as content',
+ onClick: hasCameraAsContent
+ ? screenshareHasEnded
+ : () => {
+ screenshareHasEnded();
+ this.setCameraAsContentModalIsOpen(true);
+ },
+ dataTest: 'shareCameraAsContent',
+ });
+ }
+
+ return actions;
+ }
+
+ makePresentationItems() {
+ const {
+ presentations,
+ setPresentation,
+ podIds,
+ setPresentationFitToWidth,
+ } = this.props;
+
+ if (!podIds || podIds.length < 1) return [];
+
+ // We still have code for other pods from the Flash client. This intentionally only cares
+ // about the first one because it's the default.
+ const { podId } = podIds[0];
+
+ const presentationItemElements = presentations
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map((p) => {
+ const customStyles = { color: colorPrimary };
+
+ return (
+ {
+ customStyles: p.current ? customStyles : null,
+ icon: "file",
+ iconRight: p.current ? 'check' : null,
+ selected: p.current ? true : false,
+ label: p.name,
+ description: "uploaded presentation file",
+ key: `uploaded-presentation-${p.id}`,
+ onClick: () => {
+ setPresentationFitToWidth(false);
+ setPresentation(p.id, podId);
+ },
+ }
+ );
+ });
+ return presentationItemElements;
+ }
+
+ setExternalVideoModalIsOpen(value) {
+ this.setState({ isExternalVideoModalOpen: value });
+ }
+
+ setRandomUserSelectModalIsOpen(value) {
+ this.setState({ isRandomUserSelectModalOpen: value });
+ }
+
+ setLayoutModalIsOpen(value) {
+ this.setState({ isLayoutModalOpen: value });
+ }
+
+ setCameraAsContentModalIsOpen(value) {
+ this.setState({ isCameraAsContentModalOpen: value });
+ }
+
+ setPropsToPassModal(value) {
+ this.setState({ propsToPassModal: value });
+ }
+ setForceOpen(value) {
+ this.setState({ forceOpen: value });
+ }
+
+ renderModal(isOpen, setIsOpen, priority, Component) {
+ return isOpen ? (
+ setIsOpen(false),
+ priority,
+ setIsOpen,
+ isOpen,
+ }}
+ />
+ ) : null;
+ }
+
+ render() {
+ const {
+ intl,
+ amIPresenter,
+ shortcuts: OPEN_ACTIONS_AK,
+ isMeteorConnected,
+ isDropdownOpen,
+ isMobile,
+ isRTL,
+ isSelectRandomUserEnabled,
+ propsToPassModal,
+ } = this.props;
+
+ const {
+ isExternalVideoModalOpen,
+ isRandomUserSelectModalOpen,
+ isLayoutModalOpen,
+ isCameraAsContentModalOpen,
+ } = this.state;
+
+ const availableActions = this.getAvailableActions();
+ const availablePresentations = this.makePresentationItems();
+ const children = availablePresentations.length > 1 && amIPresenter
+ ? availablePresentations.concat(availableActions)
+ : availableActions;
+
+ const customStyles = { top: '-1rem' };
+
+ if (availableActions.length === 0 || !isMeteorConnected) {
+ return null;
+ }
+
+ return (
+ <>
+ null}
+ />
+ )}
+ actions={children}
+ opts={{
+ id: 'actions-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ />
+ {this.renderModal(
+ isExternalVideoModalOpen,
+ this.setExternalVideoModalIsOpen,
+ 'low',
+ ExternalVideoModal,
+ )}
+ {amIPresenter && isSelectRandomUserEnabled
+ ? this.renderModal(
+ isRandomUserSelectModalOpen,
+ this.setRandomUserSelectModalIsOpen,
+ 'low',
+ RandomUserSelectContainer,
+ )
+ : null}
+ {this.renderModal(
+ isLayoutModalOpen,
+ this.setLayoutModalIsOpen,
+ 'low',
+ LayoutModalContainer,
+ )}
+ {this.renderModal(
+ isCameraAsContentModalOpen,
+ this.setCameraAsContentModalIsOpen,
+ 'low',
+ () => (
+ {
+ this.setPropsToPassModal({});
+ this.setForceOpen(false);
+ },
+ priority: 'low',
+ setIsOpen: this.setCameraAsContentModalIsOpen,
+ isOpen: isCameraAsContentModalOpen,
+ }}
+ {...propsToPassModal}
+ />
+ )
+ )}
+ >
+ );
+ }
+}
+
+ActionsDropdown.propTypes = propTypes;
+ActionsDropdown.defaultProps = defaultProps;
+
+export default withShortcutHelper(ActionsDropdown, 'openActions');
diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx
new file mode 100644
index 00000000..db0489c6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/container.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Presentations from '/imports/api/presentations';
+import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service';
+import PresentationPodService from '/imports/ui/components/presentation-pod/service';
+import AppService from '/imports/ui/components/app/service';
+import ActionsDropdown from './component';
+import { layoutSelectInput, layoutDispatch, layoutSelect } from '../../layout/context';
+import { SMALL_VIEWPORT_BREAKPOINT } from '../../layout/enums';
+import { isCameraAsContentEnabled, isTimerFeatureEnabled } from '/imports/ui/services/features';
+
+const ActionsDropdownContainer = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
+ const { width: browserWidth } = layoutSelectInput((i) => i.browser);
+ const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT;
+ const layoutContextDispatch = layoutDispatch();
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return (
+
+ );
+};
+
+export default withTracker(() => {
+ const presentations = Presentations.find({ 'conversion.done': true }).fetch();
+ const { allowPresentationManagementInBreakouts } = Meteor.settings.public.app.breakouts;
+ const isPresentationManagementDisabled = AppService.meetingIsBreakout()
+ && !allowPresentationManagementInBreakouts;
+
+ return {
+ presentations,
+ isTimerFeatureEnabled: isTimerFeatureEnabled(),
+ isDropdownOpen: Session.get('dropdownOpen'),
+ setPresentation: PresentationUploaderService.setPresentation,
+ podIds: PresentationPodService.getPresentationPodIds(),
+ isCameraAsContentEnabled: isCameraAsContentEnabled(),
+ isPresentationManagementDisabled,
+ };
+})(ActionsDropdownContainer);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js
new file mode 100644
index 00000000..ebd8ec77
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/actions-dropdown/styles.js
@@ -0,0 +1,15 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+
+const HideDropdownButton = styled(Button)`
+ ${({ open }) => open && `
+ @media ${smallOnly} {
+ display:none;
+ }
+ `}
+`;
+
+export default {
+ HideDropdownButton,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/component.jsx
new file mode 100644
index 00000000..55f98269
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/component.jsx
@@ -0,0 +1,176 @@
+import React, { PureComponent } from 'react';
+import CaptionsButtonContainer from '/imports/ui/components/captions/button/container';
+import deviceInfo from '/imports/utils/deviceInfo';
+import Styled from './styles';
+import ActionsDropdown from './actions-dropdown/container';
+import AudioCaptionsButtonContainer from '/imports/ui/components/audio/captions/button/container';
+import CaptionsReaderMenuContainer from '/imports/ui/components/captions/reader-menu/container';
+import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container';
+import ReactionsButtonContainer from './reactions-button/container';
+import AudioControlsContainer from '../audio/audio-controls/container';
+import JoinVideoOptionsContainer from '../video-provider/video-button/container';
+import PresentationOptionsContainer from './presentation-options/component';
+import RaiseHandDropdownContainer from './raise-hand/container';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+class ActionsBar extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isCaptionsReaderMenuModalOpen: false,
+ };
+
+ this.setCaptionsReaderMenuModalIsOpen = this.setCaptionsReaderMenuModalIsOpen.bind(this);
+ this.setRenderRaiseHand = this.renderRaiseHand.bind(this);
+ this.actionsBarRef = React.createRef();
+ }
+
+ setCaptionsReaderMenuModalIsOpen(value) {
+ this.setState({ isCaptionsReaderMenuModalOpen: value })
+ }
+
+ renderRaiseHand() {
+ const {
+ isReactionsButtonEnabled, isRaiseHandButtonEnabled, setEmojiStatus, currentUser, intl,
+ } = this.props;
+
+ return (<>
+ {isReactionsButtonEnabled ?
+ <>
+
+
+ > :
+ isRaiseHandButtonEnabled ?
+ : null}
+ >);
+ }
+
+ render() {
+ const {
+ amIPresenter,
+ amIModerator,
+ enableVideo,
+ presentationIsOpen,
+ setPresentationIsOpen,
+ handleTakePresenter,
+ intl,
+ isSharingVideo,
+ isSharedNotesPinned,
+ hasScreenshare,
+ hasGenericContent,
+ hasCameraAsContent,
+ stopExternalVideoShare,
+ isTimerActive,
+ isTimerEnabled,
+ isCaptionsAvailable,
+ isMeteorConnected,
+ isPollingEnabled,
+ isSelectRandomUserEnabled,
+ isRaiseHandButtonCentered,
+ isThereCurrentPresentation,
+ allowExternalVideo,
+ layoutContextDispatch,
+ actionsBarStyle,
+ setMeetingLayout,
+ showPushLayout,
+ setPushLayout,
+ setPresentationFitToWidth,
+ } = this.props;
+
+ const { isCaptionsReaderMenuModalOpen } = this.state;
+
+ const shouldShowOptionsButton = (isPresentationEnabled() && isThereCurrentPresentation)
+ || isSharingVideo || hasScreenshare || isSharedNotesPinned;
+ return (
+
+
+
+ {isCaptionsAvailable
+ ? (
+ <>
+
+ {
+ isCaptionsReaderMenuModalOpen ? this.setCaptionsReaderMenuModalIsOpen(false),
+ priority: "low",
+ setIsOpen: this.setCaptionsReaderMenuModalIsOpen,
+ isOpen: isCaptionsReaderMenuModalOpen,
+ }}
+ /> : null
+ }
+ >
+ )
+ : null}
+ { !deviceInfo.isMobile
+ ? (
+
+ )
+ : null }
+
+
+
+ {enableVideo
+ ? (
+
+ )
+ : null}
+
+ {isRaiseHandButtonCentered && this.renderRaiseHand()}
+
+
+ { shouldShowOptionsButton ?
+
+ : null
+ }
+ {!isRaiseHandButtonCentered && this.renderRaiseHand()}
+
+
+ );
+ }
+}
+
+export default ActionsBar;
diff --git a/src/2.7.12/imports/ui/components/actions-bar/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/container.jsx
new file mode 100644
index 00000000..2075d3c0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/container.jsx
@@ -0,0 +1,85 @@
+import React, { useContext } from 'react';
+import { Meteor } from 'meteor/meteor';
+import { withTracker } from 'meteor/react-meteor-data';
+import { injectIntl } from 'react-intl';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import Auth from '/imports/ui/services/auth';
+import PresentationService from '/imports/ui/components/presentation/service';
+import Presentations from '/imports/api/presentations';
+import { UsersContext } from '../components-data/users-context/context';
+import ActionsBar from './component';
+import Service from './service';
+import UserListService from '/imports/ui/components/user-list/service';
+import ExternalVideoService from '/imports/ui/components/external-video-player/service';
+import CaptionsService from '/imports/ui/components/captions/service';
+import TimerService from '/imports/ui/components/timer/service';
+import { layoutSelectOutput, layoutDispatch } from '../layout/context';
+import { isExternalVideoEnabled, isPollingEnabled, isPresentationEnabled } from '/imports/ui/services/features';
+import { isScreenBroadcasting, isCameraAsContentBroadcasting } from '/imports/ui/components/screenshare/service';
+
+import MediaService from '../media/service';
+
+const ActionsBarContainer = (props) => {
+ const actionsBarStyle = layoutSelectOutput((i) => i.actionBar);
+ const layoutContextDispatch = layoutDispatch();
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+
+ const currentUser = { userId: Auth.userID, emoji: users[Auth.meetingID][Auth.userID].emoji };
+
+ const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
+
+ if (actionsBarStyle.display === false) return null;
+
+ return (
+
+ );
+};
+
+const SELECT_RANDOM_USER_ENABLED = Meteor.settings.public.selectRandomUser.enabled;
+const RAISE_HAND_BUTTON_ENABLED = Meteor.settings.public.app.raiseHandActionButton.enabled;
+const RAISE_HAND_BUTTON_CENTERED = Meteor.settings.public.app.raiseHandActionButton.centered;
+
+const isReactionsButtonEnabled = () => {
+ const USER_REACTIONS_ENABLED = Meteor.settings.public.userReaction.enabled;
+ const REACTIONS_BUTTON_ENABLED = Meteor.settings.public.app.reactionsButton.enabled;
+
+ return USER_REACTIONS_ENABLED && REACTIONS_BUTTON_ENABLED;
+};
+
+export default withTracker(() => ({
+ amIModerator: Service.amIModerator(),
+ stopExternalVideoShare: ExternalVideoService.stopWatching,
+ enableVideo: getFromUserSettings('bbb_enable_video', Meteor.settings.public.kurento.enableVideo),
+ setPresentationIsOpen: MediaService.setPresentationIsOpen,
+ handleTakePresenter: Service.takePresenterRole,
+ currentSlidHasContent: PresentationService.currentSlidHasContent(),
+ parseCurrentSlideContent: PresentationService.parseCurrentSlideContent,
+ isSharingVideo: Service.isSharingVideo(),
+ isSharedNotesPinned: Service.isSharedNotesPinned(),
+ hasScreenshare: isScreenBroadcasting(),
+ hasCameraAsContent: isCameraAsContentBroadcasting(),
+ isCaptionsAvailable: CaptionsService.isCaptionsAvailable(),
+ isTimerActive: TimerService.isActive(),
+ isTimerEnabled: TimerService.isEnabled(),
+ isMeteorConnected: Meteor.status().connected,
+ isPollingEnabled: isPollingEnabled() && isPresentationEnabled(),
+ isSelectRandomUserEnabled: SELECT_RANDOM_USER_ENABLED,
+ isRaiseHandButtonEnabled: RAISE_HAND_BUTTON_ENABLED,
+ isRaiseHandButtonCentered: RAISE_HAND_BUTTON_CENTERED,
+ isReactionsButtonEnabled: isReactionsButtonEnabled(),
+ isThereCurrentPresentation: Presentations.findOne({ meetingId: Auth.meetingID, current: true },
+ { fields: {} }),
+ allowExternalVideo: isExternalVideoEnabled(),
+ setEmojiStatus: UserListService.setEmojiStatus,
+}))(injectIntl(ActionsBarContainer));
diff --git a/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx
new file mode 100644
index 00000000..1859fac1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/component.jsx
@@ -0,0 +1,1481 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { range } from '/imports/utils/array-utils';
+import deviceInfo from '/imports/utils/deviceInfo';
+import Button from '/imports/ui/components/common/button/component';
+import { Session } from 'meteor/session';
+import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component';
+import SortList from './sort-user-list/component';
+import Styled from './styles';
+import Icon from '/imports/ui/components/common/icon/component';
+import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
+import { addNewAlert } from '/imports/ui/components/screenreader-alert/service';
+import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const intlMessages = defineMessages({
+ breakoutRoomTitle: {
+ id: 'app.createBreakoutRoom.title',
+ description: 'modal title',
+ },
+ breakoutRoomDesc: {
+ id: 'app.createBreakoutRoom.modalDesc',
+ description: 'modal description',
+ },
+ breakoutRoomUpdateDesc: {
+ id: 'app.updateBreakoutRoom.modalDesc',
+ description: 'update modal description',
+ },
+ cancelLabel: {
+ id: 'app.updateBreakoutRoom.cancelLabel',
+ description: 'used in the button that close update modal',
+ },
+ updateTitle: {
+ id: 'app.updateBreakoutRoom.title',
+ description: 'update breakout title',
+ },
+ updateConfirm: {
+ id: 'app.updateBreakoutRoom.confirm',
+ description: 'Update to breakout confirm button label',
+ },
+ resetUserRoom: {
+ id: 'app.update.resetRoom',
+ description: 'Reset user room button label',
+ },
+ confirmButton: {
+ id: 'app.createBreakoutRoom.confirm',
+ description: 'confirm button label',
+ },
+ dismissLabel: {
+ id: 'app.presentationUploder.dismissLabel',
+ description: 'used in the button that close modal',
+ },
+ numberOfRooms: {
+ id: 'app.createBreakoutRoom.numberOfRooms',
+ description: 'number of rooms label',
+ },
+ duration: {
+ id: 'app.createBreakoutRoom.durationInMinutes',
+ description: 'duration time label',
+ },
+ resetAssignments: {
+ id: 'app.createBreakoutRoom.resetAssignments',
+ description: 'reset assignments label',
+ },
+ resetAssignmentsDesc: {
+ id: 'app.createBreakoutRoom.resetAssignmentsDesc',
+ description: 'reset assignments label description',
+ },
+ randomlyAssign: {
+ id: 'app.createBreakoutRoom.randomlyAssign',
+ description: 'randomly assign label',
+ },
+ randomlyAssignDesc: {
+ id: 'app.createBreakoutRoom.randomlyAssignDesc',
+ description: 'randomly assign label description',
+ },
+ breakoutRoom: {
+ id: 'app.createBreakoutRoom.room',
+ description: 'breakout room',
+ },
+ freeJoinLabel: {
+ id: 'app.createBreakoutRoom.freeJoin',
+ description: 'free join label',
+ },
+ captureNotesLabel: {
+ id: 'app.createBreakoutRoom.captureNotes',
+ description: 'capture shared notes label',
+ },
+ captureSlidesLabel: {
+ id: 'app.createBreakoutRoom.captureSlides',
+ description: 'capture slides label',
+ },
+ captureNotesType: {
+ id: 'app.notes.label',
+ description: 'indicates notes have been captured',
+ },
+ captureSlidesType: {
+ id: 'app.shortcut-help.whiteboard',
+ description: 'indicates the whiteboard has been captured',
+ },
+ roomLabel: {
+ id: 'app.createBreakoutRoom.room',
+ description: 'Room label',
+ },
+ leastOneWarnBreakout: {
+ id: 'app.createBreakoutRoom.leastOneWarnBreakout',
+ description: 'warn message label',
+ },
+ notAssigned: {
+ id: 'app.createBreakoutRoom.notAssigned',
+ description: 'Not assigned label',
+ },
+ breakoutRoomLabel: {
+ id: 'app.createBreakoutRoom.breakoutRoomLabel',
+ description: 'breakout room label',
+ },
+ addParticipantLabel: {
+ id: 'app.createBreakoutRoom.addParticipantLabel',
+ description: 'add Participant label',
+ },
+ nextLabel: {
+ id: 'app.createBreakoutRoom.nextLabel',
+ description: 'Next label',
+ },
+ backLabel: {
+ id: 'app.audio.backLabel',
+ description: 'Back label',
+ },
+ minusRoomTime: {
+ id: 'app.createBreakoutRoom.minusRoomTime',
+ description: 'aria label for btn to decrease room time',
+ },
+ addRoomTime: {
+ id: 'app.createBreakoutRoom.addRoomTime',
+ description: 'aria label for btn to increase room time',
+ },
+ record: {
+ id: 'app.createBreakoutRoom.record',
+ description: 'label for checkbox to allow record',
+ },
+ roomTime: {
+ id: 'app.createBreakoutRoom.roomTime',
+ description: 'used to provide current room time for aria label',
+ },
+ numberOfRoomsIsValid: {
+ id: 'app.createBreakoutRoom.numberOfRoomsError',
+ description: 'Label an error message',
+ },
+ roomNameEmptyIsValid: {
+ id: 'app.createBreakoutRoom.emptyRoomNameError',
+ description: 'Label an error message',
+ },
+ roomNameDuplicatedIsValid: {
+ id: 'app.createBreakoutRoom.duplicatedRoomNameError',
+ description: 'Label an error message',
+ },
+ you: {
+ id: 'app.userList.you',
+ description: 'Text for identifying your user',
+ },
+ minimumDurationWarnBreakout: {
+ id: 'app.createBreakoutRoom.minimumDurationWarnBreakout',
+ description: 'minimum duration warning message label',
+ },
+ roomNameInputDesc: {
+ id: 'app.createBreakoutRoom.roomNameInputDesc',
+ description: 'aria description for room name change',
+ },
+ movedUserLabel: {
+ id: 'app.createBreakoutRoom.movedUserLabel',
+ description: 'screen reader alert when users are moved to rooms',
+ },
+ manageRooms: {
+ id: 'app.createBreakoutRoom.manageRoomsLabel',
+ description: 'Label for manage rooms',
+ },
+ sendInvitationToMods: {
+ id: 'app.createBreakoutRoom.sendInvitationToMods',
+ description: 'label for checkbox send invitation to moderators',
+ },
+ currentSlide: {
+ id: 'app.createBreakoutRoom.currentSlideLabel',
+ description: 'label for current slide',
+ },
+});
+
+const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit;
+const MIN_BREAKOUT_ROOMS = 2;
+const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
+const MIN_BREAKOUT_TIME = 5;
+const CURRENT_SLIDE_PREFIX = 'current-';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isMe: PropTypes.func.isRequired,
+ meetingName: PropTypes.string.isRequired,
+ users: PropTypes.arrayOf(PropTypes.object).isRequired,
+ createBreakoutRoom: PropTypes.func.isRequired,
+ getUsersNotJoined: PropTypes.func.isRequired,
+ getBreakouts: PropTypes.func.isRequired,
+ sendInvitation: PropTypes.func.isRequired,
+ isBreakoutRecordable: PropTypes.bool,
+};
+
+const setPresentationVisibility = (state) => {
+ const presentationInnerWrapper = document.getElementById('presentationInnerWrapper');
+ if (presentationInnerWrapper) {
+ presentationInnerWrapper.style.display = state;
+ }
+};
+
+class BreakoutRoom extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.changeNumberOfRooms = this.changeNumberOfRooms.bind(this);
+ this.changeDurationTime = this.changeDurationTime.bind(this);
+ this.changeUserRoom = this.changeUserRoom.bind(this);
+ this.increaseDurationTime = this.increaseDurationTime.bind(this);
+ this.decreaseDurationTime = this.decreaseDurationTime.bind(this);
+ this.onCreateBreakouts = this.onCreateBreakouts.bind(this);
+ this.setRoomUsers = this.setRoomUsers.bind(this);
+ this.setFreeJoin = this.setFreeJoin.bind(this);
+ this.getUserByRoom = this.getUserByRoom.bind(this);
+ this.onAssignRandomly = this.onAssignRandomly.bind(this);
+ this.onAssignReset = this.onAssignReset.bind(this);
+ this.onInviteBreakout = this.onInviteBreakout.bind(this);
+ this.renderUserItemByRoom = this.renderUserItemByRoom.bind(this);
+ this.renderRoomsGrid = this.renderRoomsGrid.bind(this);
+ this.renderBreakoutForm = this.renderBreakoutForm.bind(this);
+ this.renderCheckboxes = this.renderUnassingUsers.bind(this);
+ this.renderRoomSortList = this.renderRoomSortList.bind(this);
+ this.renderDesktop = this.renderDesktop.bind(this);
+ this.renderMobile = this.renderMobile.bind(this);
+ this.renderButtonSetLevel = this.renderButtonSetLevel.bind(this);
+ this.renderSelectUserScreen = this.renderSelectUserScreen.bind(this);
+ this.renderTitle = this.renderTitle.bind(this);
+ this.handleDismiss = this.handleDismiss.bind(this);
+ this.setInvitationConfig = this.setInvitationConfig.bind(this);
+ this.setRecord = this.setRecord.bind(this);
+ this.setCaptureNotes = this.setCaptureNotes.bind(this);
+ this.setInviteMods = this.setInviteMods.bind(this);
+ this.setCaptureSlides = this.setCaptureSlides.bind(this);
+ this.blurDurationTime = this.blurDurationTime.bind(this);
+ this.removeRoomUsers = this.removeRoomUsers.bind(this);
+ this.renderErrorMessages = this.renderErrorMessages.bind(this);
+ this.onUpdateBreakouts = this.onUpdateBreakouts.bind(this);
+ this.getRoomPresentation = this.getRoomPresentation.bind(this);
+
+ this.state = {
+ numberOfRooms: MIN_BREAKOUT_ROOMS,
+ selectedId: '',
+ users: [],
+ durationTime: 15,
+ freeJoin: false,
+ formFillLevel: 1,
+ roomNamesChanged: [],
+ roomSelected: 0,
+ preventClosing: true,
+ leastOneUserIsValid: null,
+ numberOfRoomsIsValid: true,
+ roomNameDuplicatedIsValid: true,
+ roomNameEmptyIsValid: true,
+ record: false,
+ captureNotes: false,
+ inviteMods: false,
+ captureSlides: false,
+ durationIsValid: true,
+ breakoutJoinedUsers: null,
+ enableUnassingUsers: null,
+ roomPresentations: [],
+ };
+
+ this.btnLevelId = uniqueId('btn-set-level-');
+
+ this.handleMoveEvent = this.handleMoveEvent.bind(this);
+ this.handleShiftUser = this.handleShiftUser.bind(this);
+ }
+
+ componentDidMount() {
+ const {
+ breakoutJoinedUsers, getLastBreakouts, groups, isUpdate,
+ allowUserChooseRoomByDefault, captureSharedNotesByDefault,
+ captureWhiteboardByDefault, inviteModsByDefault,
+ } = this.props;
+ setPresentationVisibility('none');
+ this.setRoomUsers();
+ if (isUpdate) {
+ const usersToMerge = []
+ breakoutJoinedUsers.forEach((breakout) => {
+ breakout.joinedUsers.forEach((user) => {
+ usersToMerge.push({
+ userId: user.userId,
+ userName: user.name,
+ from: breakout.sequence,
+ room: breakout.sequence,
+ isModerator: user.role === ROLE_MODERATOR,
+ joined: true,
+ });
+ });
+ });
+ this.setState((prevState) => {
+ return {
+ users: [
+ ...prevState.users,
+ ...usersToMerge,
+ ],
+ };
+ });
+ }
+ this.setState({
+ freeJoin: allowUserChooseRoomByDefault,
+ captureSlides: captureWhiteboardByDefault,
+ captureNotes: captureSharedNotesByDefault,
+ inviteMods: inviteModsByDefault,
+ });
+
+ const lastBreakouts = getLastBreakouts();
+ if (lastBreakouts.length > 0) {
+ this.populateWithLastBreakouts(lastBreakouts);
+ } else if (groups && groups.length > 0) {
+ this.populateWithGroups(groups);
+ }
+ }
+
+ componentDidUpdate(prevProps, prevstate) {
+ if (this.listOfUsers) {
+ for (let i = 0; i < this.listOfUsers.children.length; i += 1) {
+ const roomWrapperChildren = this.listOfUsers.children[i].getElementsByTagName('div');
+ const roomList = roomWrapperChildren[roomWrapperChildren.length > 1 ? 1 : 0];
+ roomList.addEventListener('keydown', this.handleMoveEvent, true);
+ }
+ }
+
+ const unassignedUsers = document.getElementById('breakoutBox-0');
+ if (unassignedUsers) {
+ unassignedUsers.addEventListener('keydown', this.handleMoveEvent, true);
+ }
+
+ const { numberOfRooms } = this.state;
+ const { users } = this.props;
+ const { users: prevUsers } = prevProps;
+ if (numberOfRooms < prevstate.numberOfRooms) {
+ this.resetUserWhenRoomsChange(numberOfRooms);
+ }
+ const usersCount = users.length;
+ const prevUsersCount = prevUsers.length;
+ if (usersCount > prevUsersCount) {
+ this.setRoomUsers();
+ }
+
+ if (usersCount < prevUsersCount) {
+ this.removeRoomUsers();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.listOfUsers) {
+ for (let i = 0; i < this.listOfUsers.children.length; i += 1) {
+ const roomList = this.listOfUsers.children[i].getElementsByTagName('div')[0];
+ roomList.removeEventListener('keydown', this.handleMoveEvent, true);
+ }
+ }
+ const unassignedUsers = document.getElementById('breakoutBox-0');
+ if (unassignedUsers) {
+ unassignedUsers.removeEventListener('keydown', this.handleMoveEvent, true);
+ }
+ }
+
+ handleShiftUser(activeListSibling) {
+ const { users } = this.state;
+ if (activeListSibling) {
+ const text = activeListSibling.getElementsByTagName('input')[0].value;
+ const roomNumber = text.match(/\d/g).join('');
+ users.forEach((u, index) => {
+ if (`roomUserItem-${u.userId}` === document.activeElement.id) {
+ users[index].room = text.substr(text.length - 1).includes(')') ? 0 : parseInt(roomNumber, 10);
+ this.changeUserRoom(u.userId, users[index].room);
+ }
+ });
+ } else {
+ users.forEach((u, index) => {
+ if (`roomUserItem-${u.userId}` === document.activeElement.id) {
+ this.changeUserRoom(u.userId, 0);
+ }
+ });
+ }
+ }
+
+ handleMoveEvent(event) {
+ if (this.listOfUsers) {
+ const { activeElement } = document;
+
+ if (event.key.includes('ArrowDown')) {
+ const {
+ nextElementSibling, id, childNodes, parentElement,
+ } = activeElement;
+ if (id.includes('breakoutBox')) return childNodes[0].focus();
+
+ if (id.includes('roomUserItem')) {
+ if (!nextElementSibling) {
+ return parentElement.firstElementChild.focus();
+ }
+ return nextElementSibling.focus();
+ }
+ }
+
+ if (event.key.includes('ArrowUp')) {
+ const {
+ previousElementSibling, id, childNodes, parentElement,
+ } = activeElement;
+ if (id.includes('breakoutBox')) return childNodes[childNodes.length - 1].focus();
+
+ if (id.includes('roomUserItem')) {
+ if (!previousElementSibling) {
+ return parentElement.lastElementChild.focus();
+ }
+ return previousElementSibling.focus();
+ }
+ }
+
+ if (event.key.includes('ArrowRight')) {
+ const { parentElement: listContainer } = activeElement;
+ if (listContainer.id.includes('breakoutBox')) {
+ this.handleShiftUser(listContainer.parentElement.nextSibling);
+ }
+ }
+
+ if (event.key.includes('ArrowLeft')) {
+ const { parentElement: listContainer } = activeElement;
+ if (listContainer.id.includes('breakoutBox')) {
+ this.handleShiftUser(listContainer.parentElement.previousSibling);
+ }
+ }
+
+ this.setRoomUsers();
+ }
+ return true;
+ }
+
+ handleDismiss() {
+ const { setIsOpen } = this.props;
+ setPresentationVisibility('block');
+ return new Promise((resolve) => {
+ setIsOpen(false);
+
+ this.setState({
+ preventClosing: false,
+ }, resolve);
+ });
+ }
+
+ onCreateBreakouts() {
+ setPresentationVisibility('block');
+ const {
+ createBreakoutRoom,
+ } = this.props;
+ const {
+ users,
+ freeJoin,
+ record,
+ captureNotes,
+ captureSlides,
+ numberOfRoomsIsValid,
+ numberOfRooms,
+ durationTime,
+ durationIsValid,
+ inviteMods,
+ } = this.state;
+
+ if ((durationTime || 0) < MIN_BREAKOUT_TIME) {
+ this.setState({ durationIsValid: false });
+ return;
+ }
+
+ if (users.length === this.getUserByRoom(0).length && !freeJoin) {
+ this.setState({ leastOneUserIsValid: false, enableUnassingUsers: false });
+ return;
+ }
+
+ if (!numberOfRoomsIsValid || !durationIsValid) {
+ return;
+ }
+
+ const duplicatedNames = range(1, numberOfRooms + 1).filter((n) => this.hasNameDuplicated(n));
+ if (duplicatedNames.length > 0) {
+ this.setState({ roomNameDuplicatedIsValid: false });
+ return;
+ }
+
+ const emptyNames = range(1, numberOfRooms + 1)
+ .filter((n) => this.getRoomName(n).length === 0);
+ if (emptyNames.length > 0) {
+ this.setState({ roomNameEmptyIsValid: false });
+ return;
+ }
+
+ this.handleDismiss();
+
+ const rooms = range(1, numberOfRooms + 1).map((seq) => ({
+ users: this.getUserByRoom(seq).map((u) => u.userId),
+ name: this.getFullName(seq),
+ captureNotesFilename: this.getCaptureFilename(seq, false),
+ captureSlidesFilename: this.getCaptureFilename(seq),
+ shortName: this.getRoomName(seq),
+ isDefaultName: !this.hasNameChanged(seq),
+ freeJoin,
+ sequence: seq,
+ allPages: !this.getRoomPresentation(seq).startsWith(CURRENT_SLIDE_PREFIX),
+ presId: this.getRoomPresentation(seq).replace(CURRENT_SLIDE_PREFIX, ''),
+ }));
+
+ createBreakoutRoom(rooms, durationTime, record, captureNotes, captureSlides, inviteMods);
+ Session.set('isUserListOpen', true);
+ }
+
+ onInviteBreakout() {
+ const { getBreakouts, sendInvitation } = this.props;
+ const { users, freeJoin } = this.state;
+ const breakouts = getBreakouts();
+ if (users.length === this.getUserByRoom(0).length && !freeJoin) {
+ this.setState({ leastOneUserIsValid: false });
+ return;
+ }
+ if (users.length === this.getUserByRoom(0).length) {
+ this.setState({ enableUnassingUsers: false });
+ return;
+ }
+
+ breakouts.forEach((breakout) => {
+ const { breakoutId } = breakout;
+ const breakoutUsers = this.getUserByRoom(breakout.sequence);
+ breakoutUsers.forEach((user) => sendInvitation(breakoutId, user.userId));
+ });
+
+ this.handleDismiss();
+ }
+
+ getBreakoutBySequence(sequence) {
+ const { getBreakouts } = this.props;
+ const breakouts = getBreakouts();
+
+ return breakouts.find((breakout) => breakout.sequence === sequence);
+ }
+
+ changeUserBreakout(fromBreakoutId, toBreakoutId, userId) {
+ const { moveUser } = this.props;
+
+ moveUser(fromBreakoutId, toBreakoutId, userId);
+ }
+
+ onUpdateBreakouts() {
+ setPresentationVisibility('block');
+ const { users } = this.state;
+ const leastOneUserIsValid = users.some((user) => user.from !== user.room);
+
+ if (!leastOneUserIsValid) {
+ this.setState({ leastOneUserIsValid });
+ }
+
+ users.forEach((user) => {
+ const { from, room } = user;
+ let { userId } = user;
+
+ if (from === room) return;
+
+ let toBreakoutId = '';
+ if (room !== 0) {
+ const toBreakout = this.getBreakoutBySequence(room);
+ toBreakoutId = toBreakout.breakoutId;
+ }
+
+ let fromBreakoutId = '';
+ if (from !== 0) {
+ [userId] = userId.split('-');
+ const fromBreakout = this.getBreakoutBySequence(from);
+ fromBreakoutId = fromBreakout.breakoutId;
+ }
+
+ this.changeUserBreakout(fromBreakoutId, toBreakoutId, userId);
+ });
+
+ this.handleDismiss();
+ }
+
+ onAssignRandomly() {
+ const { numberOfRooms } = this.state;
+ this.setState({ hasUsersAssign: false });
+ const { users } = this.state;
+ // We only want to assign viewers so filter out the moderators. We also want to get
+ // all users each run so that clicking the button again will reshuffle
+ const viewers = users.filter((user) => !user.isModerator);
+ // We want to keep assigning users until all viewers have been assigned a room
+ while (viewers.length > 0) {
+ // We cycle through the rooms picking one user for each room so that the rooms
+ // will have an equal number of people in each one
+ for (let i = 1; i <= numberOfRooms && viewers.length > 0; i += 1) {
+ // Select a random user for the room
+ const userIdx = Math.floor(Math.random() * (viewers.length));
+ this.changeUserRoom(viewers[userIdx].userId, i);
+ // Remove the picked user so they aren't selected again
+ viewers.splice(userIdx, 1);
+ }
+ }
+ }
+
+ onAssignReset() {
+ const { users } = this.state;
+ this.setState({ hasUsersAssign: true });
+
+ users.forEach((u) => {
+ if (u.room !== null && u.room > 0) {
+ this.changeUserRoom(u.userId, 0);
+ }
+ });
+ }
+
+ setInvitationConfig() {
+ const { getBreakouts } = this.props;
+ this.setState({
+ numberOfRooms: getBreakouts().length,
+ formFillLevel: 2,
+ });
+ }
+
+ setRoomUsers() {
+ const { users, getUsersNotJoined } = this.props;
+ const { users: stateUsers } = this.state;
+ const stateUsersId = stateUsers.map((user) => user.userId);
+ const roomUsers = getUsersNotJoined(users)
+ .filter((user) => !stateUsersId.includes(user.userId))
+ .map((user) => ({
+ userId: user.userId,
+ extId: user.extId,
+ userName: user.name,
+ isModerator: user.role === ROLE_MODERATOR,
+ from: 0,
+ room: 0,
+ }));
+
+ this.setState({
+ users: [
+ ...stateUsers,
+ ...roomUsers,
+ ],
+ });
+ }
+
+ setFreeJoin(e) {
+ this.setState({
+ freeJoin: e.target.checked,
+ leastOneUserIsValid: true,
+ });
+ }
+
+ setRecord(e) {
+ this.setState({ record: e.target.checked });
+ }
+
+ setCaptureNotes(e) {
+ this.setState({ captureNotes: e.target.checked });
+ }
+
+ setInviteMods(e) {
+ this.setState({ inviteMods: e.target.checked });
+ }
+
+ setCaptureSlides(e) {
+ this.setState({ captureSlides: e.target.checked });
+ }
+
+ getUserByRoom(room) {
+ const { users } = this.state;
+ return users.filter((user) => user.room === room);
+ }
+
+ getUsersByRoomSequence(sequence) {
+ const { breakoutJoinedUsers } = this.state;
+ if (!breakoutJoinedUsers) return [];
+ return breakoutJoinedUsers.filter((room) => room.sequence === sequence)[0].joinedUsers || [];
+ }
+
+ getRoomName(position, padWithZeroes = false) {
+ const { intl } = this.props;
+ const { roomNamesChanged } = this.state;
+
+ if (this.hasNameChanged(position)) {
+ return roomNamesChanged[position];
+ }
+
+ return intl.formatMessage(intlMessages.breakoutRoom, {
+ 0: padWithZeroes ? `${position}`.padStart(2, '0') : position
+ });
+ }
+
+ getRoomPresentation(position) {
+ const { roomPresentations } = this.state;
+ const { presentations } = this.props;
+
+ const currentPresentation = presentations.find((presentation) => presentation.current);
+
+ return roomPresentations[position] || `${CURRENT_SLIDE_PREFIX}${currentPresentation?.id}`;
+ }
+
+ getFullName(position) {
+ const { meetingName } = this.props;
+
+ return `${meetingName} (${this.getRoomName(position)})`;
+ }
+
+ getCaptureFilename(position, slides = true) {
+ const { intl } = this.props;
+ const presentations = PresentationUploaderService.getPresentations();
+
+ const captureType = slides
+ ? intl.formatMessage(intlMessages.captureSlidesType)
+ : intl.formatMessage(intlMessages.captureNotesType);
+
+ const fileName = `${this.getRoomName(position, true)}_${captureType}`.replace(/ /g, '_');
+
+ const fileNameDuplicatedCount = presentations.filter((pres) => pres.filename?.startsWith(fileName)
+ || pres.name?.startsWith(fileName)).length;
+
+ return fileNameDuplicatedCount === 0 ? fileName : `${fileName}(${fileNameDuplicatedCount + 1})`;
+ }
+
+ resetUserWhenRoomsChange(rooms) {
+ const { users } = this.state;
+ const filtredUsers = users.filter((u) => u.room > rooms);
+ filtredUsers.forEach((u) => this.changeUserRoom(u.userId, 0));
+ }
+
+ changeUserRoom(userId, room) {
+ const { intl } = this.props;
+ const { users, freeJoin } = this.state;
+
+ const idxUser = users.findIndex((user) => user.userId === userId.replace('roomUserItem-', ''));
+
+ const usersCopy = [...users];
+ let userName = null;
+
+ if (idxUser >= 0) {
+ usersCopy[idxUser].room = room;
+ userName = usersCopy[idxUser].userName;
+ };
+
+ this.setState({
+ users: usersCopy,
+ leastOneUserIsValid: (this.getUserByRoom(0).length !== users.length || freeJoin),
+ enableUnassingUsers: (this.getUserByRoom(0).length !== users.length),
+ }, () => {
+ addNewAlert(intl.formatMessage(intlMessages.movedUserLabel, { 0: userName, 1: room }))
+ });
+ }
+
+ increaseDurationTime() {
+ const { durationTime } = this.state;
+ const number = ((1 * durationTime) + 1);
+ const newDurationTime = number > MIN_BREAKOUT_TIME ? number : MIN_BREAKOUT_TIME;
+
+ this.setState({ durationTime: newDurationTime, durationIsValid: true });
+ }
+
+ decreaseDurationTime() {
+ const { durationTime } = this.state;
+ const number = ((1 * durationTime) - 1);
+ const newDurationTime = number > MIN_BREAKOUT_TIME ? number : MIN_BREAKOUT_TIME;
+
+ this.setState({ durationTime: newDurationTime, durationIsValid: true });
+ }
+
+ changeDurationTime(event) {
+ const durationTime = Number.parseInt(event.target.value, 10) || '';
+ const durationIsValid = durationTime >= MIN_BREAKOUT_TIME;
+
+ this.setState({ durationTime, durationIsValid });
+ }
+
+ blurDurationTime(event) {
+ const value = Number.parseInt(event.target.value, 10);
+ this.setState({ durationTime: !(value <= 0) ? value : 1 });
+ }
+
+ changeNumberOfRooms(event) {
+ const numberOfRooms = Number.parseInt(event.target.value, 10);
+ this.setState({
+ numberOfRooms,
+ numberOfRoomsIsValid: numberOfRooms <= MAX_BREAKOUT_ROOMS
+ && numberOfRooms >= MIN_BREAKOUT_ROOMS,
+ });
+ }
+
+ removeRoomUsers() {
+ const { users } = this.props;
+ const { users: stateUsers } = this.state;
+ const userIds = users.map((user) => user.userId);
+ const removeUsers = stateUsers.filter((user) => userIds.includes(user.userId));
+
+ this.setState({
+ users: removeUsers,
+ });
+ }
+
+ hasNameChanged(position) {
+ const { intl } = this.props;
+ const { roomNamesChanged } = this.state;
+
+ if (typeof roomNamesChanged[position] !== 'undefined'
+ && roomNamesChanged[position] !== intl
+ .formatMessage(intlMessages.breakoutRoom, { 0: position })) {
+ return true;
+ }
+ return false;
+ }
+
+ hasNameDuplicated(position) {
+ const { numberOfRooms } = this.state;
+ const currName = this.getRoomName(position).trim();
+ const equals = range(1, numberOfRooms + 1)
+ .filter((n) => this.getRoomName(n).trim() === currName);
+ if (equals.length > 1) return true;
+
+ return false;
+ }
+
+ populateWithLastBreakouts(lastBreakouts) {
+ const { getBreakoutUserWasIn, users, intl } = this.props;
+
+ const changedNames = [];
+ lastBreakouts.forEach((breakout) => {
+ if (breakout.isDefaultName === false) {
+ changedNames[breakout.sequence] = breakout.shortName;
+ }
+ });
+
+ this.setState({
+ roomNamesChanged: changedNames,
+ numberOfRooms: lastBreakouts.length,
+ roomNameDuplicatedIsValid: true,
+ roomNameEmptyIsValid: true,
+ }, () => {
+ const rooms = range(1, lastBreakouts.length + 1).map((seq) => this.getRoomName(seq));
+
+ users.forEach((u) => {
+ const lastUserBreakout = getBreakoutUserWasIn(u.userId, u.extId);
+ if (lastUserBreakout !== null) {
+ const lastUserBreakoutName = lastUserBreakout.isDefaultName === false
+ ? lastUserBreakout.shortName
+ : intl.formatMessage(intlMessages.breakoutRoom, { 0: lastUserBreakout.sequence });
+
+ if (rooms.indexOf(lastUserBreakoutName) !== false) {
+ this.changeUserRoom(u.userId, rooms.indexOf(lastUserBreakoutName) + 1);
+ }
+ }
+ });
+ });
+ }
+
+ populateWithGroups(groups) {
+ const { users } = this.props;
+
+ const changedNames = [];
+ groups.forEach((group, idx) => {
+ if (group.name.length > 0) {
+ changedNames[idx + 1] = group.name;
+ }
+ });
+
+ this.setState({
+ roomNamesChanged: changedNames,
+ numberOfRooms: groups.length > 1 ? groups.length : 2,
+ roomNameDuplicatedIsValid: true,
+ roomNameEmptyIsValid: true,
+ }, () => {
+ groups.forEach((group, groupIdx) => {
+ const usersInGroup = group.usersExtId;
+ if (usersInGroup.length > 0) {
+ usersInGroup.forEach((groupUserExtId) => {
+ users.filter((u) => u.extId === groupUserExtId).forEach((foundUser) => {
+ this.changeUserRoom(foundUser.userId, groupIdx + 1);
+ });
+ });
+ }
+ });
+ });
+ }
+
+ renderContent() {
+ const { intl } = this.props;
+ const {
+ leastOneUserIsValid,
+ allowDrop,
+ } = this.state;
+ const drop = (room) => (ev) => {
+ ev.preventDefault();
+ const data = ev.dataTransfer.getData('text');
+ this.changeUserRoom(data, room);
+ this.setState({ selectedId: '' });
+ };
+
+ return (
+
+
+
+
+
+
+ {this.renderUserItemByRoom(0)}
+
+
+ {intl.formatMessage(intlMessages.leastOneWarnBreakout)}
+
+
+ {this.renderRoomsGrid()}
+
+ );
+ }
+
+ renderRoomsGrid() {
+ const { intl, isUpdate, presentations } = this.props;
+ const {
+ leastOneUserIsValid,
+ numberOfRooms,
+ roomNamesChanged,
+ roomPresentations,
+ } = this.state;
+
+ const rooms = (numberOfRooms > MAX_BREAKOUT_ROOMS
+ || numberOfRooms < MIN_BREAKOUT_ROOMS)
+ ? 0 : numberOfRooms;
+ const allowDrop = (ev) => {
+ ev.preventDefault();
+ };
+
+ const drop = (room) => (ev) => {
+ ev.preventDefault();
+ const data = ev.dataTransfer.getData('text');
+ this.changeUserRoom(data, room);
+ this.setState({ selectedId: '' });
+ };
+
+ const changeRoomName = (position) => (ev) => {
+ const newRoomsNames = [...roomNamesChanged];
+ newRoomsNames[position] = ev.target.value;
+
+ this.setState({
+ roomNamesChanged: newRoomsNames,
+ roomNameDuplicatedIsValid: true,
+ roomNameEmptyIsValid: true,
+ });
+ };
+
+ const changeRoomPresentation = (position) => (ev) => {
+ const newRoomsPresentations = [...roomPresentations];
+ newRoomsPresentations[position] = ev.target.value;
+
+ this.setState({
+ roomPresentations: newRoomsPresentations,
+ });
+ };
+
+ const currentPresentation = presentations.find((presentation) => presentation.current);
+
+ return (
+ { this.listOfUsers = r; }} data-test="roomGrid">
+ {
+ range(1, rooms + 1).map((value) => (
+
+
+
+
+ {intl.formatMessage(intlMessages.roomNameInputDesc)}
+
+
+ { presentations.length > 0 && !isUpdate ? (
+
+
+ { currentPresentation?.id ? (
+
+ {intl.formatMessage(intlMessages.currentSlide)}
+
+ ) : null }
+ {
+ presentations.map((presentation) => (
+
+ {presentation.name}
+
+ ))
+ }
+
+
+ ) : null }
+
+ {this.renderUserItemByRoom(value)}
+
+ {this.hasNameDuplicated(value) ? (
+
+ {intl.formatMessage(intlMessages.roomNameDuplicatedIsValid)}
+
+ ) : null}
+ {this.getRoomName(value).length === 0 ? (
+
+ {intl.formatMessage(intlMessages.roomNameEmptyIsValid)}
+
+ ) : null}
+
+ ))
+ }
+
+ );
+ }
+
+ renderBreakoutForm() {
+ const {
+ intl,
+ isUpdate,
+ isBreakoutRecordable,
+ } = this.props;
+ const {
+ numberOfRooms,
+ durationTime,
+ numberOfRoomsIsValid,
+ durationIsValid,
+ freeJoin,
+ record,
+ captureNotes,
+ captureSlides,
+ inviteMods,
+ } = this.state;
+ if (isUpdate) return null;
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages.numberOfRooms)}
+
+
+ {
+ range(MIN_BREAKOUT_ROOMS, MAX_BREAKOUT_ROOMS + 1).map((item) => ({item} ))
+ }
+
+
+
+
+ {intl.formatMessage(intlMessages.duration)}
+
+
+
+
+
+ {
+ intl.formatMessage(
+ intlMessages.minimumDurationWarnBreakout,
+ { 0: MIN_BREAKOUT_TIME },
+ )
+ }
+
+
+
+
+
+ {intl.formatMessage(intlMessages.freeJoinLabel)}
+
+ {
+ isBreakoutRecordable ? (
+
+
+
+ {intl.formatMessage(intlMessages.record)}
+
+
+ ) : null
+ }
+ {
+ isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled() ? (
+
+
+
+ {intl.formatMessage(intlMessages.captureSlidesLabel)}
+
+
+ ) : null
+ }
+ {
+ isImportSharedNotesFromBreakoutRoomsEnabled() ? (
+
+
+
+ {intl.formatMessage(intlMessages.captureNotesLabel)}
+
+
+ ) : null
+ }
+
+
+
+ {intl.formatMessage(intlMessages.sendInvitationToMods)}
+
+
+
+
+
+ {intl.formatMessage(intlMessages.numberOfRoomsIsValid)}
+
+
+ {intl.formatMessage(intlMessages.randomlyAssignDesc)}
+
+
+
+ );
+ }
+
+ renderSelectUserScreen() {
+ const {
+ users,
+ roomSelected,
+ } = this.state;
+
+ return (
+ this.setState({ formFillLevel: 2 })}
+ users={users}
+ room={roomSelected}
+ onCheck={this.changeUserRoom}
+ onUncheck={(userId) => this.changeUserRoom(userId, 0)}
+ />
+ );
+ }
+
+ renderUnassingUsers() {
+ const {
+ intl,
+ isUpdate,
+ } = this.props;
+ if (isUpdate) return null;
+ const {
+ numberOfRoomsIsValid,
+ enableUnassingUsers,
+ } = this.state;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.manageRooms)}
+
+ {enableUnassingUsers ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ renderUserItemByRoom(room) {
+ const {
+ leastOneUserIsValid,
+ selectedId,
+ } = this.state;
+
+ const { intl, isMe } = this.props;
+
+ const dragStart = (ev) => {
+ ev.dataTransfer.setData('text', ev.target.id);
+ this.setState({ selectedId: ev.target.id });
+
+ if (!leastOneUserIsValid) {
+ this.setState({ leastOneUserIsValid: true });
+ }
+ };
+
+ const dragEnd = () => {
+ this.setState({ selectedId: '' });
+ };
+
+ return this.getUserByRoom(room)
+ .map((user) => (
+
+
+ {user.userName}
+ {(isMe(user.userId)) ? ` (${intl.formatMessage(intlMessages.you)})` : ''}
+
+ {room !== user.from ? (
+ this.changeUserRoom(user.userId, user.from)}
+ >
+
+
+ ) : null}
+
+ ));
+ }
+
+ renderRoomSortList() {
+ const { intl } = this.props;
+ const { numberOfRooms } = this.state;
+ const onClick = (roomNumber) => this.setState({ formFillLevel: 3, roomSelected: roomNumber });
+ return (
+
+
+ {
+ new Array(numberOfRooms).fill(1).map((room, idx) => (
+
+
+ {intl.formatMessage(intlMessages.breakoutRoomLabel, { 0: idx + 1 })}
+
+ onClick(idx + 1)}
+ />
+
+ ))
+ }
+
+ {this.renderButtonSetLevel(1, intl.formatMessage(intlMessages.backLabel))}
+
+ );
+ }
+
+ renderErrorMessages() {
+ const {
+ intl,
+ } = this.props;
+ const {
+ leastOneUserIsValid,
+ numberOfRoomsIsValid,
+ roomNameDuplicatedIsValid,
+ roomNameEmptyIsValid,
+ } = this.state;
+ return (
+ <>
+ {!leastOneUserIsValid
+ && (
+
+ {intl.formatMessage(intlMessages.leastOneWarnBreakout)}
+
+ )}
+ {!numberOfRoomsIsValid
+ && (
+
+ {intl.formatMessage(intlMessages.numberOfRoomsIsValid)}
+
+ )}
+ {!roomNameDuplicatedIsValid
+ && (
+
+ {intl.formatMessage(intlMessages.roomNameDuplicatedIsValid)}
+
+ )}
+ {!roomNameEmptyIsValid
+ && (
+
+ {intl.formatMessage(intlMessages.roomNameEmptyIsValid)}
+
+ )}
+ >
+ );
+ }
+
+ renderDesktop() {
+ return [
+ this.renderBreakoutForm(),
+ this.renderUnassingUsers(),
+ this.renderContent(),
+ ];
+ }
+
+ renderMobile() {
+ const { intl } = this.props;
+ const { formFillLevel } = this.state;
+ if (formFillLevel === 2) {
+ return [
+ this.renderErrorMessages(),
+ this.renderRoomSortList(),
+ ];
+ }
+
+ if (formFillLevel === 3) {
+ return [
+ this.renderErrorMessages(),
+ this.renderSelectUserScreen(),
+ ];
+ }
+
+ return [
+ this.renderErrorMessages(),
+ this.renderBreakoutForm(),
+ this.renderUnassingUsers(),
+ this.renderButtonSetLevel(2, intl.formatMessage(intlMessages.nextLabel)),
+ ];
+ }
+
+ renderButtonSetLevel(level, label) {
+ return (
+ this.setState({ formFillLevel: level })}
+ key={this.btnLevelId}
+ />
+ );
+ }
+
+ renderTitle() {
+ const { intl, isUpdate } = this.props;
+ return (
+
+ {isUpdate
+ ? intl.formatMessage(intlMessages.breakoutRoomUpdateDesc)
+ : intl.formatMessage(intlMessages.breakoutRoomDesc)}
+
+ );
+ }
+
+ render() {
+ const { intl, isUpdate, isOpen, priority, setIsOpen, } = this.props;
+ const {
+ preventClosing,
+ leastOneUserIsValid,
+ numberOfRoomsIsValid,
+ roomNameDuplicatedIsValid,
+ roomNameEmptyIsValid,
+ durationIsValid,
+ } = this.state;
+
+ const { isMobile } = deviceInfo;
+
+ return (
+
+
+ {this.renderTitle()}
+ {isMobile ? this.renderMobile() : this.renderDesktop()}
+
+
+ );
+ }
+}
+
+BreakoutRoom.propTypes = propTypes;
+
+export default injectIntl(BreakoutRoom);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/container.jsx
new file mode 100644
index 00000000..ef31af5d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/container.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ActionsBarService from '/imports/ui/components/actions-bar/service';
+import BreakoutRoomService from '/imports/ui/components/breakout-room/service';
+import CreateBreakoutRoomModal from './component';
+import Presentations from '/imports/api/presentations';
+import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
+
+const METEOR_SETTINGS_APP = Meteor.settings.public.app;
+
+const CreateBreakoutRoomContainer = (props) => {
+ const { allowUserChooseRoomByDefault } = METEOR_SETTINGS_APP.breakouts;
+ const captureWhiteboardByDefault = METEOR_SETTINGS_APP.breakouts.captureWhiteboardByDefault
+ && isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled();
+ const captureSharedNotesByDefault = METEOR_SETTINGS_APP.breakouts.captureSharedNotesByDefault
+ && isImportSharedNotesFromBreakoutRoomsEnabled();
+ const inviteModsByDefault = METEOR_SETTINGS_APP.breakouts.sendInvitationToAssignedModeratorsByDefault;
+
+ const { amIModerator } = props;
+ return (
+ amIModerator
+ && (
+
+ )
+ );
+};
+
+export default withTracker(() => ({
+ createBreakoutRoom: ActionsBarService.createBreakoutRoom,
+ getBreakouts: ActionsBarService.getBreakouts,
+ getLastBreakouts: ActionsBarService.getLastBreakouts,
+ getBreakoutUserWasIn: BreakoutRoomService.getBreakoutUserWasIn,
+ getUsersNotJoined: ActionsBarService.getUsersNotJoined,
+ sendInvitation: ActionsBarService.sendInvitation,
+ breakoutJoinedUsers: ActionsBarService.breakoutJoinedUsers(),
+ users: ActionsBarService.users(),
+ groups: ActionsBarService.groups(),
+ isMe: ActionsBarService.isMe,
+ meetingName: ActionsBarService.meetingName(),
+ amIModerator: ActionsBarService.amIModerator(),
+ moveUser: ActionsBarService.moveUser,
+ presentations: Presentations.find({ 'conversion.done': true }).fetch(),
+}))(CreateBreakoutRoomContainer);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/component.jsx
new file mode 100644
index 00000000..86ca8ea6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/component.jsx
@@ -0,0 +1,151 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const propTypes = {
+ confirm: PropTypes.func.isRequired,
+ users: PropTypes.arrayOf(PropTypes.object).isRequired,
+ room: PropTypes.number.isRequired,
+ onCheck: PropTypes.func,
+ onUncheck: PropTypes.func,
+};
+
+const defaultProps = {
+ onCheck: () => { },
+ onUncheck: () => { },
+};
+
+const intlMessages = defineMessages({
+ breakoutRoomLabel: {
+ id: 'app.createBreakoutRoom.breakoutRoomLabel',
+ description: 'breakout room label',
+ },
+ doneLabel: {
+ id: 'app.createBreakoutRoom.doneLabel',
+ description: 'done label',
+ },
+});
+
+class SortUsers extends Component {
+ constructor(props) {
+ super(props);
+
+ this.setUsers = this.setUsers.bind(this);
+ this.renderUserItem = this.renderUserItem.bind(this);
+ this.onChage = this.onChage.bind(this);
+ this.renderJoinedUserItem = this.renderJoinedUserItem.bind(this);
+
+ this.state = {
+ users: [],
+ joinedUsers: [],
+ };
+ }
+
+ componentDidMount() {
+ const { users, breakoutJoinedUsers } = this.props;
+
+ this.setUsers(users);
+ this.setJoinedUsers(breakoutJoinedUsers);
+ }
+
+ onChage(userId, room) {
+ const {
+ onCheck,
+ onUncheck,
+ } = this.props;
+ return (ev) => {
+ const check = ev.target.checked;
+ if (check) {
+ return onCheck(userId, room);
+ }
+ return onUncheck(userId, room);
+ };
+ }
+
+ setUsers(users) {
+ this.setState({ users: users.sort((a, b) => a.room - b.room) });
+ }
+
+ setJoinedUsers(users) {
+ if (!users) return;
+ this.setState({ joinedUsers: users.sort((a, b) => a.sequence - b.sequence) });
+ }
+
+ renderUserItem() {
+ const { room } = this.props;
+ const { users } = this.state;
+ return users
+ .map((user, idx) => (
+
+
+
+
+
+
+
+
+ {user.userName}
+ {user.room && !(user.room === room) ? `\t[${user.room}]` : ''}
+
+
+ ));
+ }
+
+ renderJoinedUserItem() {
+ const { joinedUsers } = this.state;
+ if (!joinedUsers.length) return null;
+
+ return joinedUsers
+ .map((b) => b.joinedUsers.map((u) => ({ ...u, room: b.sequence })))
+ .flat()
+ .map((user) => (
+
+
+
+ {user.name}
+ {`\t[${user.room}]`}
+
+
+ ));
+ }
+
+ render() {
+ const {
+ intl,
+ room,
+ confirm,
+ } = this.props;
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.breakoutRoomLabel, { 0: room })}
+
+
+
+ {this.renderUserItem()}
+ {this.renderJoinedUserItem()}
+
+ );
+ }
+}
+SortUsers.propTypes = propTypes;
+SortUsers.defaultProps = defaultProps;
+
+export default injectIntl(SortUsers);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/styles.js b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/styles.js
new file mode 100644
index 00000000..3c932e2f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/sort-user-list/styles.js
@@ -0,0 +1,133 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { TextElipsis, TitleElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
+import Styled from '/imports/ui/components/actions-bar/create-breakout-room/styles';
+import { colorWhite, colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+
+const SelectUserContainer = styled.div`
+ margin: 1.5rem 1rem;
+`;
+
+const Round = styled.span`
+ position: relative;
+
+ & > label {
+ margin-top: -10px;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-radius: 50%;
+ cursor: pointer;
+ height: 28px;
+ left: 0;
+ right : auto;
+ position: absolute;
+ top: 0;
+ width: 28px;
+
+ [dir="rtl"] & {
+ left : auto;
+ right: 0;
+ }
+ }
+
+ & > label:after {
+ border: {
+ style: solid;
+ color: #fff;
+ width: 2px;
+ right: {
+ style : none;
+ }
+ top: {
+ style: none;
+ }
+ }
+ content: "";
+ height: 6px;
+ left: 7px;
+ opacity: 0;
+ position: absolute;
+ top: 8px;
+ transform: rotate(-45deg);
+ width: 12px;
+
+ [dir="rtl"] & {
+ border: {
+ style: solid;
+ color: #fff;
+ width: 2px;
+ left: {
+ style : none;
+ }
+ top: {
+ style: none;
+ }
+ }
+ }
+ }
+
+ & > input[type="checkbox"] {
+ visibility: hidden;
+ }
+
+ & > input[type="checkbox"]:checked + label {
+ background-color: #66bb6a;
+ border-color: #66bb6a;
+ }
+
+ & > input[type="checkbox"]:checked + label:after {
+ opacity: 1;
+ }
+`;
+
+const TextName = styled(TextElipsis)`
+ margin-left: 1.5rem;
+`;
+
+const LockIcon = styled(Styled.LockIcon)`
+background:red;
+`;
+
+const SelectUserScreen = styled.div`
+ position: fixed;
+ display: block;
+ height: 100vh;
+ width: 100%;
+ background-color: ${colorWhite};
+ z-index: 1002;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+`;
+
+const Header = styled.header`
+ display: flex;
+ padding: ${lineHeightComputed} 0;
+ border-bottom: ${borderSize} solid ${colorGrayLighter};
+ margin: 0 1rem 0 1rem;
+`;
+
+const Title = styled(TitleElipsis)`
+ align-content: flex-end;
+ flex: 1;
+ margin: 0;
+ font-weight: 400;
+`;
+
+const ButtonAdd = styled(Button)`
+ flex: 0 1 35%;
+`;
+
+export default {
+ SelectUserContainer,
+ Round,
+ TextName,
+ LockIcon,
+ SelectUserScreen,
+ Header,
+ Title,
+ ButtonAdd,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/styles.js b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/styles.js
new file mode 100644
index 00000000..956e15c5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/create-breakout-room/styles.js
@@ -0,0 +1,390 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+import HoldButton from '/imports/ui/components/presentation/presentation-toolbar/zoom-tool/holdButton/component';
+import Button from '/imports/ui/components/common/button/component';
+import { FlexRow, FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders';
+import {
+ colorDanger,
+ colorGray,
+ colorGrayLight,
+ colorGrayLighter,
+ colorWhite,
+ colorPrimary,
+ colorBlueLight,
+ colorBlueLightest,
+ colorGrayLightest,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeSmall, fontSizeBase, fontSizeSmaller } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ borderRadius,
+ borderSize,
+ lgPaddingX,
+ lgPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+
+const BoxContainer = styled.div`
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ grid-gap: 1.6rem 1rem;
+ box-sizing: border-box;
+ padding-bottom: 1rem;
+`;
+
+const ContentContainer = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ grid-template-areas: "sidebar content";
+ grid-gap: 1.5rem;
+`;
+
+const Alert = styled.div`
+ grid-area: sidebar;
+ margin-bottom: 2.5rem;
+ ${({ valid }) => valid === false && `
+ position: relative;
+
+ & > * {
+ border-color: ${colorDanger} !important;
+ color: ${colorDanger};
+ }
+ `}
+`;
+
+const FreeJoinLabel = styled.label`
+ font-size: ${fontSizeSmall};
+ font-weight: bolder;
+ display: flex;
+ align-items: center;
+ font-size: ${fontSizeSmall};
+ margin-bottom: 0.2rem;
+
+ & > * {
+ margin: 0 .5rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 .5rem;
+ }
+ }
+`;
+
+const BreakoutSlideLabel = styled.label`
+ font-size: ${fontSizeSmall};
+ font-weight: bolder;
+ display: flex;
+ align-items: center;
+ font-size: ${fontSizeSmall};
+ margin-bottom: 0.2rem;
+`
+
+const BreakoutNameInput = styled.input`
+ width: 100%;
+ text-align: center;
+ font-weight: 600;
+ padding: .25rem .25rem .25rem 0;
+ margin: 0;
+ &::placeholder {
+ color: ${colorGray};
+ opacity: 1;
+ }
+ border: 1px solid ${colorGrayLightest};
+ margin-bottom: 1rem;
+
+ ${({ readOnly }) => readOnly && `
+ cursor: default;
+ `}
+`;
+
+const BreakoutBox = styled(ScrollboxVertical)`
+ width: 100%;
+ height: 10rem;
+ border: 1px solid ${colorGrayLightest};
+ border-radius: ${borderRadius};
+ padding: ${lgPaddingY} 0;
+
+ ${({ hundred }) => hundred && `
+ height: 100%;
+ `}
+`;
+
+const SpanWarn = styled.span`
+ ${({ valid }) => valid && `
+ display: none;
+ `}
+
+ ${({ valid }) => !valid && `
+ margin-top: .75rem;
+ position: absolute;
+ font-size: ${fontSizeSmall};
+ color: ${colorDanger};
+ font-weight: 200;
+ white-space: nowrap;
+ z-index: 1;
+ `}
+`;
+
+const RoomName = styled(BreakoutNameInput)`
+ ${({ value }) => value.length === 0 && `
+ border-color: ${colorDanger} !important;
+ `}
+
+ ${({ duplicated }) => duplicated === 0 && `
+ border-color: ${colorDanger} !important;
+ `}
+`;
+
+const BreakoutSettings = styled.div`
+ display: grid;
+ grid-template-columns: 1fr 1fr 2fr;
+ grid-template-rows: 1fr;
+ grid-gap: 2rem;
+
+ @media ${smallOnly} {
+ grid-template-columns: 1fr ;
+ grid-template-rows: 1fr 1fr 1fr;
+ flex-direction: column;
+ }
+`;
+
+const FormLabel = styled.p`
+ color: ${colorGray};
+ white-space: nowrap;
+ margin-bottom: .5rem;
+
+ ${({ valid }) => !valid && `
+ color: ${colorDanger};
+ `}
+`;
+
+const InputRooms = styled.select`
+ background-color: ${colorWhite};
+ color: ${colorGray};
+ border: 1px solid ${colorGrayLighter};
+ border-radius: ${borderRadius};
+ width: 100%;
+ padding-top: .25rem;
+ padding-bottom: .25rem;
+ padding: .25rem 0 .25rem .25rem;
+
+ ${({ valid }) => !valid && `
+ border-color: ${colorDanger} !important;
+ `}
+`;
+
+const DurationLabel = styled.label`
+ ${({ valid }) => !valid && `
+ & > * {
+ border-color: ${colorDanger} !important;
+ color: ${colorDanger};
+ }
+ `}
+`;
+
+const LabelText = styled.p`
+ color: ${colorGray};
+ white-space: nowrap;
+ margin-bottom: .5rem;
+
+ ${({ bold }) => bold && `
+ font-weight: bold;
+ font-size: 1.5rem;
+ `}
+`;
+
+const DurationArea = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+`;
+
+const DurationInput = styled.input`
+ background-color: ${colorWhite};
+ color: ${colorGray};
+ border: 1px solid ${colorGrayLighter};
+ border-radius: ${borderRadius};
+ width: 100%;
+ text-align: left;
+ padding: .25rem;
+
+
+ &::placeholder {
+ color: ${colorGray};
+ }
+`;
+
+const HoldButtonWrapper = styled(HoldButton)`
+ & > button > span {
+ padding-bottom: ${borderSize};
+ }
+
+ & > button > span > i {
+ color: ${colorGray};
+ width: ${lgPaddingX};
+ height: ${lgPaddingX};
+ font-size: 170% !important;
+ }
+`;
+
+const AssignBtnsContainer = styled.div`
+ justify-items: center;
+ display: flex;
+ flex-flow: row;
+ align-items: baseline;
+ margin-top: auto;
+`;
+
+const AssignBtns = styled(Button)`
+ color: ${colorDanger};
+ font-size: ${fontSizeSmall};
+ white-space: nowrap;
+ margin-bottom: 0.5rem;
+
+ ${({ random }) => random && `
+ color: ${colorPrimary};
+ `}
+`;
+
+const CheckBoxesContainer = styled(FlexRow)`
+ display: flex;
+ flex-flow: column;
+ justify-content: flex-end;
+`;
+
+const Separator = styled.div`
+ width: 100%;
+ height: 1px;
+ margin: 1rem 0;
+ border: 1px solid ${colorGrayLightest};
+`;
+
+const FreeJoinCheckbox = styled.input`
+ width: 1rem;
+ height: 1rem;
+`;
+
+const RoomUserItem = styled.p`
+ margin: 0;
+ padding: .25rem 0 .25rem .25rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+ display: flex;
+ justify-content: space-between;
+
+ [dir="rtl"] & {
+ padding: .25rem .25rem .25rem 0;
+ }
+
+ span.close {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-right: 5px;
+ font-size: ${fontSizeSmaller};
+ }
+
+ &:focus {
+ background-color: ${colorPrimary};
+ color: ${colorWhite};
+ }
+
+ ${({ selected }) => selected && `
+ background-color: ${colorPrimary};
+ color: ${colorWhite};
+ `}
+
+ ${({ disabled }) => disabled && `
+ cursor: not-allowed;
+ color: ${colorGrayLighter};
+ `}
+
+ ${({ highlight }) => highlight && `
+ background-color: ${colorBlueLightest};
+ `}
+`;
+
+const LockIcon = styled.span`
+ float: right;
+ margin-right: 1rem;
+
+ @media ${smallOnly} {
+ margin-left: .5rem;
+ margin-right: auto;
+ float: left;
+ }
+
+ &:after {
+ font-family: 'bbb-icons' !important;
+ content: '\\e926';
+ color: ${colorGrayLight};
+ }
+`;
+
+const ListContainer = styled(FlexColumn)`
+ justify-content: flex-start;
+`;
+
+const RoomItem = styled.div`
+ margin: 1rem 0 1rem 0;
+`;
+
+const ItemTitle = styled.h2`
+ margin: 0;
+ color: ${colorBlueLight};
+`;
+
+const ItemButton = styled(Button)`
+ padding: 0;
+ outline: none !important;
+
+ & > span {
+ color: ${colorBlueLight};
+ }
+`;
+
+const WithError = styled.span`
+ color: ${colorDanger};
+`;
+
+const SubTitle = styled.p`
+ font-size: ${fontSizeBase};
+ text-align: justify;
+ color: ${colorGray};
+`;
+
+const Content = styled(FlexColumn)``;
+
+export default {
+ BoxContainer,
+ Alert,
+ FreeJoinLabel,
+ BreakoutNameInput,
+ BreakoutBox,
+ SpanWarn,
+ RoomName,
+ BreakoutSettings,
+ FormLabel,
+ InputRooms,
+ DurationLabel,
+ LabelText,
+ DurationArea,
+ DurationInput,
+ HoldButtonWrapper,
+ AssignBtnsContainer,
+ AssignBtns,
+ CheckBoxesContainer,
+ Separator,
+ FreeJoinCheckbox,
+ RoomUserItem,
+ LockIcon,
+ ListContainer,
+ RoomItem,
+ ItemTitle,
+ ItemButton,
+ WithError,
+ SubTitle,
+ Content,
+ ContentContainer,
+ BreakoutSlideLabel,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/presentation-options/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/presentation-options/component.jsx
new file mode 100644
index 00000000..fabbe6b7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/presentation-options/component.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Button from '/imports/ui/components/common/button/component';
+
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ setPresentationIsOpen: PropTypes.func.isRequired,
+};
+
+const intlMessages = defineMessages({
+ minimizePresentationLabel: {
+ id: 'app.actionsBar.actionsDropdown.minimizePresentationLabel',
+ description: '',
+ },
+ minimizePresentationDesc: {
+ id: 'app.actionsBar.actionsDropdown.restorePresentationDesc',
+ description: '',
+ },
+ restorePresentationLabel: {
+ id: 'app.actionsBar.actionsDropdown.restorePresentationLabel',
+ description: 'Restore Presentation option label',
+ },
+ restorePresentationDesc: {
+ id: 'app.actionsBar.actionsDropdown.restorePresentationDesc',
+ description: 'button to restore presentation after it has been closed',
+ },
+});
+
+const PresentationOptionsContainer = ({
+ intl,
+ presentationIsOpen,
+ setPresentationIsOpen,
+ layoutContextDispatch,
+ hasPresentation,
+ hasExternalVideo,
+ hasScreenshare,
+ hasPinnedSharedNotes,
+ hasGenericContent,
+ hasCameraAsContent,
+}) => {
+ let buttonType = 'presentation';
+ if (hasExternalVideo) {
+ // hack until we have an external-video icon
+ buttonType = 'external-video';
+ } else if (hasScreenshare) {
+ buttonType = 'desktop';
+ } else if (hasCameraAsContent) {
+ buttonType = 'video';
+ }
+
+ const isThereCurrentPresentation = hasExternalVideo || hasScreenshare
+ || hasPresentation || hasPinnedSharedNotes
+ || hasGenericContent || hasCameraAsContent;
+ return (
+ {
+ setPresentationIsOpen(layoutContextDispatch, !presentationIsOpen);
+ if (!hasExternalVideo && !hasScreenshare && !hasPinnedSharedNotes) {
+ Session.set('presentationLastState', !presentationIsOpen);
+ }
+ }}
+ id="restore-presentation"
+ ghost={!presentationIsOpen}
+ disabled={!isThereCurrentPresentation}
+ data-test={!presentationIsOpen ? 'restorePresentation' : 'minimizePresentation'}
+ />
+ );
+};
+
+PresentationOptionsContainer.propTypes = propTypes;
+export default injectIntl(PresentationOptionsContainer);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx
new file mode 100644
index 00000000..6ade67fa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx
@@ -0,0 +1,269 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import Dropdown from '/imports/ui/components/dropdown/component';
+import Styled from './styles';
+import { PANELS, ACTIONS } from '../../layout/enums';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const POLL_SETTINGS = Meteor.settings.public.poll;
+const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom;
+const CANCELED_POLL_DELAY = 250;
+
+const intlMessages = defineMessages({
+ quickPollLabel: {
+ id: 'app.poll.quickPollTitle',
+ description: 'Quick poll button title',
+ },
+ trueOptionLabel: {
+ id: 'app.poll.t',
+ description: 'Poll true option value',
+ },
+ falseOptionLabel: {
+ id: 'app.poll.f',
+ description: 'Poll false option value',
+ },
+ yesOptionLabel: {
+ id: 'app.poll.y',
+ description: 'Poll yes option value',
+ },
+ noOptionLabel: {
+ id: 'app.poll.n',
+ description: 'Poll no option value',
+ },
+ abstentionOptionLabel: {
+ id: 'app.poll.abstention',
+ description: 'Poll Abstention option value',
+ },
+ typedRespLabel: {
+ id: 'app.poll.userResponse.label',
+ description: 'quick poll typed response label',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ parseCurrentSlideContent: PropTypes.func.isRequired,
+ amIPresenter: PropTypes.bool.isRequired,
+};
+
+const QuickPollDropdown = (props) => {
+ const {
+ amIPresenter,
+ intl,
+ parseCurrentSlideContent,
+ startPoll,
+ stopPoll,
+ currentSlide,
+ activePoll,
+ className,
+ layoutContextDispatch,
+ pollTypes,
+ } = props;
+
+ const parsedSlide = parseCurrentSlideContent(
+ intl.formatMessage(intlMessages.yesOptionLabel),
+ intl.formatMessage(intlMessages.noOptionLabel),
+ intl.formatMessage(intlMessages.abstentionOptionLabel),
+ intl.formatMessage(intlMessages.trueOptionLabel),
+ intl.formatMessage(intlMessages.falseOptionLabel),
+ );
+
+ const {
+ slideId, quickPollOptions, optionsWithLabels, pollQuestion,
+ } = parsedSlide;
+
+ const handleClickQuickPoll = (lCDispatch) => {
+ lCDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ lCDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.POLL,
+ });
+ Session.set('forcePollOpen', true);
+ Session.set('pollInitiated', true);
+ };
+
+ const getAvailableQuickPolls = (
+ slideId, parsedSlides, funcStartPoll, _pollTypes, _layoutContextDispatch,
+ ) => {
+ const pollItemElements = parsedSlides.map((poll) => {
+ const { poll: label } = poll;
+ const { type, poll: pollData } = poll;
+ let itemLabel = label;
+ const letterAnswers = [];
+
+ if (type === 'R-') {
+ return (
+ {
+ if (activePoll) {
+ stopPoll();
+ }
+ setTimeout(() => {
+ handleClickQuickPoll(_layoutContextDispatch);
+ funcStartPoll(type, slideId, letterAnswers, pollData?.question);
+ }, CANCELED_POLL_DELAY);
+ }}
+ question={pollData?.question}
+ />
+ );
+ }
+
+ if (type !== _pollTypes.YesNo
+ && type !== _pollTypes.YesNoAbstention
+ && type !== _pollTypes.TrueFalse) {
+ const { options } = itemLabel;
+ itemLabel = options.join('/').replace(/[\n.)]/g, '');
+ if (type === _pollTypes.Custom) {
+ for (let i = 0; i < options.length; i += 1) {
+ const letterOption = options[i]?.replace(/[\r.)]/g, '').toUpperCase();
+ if (letterAnswers.length < MAX_CUSTOM_FIELDS) {
+ letterAnswers.push(letterOption);
+ } else {
+ break;
+ }
+ }
+ }
+ }
+
+ // removes any whitespace from the label
+ itemLabel = itemLabel?.replace(/\s+/g, '').toUpperCase();
+
+ const numChars = {
+ 1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E', 6: 'F', 7: 'G'
+ };
+ itemLabel = itemLabel.split('').map((c) => {
+ if (numChars[c]) return numChars[c];
+ return c;
+ }).join('');
+
+ return (
+ {
+ if (activePoll) {
+ stopPoll();
+ }
+ setTimeout(() => {
+ handleClickQuickPoll(_layoutContextDispatch);
+ funcStartPoll(type, slideId, letterAnswers, pollQuestion, pollData?.multiResp);
+ }, CANCELED_POLL_DELAY);
+ }}
+ answers={letterAnswers}
+ multiResp={pollData?.multiResp}
+ />
+ );
+ });
+
+ const sizes = [];
+ return pollItemElements.filter((el) => {
+ const { label } = el.props;
+ if (label.length === sizes[sizes.length - 1]) return false;
+ sizes.push(label.length);
+ return el;
+ });
+ };
+
+ const quickPolls = getAvailableQuickPolls(
+ slideId, quickPollOptions, startPoll, pollTypes, layoutContextDispatch,
+ );
+
+ if (quickPollOptions.length === 0) return null;
+
+ let answers = null;
+ let question = '';
+ let quickPollLabel = '';
+ let multiResponse = false;
+
+ if (quickPolls.length > 0) {
+ const { props: pollProps } = quickPolls[0];
+ quickPollLabel = pollProps?.label;
+ answers = pollProps?.answers;
+ question = pollProps?.question;
+ multiResponse = pollProps?.multiResp;
+ }
+
+ let singlePollType = null;
+ if (quickPolls.length === 1 && quickPollOptions.length) {
+ const { type } = quickPollOptions[0];
+ singlePollType = type;
+ }
+
+ let btn = (
+ {
+ if (activePoll) {
+ stopPoll();
+ }
+
+ setTimeout(() => {
+ handleClickQuickPoll(layoutContextDispatch);
+ if (singlePollType === 'R-' || singlePollType === 'TF' || singlePollType === 'YN') {
+ startPoll(singlePollType, currentSlide.id, answers, pollQuestion, multiResponse);
+ } else {
+ startPoll(
+ pollTypes.Custom,
+ currentSlide.id,
+ optionsWithLabels,
+ pollQuestion,
+ multiResponse,
+ );
+ }
+ }, CANCELED_POLL_DELAY);
+ }}
+ size="lg"
+ data-test="quickPollBtn"
+ color="primary"
+ />
+ );
+
+ const usePollDropdown = quickPollOptions && quickPollOptions.length && quickPolls.length > 1;
+ let dropdown = null;
+
+ if (usePollDropdown) {
+ btn = (
+ null}
+ size="lg"
+ data-test="yesNoQuickPoll"
+ />
+ );
+
+ dropdown = (
+
+
+ {btn}
+
+
+
+ {quickPolls}
+
+
+
+ );
+ }
+
+ return amIPresenter && usePollDropdown ? (
+ dropdown
+ ) : (
+ btn
+ );
+};
+
+QuickPollDropdown.propTypes = propTypes;
+
+export default QuickPollDropdown;
diff --git a/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/container.jsx
new file mode 100644
index 00000000..7c197224
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/container.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { injectIntl } from 'react-intl';
+import { makeCall } from '/imports/ui/services/api';
+import PollService from '/imports/ui/components/poll/service';
+import QuickPollDropdown from './component';
+import { layoutDispatch } from '../../layout/context';
+
+const QuickPollDropdownContainer = (props) => {
+ const layoutContextDispatch = layoutDispatch();
+ return ;
+};
+
+export default withTracker(() => ({
+ activePoll: Session.get('pollInitiated') || false,
+ pollTypes: PollService.pollTypes,
+ stopPoll: () => makeCall('stopPoll'),
+}))(injectIntl(QuickPollDropdownContainer));
diff --git a/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/styles.js b/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/styles.js
new file mode 100644
index 00000000..fae468b7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/quick-poll-dropdown/styles.js
@@ -0,0 +1,26 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general';
+import { headingsFontWeight } from '/imports/ui/stylesheets/styled-components/typography';
+
+const QuickPollButton = styled(Button)`
+ margin-left: .5rem;
+ padding: .1rem;
+ box-shadow: none !important;
+ background-clip: unset !important;
+
+ & > span:first-child {
+ border-radius: ${borderSizeLarge};
+ font-size: small;
+ font-weight: ${headingsFontWeight};
+ opacity: 1;
+ }
+
+ & > span:first-child:hover {
+ opacity: 1 !important;
+ }
+`;
+
+export default {
+ QuickPollButton,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/raise-hand/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/raise-hand/component.jsx
new file mode 100644
index 00000000..1b3fa2f5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/raise-hand/component.jsx
@@ -0,0 +1,140 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Button from '/imports/ui/components/common/button/component';
+import Styled from './styles';
+import { EMOJI_STATUSES } from '/imports/utils/statuses';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ shortcuts: PropTypes.objectOf(PropTypes.string),
+};
+
+const defaultProps = {
+ shortcuts: {},
+};
+
+const intlMessages = defineMessages({
+ statusTriggerLabel: {
+ id: 'app.actionsBar.emojiMenu.statusTriggerLabel',
+ description: 'label for option to show emoji menu',
+ },
+ clearStatusLabel: {
+ id: 'app.actionsBar.emojiMenu.noneLabel',
+ description: 'label for status clearing',
+ },
+ raiseHandLabel: {
+ id: 'app.actionsBar.emojiMenu.raiseHandLabel',
+ description: 'label for raise hand status',
+ },
+ lowerHandLabel: {
+ id: 'app.actionsBar.emojiMenu.lowerHandLabel',
+ description: 'label for lower hand',
+ },
+});
+
+class RaiseHandDropdown extends PureComponent {
+
+ getAvailableActions() {
+ const {
+ userId,
+ getEmojiList,
+ setEmojiStatus,
+ intl,
+ } = this.props;
+
+ const actions = [];
+ const statuses = Object.keys(getEmojiList);
+
+ statuses.forEach((s) => {
+ actions.push({
+ key: s,
+ label: intl.formatMessage({ id: `app.actionsBar.emojiMenu.${s}Label` }),
+ onClick: () => {
+ setEmojiStatus(userId, s);
+ },
+ icon: getEmojiList[s],
+ });
+ });
+ return actions;
+ }
+
+ renderRaiseHandButton() {
+ const {
+ intl,
+ setEmojiStatus,
+ currentUser,
+ shortcuts,
+ } = this.props;
+
+ let btnLabel;
+ let btnEmoji;
+ if (currentUser.emoji === 'none') {
+ btnEmoji = 'raiseHand';
+ btnLabel = intlMessages.raiseHandLabel;
+ } else if (currentUser.emoji === 'raiseHand') {
+ btnEmoji = 'none';
+ btnLabel = intlMessages.lowerHandLabel;
+ } else {
+ btnEmoji = 'none';
+ btnLabel = intlMessages.clearStatusLabel;
+ }
+
+ return (
+ {
+ e.stopPropagation();
+ setEmojiStatus(currentUser.userId, btnEmoji);
+ }}
+ />
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ } = this.props;
+
+ const actions = this.getAvailableActions();
+
+ return (
+
+ {this.renderRaiseHandButton()}
+
+
+ >
+ )}
+ actions={actions}
+ />
+
+ );
+ }
+}
+
+RaiseHandDropdown.propTypes = propTypes;
+RaiseHandDropdown.defaultProps = defaultProps;
+
+export default withShortcutHelper(RaiseHandDropdown, ['raiseHand']);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/raise-hand/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/raise-hand/container.jsx
new file mode 100644
index 00000000..94c46e65
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/raise-hand/container.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import RaiseHandDropdown from './component';
+import UserListService from '/imports/ui/components/user-list/service';
+
+const RaiseHandDropdownContainer = (props) => (
+
+);
+
+export default withTracker(() => ({
+ isDropdownOpen: Session.get('dropdownOpen'),
+ getEmojiList: UserListService.getEmojiList(),
+}))(RaiseHandDropdownContainer);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/raise-hand/styles.js b/src/2.7.12/imports/ui/components/actions-bar/raise-hand/styles.js
new file mode 100644
index 00000000..7474c7a3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/raise-hand/styles.js
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
+
+const HideDropdownButton = styled(ButtonEmoji)`
+ span {
+ i {
+ width: 0px !important;
+ bottom: 1px;
+ }
+ }
+`;
+
+const OffsetBottom = styled.div`
+ position: relative;
+`;
+
+export default {
+ HideDropdownButton,
+ OffsetBottom,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/reactions-button/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/reactions-button/component.jsx
new file mode 100644
index 00000000..b8f00bfe
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/reactions-button/component.jsx
@@ -0,0 +1,181 @@
+import React, { useState } from 'react';
+import { defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import UserReactionService from '/imports/ui/components/user-reaction/service';
+import UserListService from '/imports/ui/components/user-list/service';
+import { convertRemToPixels } from '/imports/utils/dom-utils';
+import data from '@emoji-mart/data';
+import { init } from 'emoji-mart';
+
+import Styled from './styles';
+
+const REACTIONS = Meteor.settings.public.userReaction.reactions;
+
+const ReactionsButton = (props) => {
+ const {
+ intl,
+ actionsBarRef,
+ userId,
+ raiseHand,
+ isMobile,
+ currentUserReaction,
+ autoCloseReactionsBar,
+ shortcuts,
+ } = props;
+
+ // initialize emoji-mart data, need for the new version
+ init({ data });
+
+ const [showEmojiPicker, setShowEmojiPicker] = useState(false);
+
+ const intlMessages = defineMessages({
+ reactionsLabel: {
+ id: 'app.actionsBar.reactions.reactionsButtonLabel',
+ description: 'reactions Label',
+ },
+ raiseHandLabel: {
+ id: 'app.actionsBar.reactions.raiseHand',
+ description: 'raise Hand Label',
+ },
+ notRaiseHandLabel: {
+ id: 'app.actionsBar.reactions.lowHand',
+ description: 'not Raise Hand Label',
+ },
+ });
+
+ const handleClose = () => {
+ setShowEmojiPicker(false);
+ setTimeout(() => {
+ document.activeElement.blur();
+ }, 0);
+ };
+
+ const handleReactionSelect = (reaction) => {
+ const newReaction = currentUserReaction === reaction ? 'none' : reaction;
+ UserReactionService.setUserReaction(newReaction);
+ };
+
+ const handleRaiseHandButtonClick = () => {
+ UserListService.setUserRaiseHand(userId, !raiseHand);
+ };
+
+ const RaiseHandButtonLabel = () => {
+ if (isMobile) return null;
+
+ return raiseHand
+ ? intl.formatMessage(intlMessages.notRaiseHandLabel)
+ : intl.formatMessage(intlMessages.raiseHandLabel);
+ };
+
+ const customStyles = {
+ top: '-1rem',
+ borderRadius: '1.7rem',
+ };
+
+ const actionCustomStyles = {
+ paddingLeft: 0,
+ paddingRight: 0,
+ paddingTop: isMobile ? '0' : '0.5rem',
+ paddingBottom: isMobile ? '0' : '0.5rem',
+ };
+
+ const emojiProps = {
+ size: convertRemToPixels(1.5),
+ padding: '4px',
+ };
+
+ const handReaction = {
+ id: 'hand',
+ native: '✋',
+ };
+
+ let actions = [];
+
+ REACTIONS.forEach(({ id, native }) => {
+ actions.push({
+ label: ,
+ key: id,
+ onClick: () => handleReactionSelect(native),
+ customStyles: actionCustomStyles,
+ });
+ });
+
+ actions.push({
+ label: {RaiseHandButtonLabel()} ,
+ key: 'hand',
+ onClick: () => handleRaiseHandButtonClick(),
+ customStyles: {...actionCustomStyles, width: 'auto'},
+ });
+
+ const icon = !raiseHand && currentUserReaction === 'none' ? 'hand' : null;
+ const currentUserReactionEmoji = REACTIONS.find(({ native }) => native === currentUserReaction);
+
+ let customIcon = null;
+
+ if (raiseHand) {
+ customIcon = ;
+ } else {
+ if (!icon) {
+ customIcon = ;
+ }
+ }
+
+ return (
+
+ {}}
+ onClick={() => setShowEmojiPicker(true)}
+ color={showEmojiPicker || customIcon ? 'primary' : 'default'}
+ hideLabel
+ circle
+ size="lg"
+ />
+
+ )}
+ actions={actions}
+ onCloseCallback={() => handleClose()}
+ customAnchorEl={!isMobile ? actionsBarRef.current : null}
+ customStyles={customStyles}
+ open={showEmojiPicker}
+ hasRoundedCorners
+ overrideMobileStyles
+ isHorizontal={!isMobile}
+ isMobile={isMobile}
+ roundButtons={true}
+ keepOpen={!autoCloseReactionsBar}
+ opts={{
+ id: 'reactions-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ anchorOrigin: { vertical: 'top', horizontal: 'center' },
+ transformOrigin: { vertical: 'bottom', horizontal: 'center' },
+ }}
+ />
+ );
+};
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ userId: PropTypes.string.isRequired,
+ emoji: PropTypes.string.isRequired,
+ sidebarContentPanel: PropTypes.string.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+};
+
+ReactionsButton.propTypes = propTypes;
+
+export default withShortcutHelper(ReactionsButton, ['raiseHand']);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/reactions-button/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/reactions-button/container.jsx
new file mode 100644
index 00000000..0d1ab2ef
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/reactions-button/container.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context';
+import { injectIntl } from 'react-intl';
+import ReactionsButton from './component';
+import actionsBarService from '../service';
+import UserReactionService from '/imports/ui/components/user-reaction/service';
+import { SMALL_VIEWPORT_BREAKPOINT } from '/imports/ui/components/layout/enums';
+import SettingsService from '/imports/ui/services/settings';
+
+const ReactionsButtonContainer = ({ ...props }) => {
+ const layoutContextDispatch = layoutDispatch();
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+
+ const { width: browserWidth } = layoutSelectInput((i) => i.browser);
+ const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT;
+
+ return (
+
+ );
+};
+
+export default injectIntl(withTracker(() => {
+ const currentUser = actionsBarService.currentUser();
+ const currentUserReaction = UserReactionService.getUserReaction(currentUser.userId);
+
+ return {
+ userId: currentUser.userId,
+ emoji: currentUser.emoji,
+ currentUserReaction: currentUserReaction.reaction,
+ raiseHand: currentUser.raiseHand,
+ autoCloseReactionsBar: SettingsService?.application?.autoCloseReactionsBar,
+ };
+})(ReactionsButtonContainer));
+
diff --git a/src/2.7.12/imports/ui/components/actions-bar/reactions-button/styles.js b/src/2.7.12/imports/ui/components/actions-bar/reactions-button/styles.js
new file mode 100644
index 00000000..b115f6fb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/reactions-button/styles.js
@@ -0,0 +1,88 @@
+import styled from 'styled-components';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import Button from '/imports/ui/components/common/button/component';
+
+import {
+ colorGrayDark,
+ colorGrayLightest,
+ btnPrimaryColor,
+ btnPrimaryActiveBg,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const RaiseHandButton = styled(Button)`
+${({ ghost }) => ghost && `
+ & > span {
+ box-shadow: none;
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ `}
+`;
+
+const ReactionsDropdown = styled.div`
+ position: relative;
+`;
+
+const ButtonWrapper = styled.div`
+ border: 1px solid transparent;
+ cursor: pointer;
+ height: 2.5rem;
+ display: flex;
+ align-items: center;
+ border-radius: 50%;
+ margin: 0 .5rem;
+
+ &:focus {
+ background-color: ${colorGrayDark};
+ }
+
+ & > button {
+ cursor: pointer;
+ flex: auto;
+ }
+
+ & > * > span {
+ padding: 4px;
+ }
+
+ ${({ active }) => active && `
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg};
+
+ &:hover{
+ filter: brightness(90%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg} !important;
+ }
+ `}
+`;
+
+const RaiseHandButtonWrapper = styled(ButtonWrapper)`
+ width: 2.5rem;
+ border-radius: 1.7rem;
+
+
+ ${({ isMobile }) => !isMobile && `
+ border: 1px solid ${colorGrayLightest};
+ padding: 1rem 0.5rem;
+ width: auto;
+ `}
+
+ ${({ active }) => active && `
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg};
+
+ &:hover{
+ filter: brightness(90%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg} !important;
+ }
+ `}
+`;
+
+export default {
+ RaiseHandButton,
+ ReactionsDropdown,
+ ButtonWrapper,
+ RaiseHandButtonWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/screenshare/component.jsx b/src/2.7.12/imports/ui/components/actions-bar/screenshare/component.jsx
new file mode 100644
index 00000000..ec210fe4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/screenshare/component.jsx
@@ -0,0 +1,213 @@
+import React, { memo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import deviceInfo from '/imports/utils/deviceInfo';
+import browserInfo from '/imports/utils/browserInfo';
+import logger from '/imports/startup/client/logger';
+import { notify } from '/imports/ui/services/notification';
+import Styled from './styles';
+import ScreenshareBridgeService from '/imports/api/screenshare/client/bridge/service';
+import {
+ shareScreen,
+ screenshareHasEnded,
+} from '/imports/ui/components/screenshare/service';
+import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors';
+import Button from '/imports/ui/components/common/button/component';
+import { parsePayloads } from 'sdp-transform';
+
+const { isMobile } = deviceInfo;
+const { isSafari, isTabletApp } = browserInfo;
+
+const propTypes = {
+ intl: PropTypes.objectOf(Object).isRequired,
+ enabled: PropTypes.bool.isRequired,
+ amIPresenter: PropTypes.bool.isRequired,
+ isScreenBroadcasting: PropTypes.bool.isRequired,
+ isMeteorConnected: PropTypes.bool.isRequired,
+ screenshareDataSavingSetting: PropTypes.bool.isRequired,
+};
+
+const intlMessages = defineMessages({
+ desktopShareLabel: {
+ id: 'app.actionsBar.actionsDropdown.desktopShareLabel',
+ description: 'Desktop Share option label',
+ },
+ stopDesktopShareLabel: {
+ id: 'app.actionsBar.actionsDropdown.stopDesktopShareLabel',
+ description: 'Stop Desktop Share option label',
+ },
+ desktopShareDesc: {
+ id: 'app.actionsBar.actionsDropdown.desktopShareDesc',
+ description: 'adds context to desktop share option',
+ },
+ stopDesktopShareDesc: {
+ id: 'app.actionsBar.actionsDropdown.stopDesktopShareDesc',
+ description: 'adds context to stop desktop share option',
+ },
+ screenShareNotSupported: {
+ id: 'app.media.screenshare.notSupported',
+ descriptions: 'error message when trying share screen on unsupported browsers',
+ },
+ screenShareUnavailable: {
+ id: 'app.media.screenshare.unavailable',
+ descriptions: 'title for unavailable screen share modal',
+ },
+ finalError: {
+ id: 'app.screenshare.screenshareFinalError',
+ description: 'Screen sharing failures with no recovery procedure',
+ },
+ retryError: {
+ id: 'app.screenshare.screenshareRetryError',
+ description: 'Screen sharing failures where a retry is recommended',
+ },
+ retryOtherEnvError: {
+ id: 'app.screenshare.screenshareRetryOtherEnvError',
+ description: 'Screen sharing failures where a retry in another environment is recommended',
+ },
+ unsupportedEnvError: {
+ id: 'app.screenshare.screenshareUnsupportedEnv',
+ description: 'Screen sharing is not supported, changing browser or device is recommended',
+ },
+ permissionError: {
+ id: 'app.screenshare.screensharePermissionError',
+ description: 'Screen sharing failure due to lack of permission',
+ },
+});
+
+const getErrorLocale = (errorCode) => {
+ switch (errorCode) {
+ // Denied getDisplayMedia permission error
+ case SCREENSHARING_ERRORS.NotAllowedError.errorCode:
+ return intlMessages.permissionError;
+ // Browser is supposed to be supported, but a browser-related error happening.
+ // Suggest retrying in another device/browser/env
+ case SCREENSHARING_ERRORS.AbortError.errorCode:
+ case SCREENSHARING_ERRORS.InvalidStateError.errorCode:
+ case SCREENSHARING_ERRORS.OverconstrainedError.errorCode:
+ case SCREENSHARING_ERRORS.TypeError.errorCode:
+ case SCREENSHARING_ERRORS.NotFoundError.errorCode:
+ case SCREENSHARING_ERRORS.NotReadableError.errorCode:
+ case SCREENSHARING_ERRORS.PEER_NEGOTIATION_FAILED.errorCode:
+ case SCREENSHARING_ERRORS.SCREENSHARE_PLAY_FAILED.errorCode:
+ case SCREENSHARING_ERRORS.MEDIA_NO_AVAILABLE_CODEC.errorCode:
+ case SCREENSHARING_ERRORS.MEDIA_INVALID_SDP.errorCode:
+ return intlMessages.retryOtherEnvError;
+ // Fatal errors where a retry isn't warranted. This probably means the server
+ // is misconfigured somehow or the provider is utterly botched, so nothing
+ // the end user can do besides requesting support
+ case SCREENSHARING_ERRORS.SIGNALLING_TRANSPORT_CONNECTION_FAILED.errorCode:
+ case SCREENSHARING_ERRORS.MEDIA_SERVER_CONNECTION_ERROR.errorCode:
+ case SCREENSHARING_ERRORS.SFU_INVALID_REQUEST.errorCode:
+ return intlMessages.finalError;
+ // Unsupported errors
+ case SCREENSHARING_ERRORS.NotSupportedError.errorCode:
+ return intlMessages.unsupportedEnvError;
+ // Errors that should be silent/ignored. They WILL NOT be LOGGED nor NOTIFIED via toasts.
+ case SCREENSHARING_ERRORS.ENDED_WHILE_STARTING.errorCode:
+ return null;
+ // Fall through: everything else is an error which might be solved with a retry
+ default:
+ return intlMessages.retryError;
+ }
+};
+
+const ScreenshareButton = ({
+ intl,
+ enabled,
+ isScreenBroadcasting,
+ amIPresenter,
+ isMeteorConnected,
+}) => {
+ // This is the failure callback that will be passed to the /api/screenshare/kurento.js
+ // script on the presenter's call
+ const handleFailure = (error) => {
+ const {
+ errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
+ errorMessage = error.message,
+ } = error;
+
+ const localizedError = getErrorLocale(errorCode);
+
+ if (localizedError) {
+ notify(intl.formatMessage(localizedError, { 0: errorCode }), 'error', 'desktop');
+ logger.error({
+ logCode: 'screenshare_failed',
+ extraInfo: { errorCode, errorMessage },
+ }, `Screenshare failed: ${errorMessage} (code=${errorCode})`);
+ }
+
+ screenshareHasEnded();
+ };
+
+ const [isScreenshareUnavailableModalOpen, setScreenshareUnavailableModalIsOpen] = useState(false);
+
+ const RenderScreenshareUnavailableModal = (otherProps) =>
+
+
+ {intl.formatMessage(intlMessages.screenShareUnavailable)}
+
+ {intl.formatMessage(intlMessages.screenShareNotSupported)}
+ ;
+
+ const screenshareLabel = intlMessages.desktopShareLabel;
+
+ const vLabel = isScreenBroadcasting
+ ? intlMessages.stopDesktopShareLabel : screenshareLabel;
+
+ const vDescr = isScreenBroadcasting
+ ? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
+ const amIBroadcasting = isScreenBroadcasting && amIPresenter;
+
+ const shouldAllowScreensharing = enabled
+ && ( !isMobile || isTabletApp)
+ && amIPresenter;
+
+ const dataTest = isScreenBroadcasting ? 'stopScreenShare' : 'startScreenShare';
+
+ return <>
+ {
+ shouldAllowScreensharing
+ ? (
+ {
+ if (isSafari && !ScreenshareBridgeService.HAS_DISPLAY_MEDIA) {
+ setScreenshareUnavailableModalIsOpen(true);
+ } else {
+ shareScreen(amIPresenter, handleFailure);
+ }
+ }}
+ id={amIBroadcasting ? 'unshare-screen-button' : 'share-screen-button'}
+ />
+ ) : null
+ }
+ {
+ isScreenshareUnavailableModalOpen ? setScreenshareUnavailableModalIsOpen(false),
+ priority: "low",
+ setIsOpen: setScreenshareUnavailableModalIsOpen,
+ isOpen: isScreenshareUnavailableModalOpen,
+ }}
+ /> : null
+ }
+ >
+};
+
+ScreenshareButton.propTypes = propTypes;
+export default injectIntl(memo(ScreenshareButton));
diff --git a/src/2.7.12/imports/ui/components/actions-bar/screenshare/container.jsx b/src/2.7.12/imports/ui/components/actions-bar/screenshare/container.jsx
new file mode 100644
index 00000000..ce119dc9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/screenshare/container.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ScreenshareButton from './component';
+import { isScreenSharingEnabled } from '/imports/ui/services/features';
+import {
+ isScreenBroadcasting,
+ dataSavingSetting,
+} from '/imports/ui/components/screenshare/service';
+
+const ScreenshareButtonContainer = (props) => ;
+
+/*
+ * All props, including the ones that are inherited from actions-bar
+ * isScreenBroadcasting,
+ * amIPresenter,
+ * screenSharingCheck,
+ * isMeteorConnected,
+ * screenshareDataSavingSetting,
+ */
+export default withTracker(() => ({
+ isScreenBroadcasting: isScreenBroadcasting(),
+ screenshareDataSavingSetting: dataSavingSetting(),
+ enabled: isScreenSharingEnabled(),
+}))(ScreenshareButtonContainer);
diff --git a/src/2.7.12/imports/ui/components/actions-bar/screenshare/styles.js b/src/2.7.12/imports/ui/components/actions-bar/screenshare/styles.js
new file mode 100644
index 00000000..2f41d907
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/screenshare/styles.js
@@ -0,0 +1,29 @@
+import styled from 'styled-components';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import { colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ jumboPaddingY,
+ minModalHeight,
+ headingsFontWeight,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+
+const ScreenShareModal = styled(ModalSimple)`
+ padding: ${jumboPaddingY};
+ min-height: ${minModalHeight};
+ text-align: center;
+`;
+
+const Title = styled.h3`
+ font-weight: ${headingsFontWeight};
+ font-size: ${fontSizeLarge};
+ color: ${colorGrayDark};
+ white-space: normal;
+ padding-bottom: ${mdPaddingX};
+`;
+
+export default {
+ ScreenShareModal,
+ Title,
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/service.js b/src/2.7.12/imports/ui/components/actions-bar/service.js
new file mode 100644
index 00000000..39b91fce
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/service.js
@@ -0,0 +1,86 @@
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users';
+import { makeCall } from '/imports/ui/services/api';
+import Meetings from '/imports/api/meetings';
+import Breakouts from '/imports/api/breakouts';
+import { getVideoUrl } from '/imports/ui/components/external-video-player/service';
+import NotesService from '/imports/ui/components/notes/service';
+import BreakoutsHistory from '/imports/api/breakouts-history';
+
+const USER_CONFIG = Meteor.settings.public.user;
+const ROLE_MODERATOR = USER_CONFIG.role_moderator;
+const DIAL_IN_USER = 'dial-in-user';
+
+const getBreakouts = () => Breakouts.find({ parentMeetingId: Auth.meetingID })
+ .fetch()
+ .sort((a, b) => a.sequence - b.sequence);
+
+const getLastBreakouts = () => {
+ const lastBreakouts = BreakoutsHistory.findOne({ meetingId: Auth.meetingID });
+ if (lastBreakouts) {
+ return lastBreakouts.rooms
+ .sort((a, b) => a.sequence - b.sequence);
+ }
+
+ return [];
+};
+
+const currentBreakoutUsers = (user) => !Breakouts.findOne({
+ 'joinedUsers.userId': new RegExp(`^${user.userId}`),
+});
+
+const filterBreakoutUsers = (filter) => (users) => users.filter(filter);
+
+const getUsersNotJoined = filterBreakoutUsers(currentBreakoutUsers);
+
+const takePresenterRole = () => makeCall('assignPresenter', Auth.userID);
+
+const amIModerator = () => {
+ const currentUser = Users.findOne({ userId: Auth.userID },
+ { fields: { role: 1 } });
+
+ if (!currentUser) {
+ return false;
+ }
+
+ return currentUser.role === ROLE_MODERATOR;
+};
+
+const isMe = (intId) => intId === Auth.userID;
+
+export default {
+ amIModerator,
+ isMe,
+ currentUser: () => Users.findOne({ meetingId: Auth.meetingID, userId: Auth.userID },
+ {
+ fields: {
+ userId: 1,
+ emoji: 1,
+ away: 1,
+ raiseHand: 1,
+ },
+ }),
+ meetingName: () => Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'meetingProp.name': 1 } })?.meetingProp?.name,
+ users: () => Users.find({
+ meetingId: Auth.meetingID,
+ clientType: { $ne: DIAL_IN_USER },
+ }).fetch(),
+ groups: () => Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { groups: 1 } })?.groups,
+ isBreakoutRecordable: () => Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'breakoutProps.record': 1 } })?.breakoutProps?.record,
+ toggleRecording: () => makeCall('toggleRecording'),
+ createBreakoutRoom: (rooms, durationInMinutes, record = false, captureNotes = false, captureSlides = false, sendInviteToModerators = false) => makeCall('createBreakoutRoom', rooms, durationInMinutes, record, captureNotes, captureSlides, sendInviteToModerators),
+ sendInvitation: (breakoutId, userId) => makeCall('requestJoinURL', { breakoutId, userId }),
+ breakoutJoinedUsers: () => Breakouts.find({
+ joinedUsers: { $exists: true },
+ }, { fields: { joinedUsers: 1, breakoutId: 1, sequence: 1 }, sort: { sequence: 1 } }).fetch(),
+ moveUser: (fromBreakoutId, toBreakoutId, userId) => makeCall('moveUser', fromBreakoutId, toBreakoutId, userId),
+ getBreakouts,
+ getLastBreakouts,
+ getUsersNotJoined,
+ takePresenterRole,
+ isSharedNotesPinned: () => NotesService.isSharedNotesPinned(),
+ isSharingVideo: () => getVideoUrl(),
+};
diff --git a/src/2.7.12/imports/ui/components/actions-bar/styles.js b/src/2.7.12/imports/ui/components/actions-bar/styles.js
new file mode 100644
index 00000000..5ca77665
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/actions-bar/styles.js
@@ -0,0 +1,132 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { smPaddingX, smPaddingY } from '/imports/ui/stylesheets/styled-components/general';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import Button from '/imports/ui/components/common/button/component';
+
+const ActionsBar = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+`;
+
+const Left = styled.div`
+ display: inherit;
+ flex: 0;
+
+ > * {
+ margin: 0 ${smPaddingX};
+
+ @media ${smallOnly} {
+ margin: 0 ${smPaddingY};
+ }
+ }
+
+ @media ${smallOnly} {
+ bottom: ${smPaddingX};
+ left: ${smPaddingX};
+ right: auto;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: ${smPaddingX};
+ }
+ }
+
+`;
+
+const Center = styled.div`
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+ justify-content: center;
+
+ > * {
+ margin: 0 ${smPaddingX};
+
+ @media ${smallOnly} {
+ margin: 0 ${smPaddingY};
+ }
+ }
+
+`;
+
+const Right = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ position: relative;
+
+ [dir="rtl"] & {
+ right: auto;
+ left: ${smPaddingX};
+ }
+
+ @media ${smallOnly} {
+ right: 0;
+ left: 0;
+ display: contents;
+ }
+
+ > * {
+ margin: 0 ${smPaddingX};
+
+ @media ${smallOnly} {
+ margin: 0 ${smPaddingY};
+ }
+ }
+`;
+
+const RaiseHandButton = styled(Button)`
+${({ ghost }) => ghost && `
+ & > span {
+ box-shadow: none;
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ `}
+`;
+
+const ButtonContainer = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ > * {
+ margin: 8px;
+ }
+`;
+
+const ReactionsDropdown = styled.div`
+ position: relative;
+`;
+
+const Wrapper = styled.div`
+ overflow: hidden;
+ margin: 0.2em 0.2em 0.2em 0.2em;
+ text-align: center;
+ max-height: 270px;
+ width: 270px;
+ em-emoji {
+ cursor: pointer;
+ }
+`;
+
+const Separator = styled.div`
+ height: 2.5rem;
+ width: 0;
+ border: 1px solid ${colorWhite};
+ align-self: center;
+ opacity: .75;
+`;
+
+export default {
+ ActionsBar,
+ Left,
+ Center,
+ Right,
+ RaiseHandButton,
+ ButtonContainer,
+ ReactionsDropdown,
+ Wrapper,
+ Separator,
+};
diff --git a/src/2.7.12/imports/ui/components/activity-check/component.jsx b/src/2.7.12/imports/ui/components/activity-check/component.jsx
new file mode 100644
index 00000000..5da2fe0f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/activity-check/component.jsx
@@ -0,0 +1,122 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+
+import Button from '/imports/ui/components/common/button/component';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import { makeCall } from '/imports/ui/services/api';
+
+import { Meteor } from 'meteor/meteor';
+import Styled from './styles';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ responseDelay: PropTypes.number.isRequired,
+};
+
+const intlMessages = defineMessages({
+ activityCheckTitle: {
+ id: 'app.user.activityCheck',
+ description: 'Title for activity check modal',
+ },
+ activityCheckLabel: {
+ id: 'app.user.activityCheck.label',
+ description: 'Label for activity check modal',
+ },
+ activityCheckButton: {
+ id: 'app.user.activityCheck.check',
+ description: 'Check button for activity modal',
+ },
+});
+
+const handleInactivityDismiss = () => makeCall('userActivitySign');
+
+class ActivityCheck extends Component {
+ constructor(props) {
+ super(props);
+
+ const { responseDelay } = this.props;
+
+ this.state = ({
+ responseDelay,
+ });
+
+ this.stopRemainingTime = this.stopRemainingTime.bind(this);
+ this.updateRemainingTime = this.updateRemainingTime.bind(this);
+ this.playAudioAlert = this.playAudioAlert.bind(this);
+ }
+
+ componentDidMount() {
+ this.playAudioAlert();
+ this.interval = this.updateRemainingTime();
+ }
+
+ componentDidUpdate() {
+ this.stopRemainingTime();
+ this.interval = this.updateRemainingTime();
+ }
+
+ componentWillUnmount() {
+ this.stopRemainingTime();
+ }
+
+ updateRemainingTime() {
+ const { responseDelay } = this.state;
+
+ return setInterval(() => {
+ if (responseDelay === 0) return;
+
+ const remainingTime = responseDelay - 1;
+
+ this.setState({
+ responseDelay: remainingTime,
+ });
+ }, 1000);
+ }
+
+ stopRemainingTime() {
+ clearInterval(this.interval);
+ }
+
+ playAudioAlert() {
+ this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename + Meteor.settings.public.app.instanceId}/resources/sounds/notify.mp3`);
+ this.alert.addEventListener('ended', () => { this.alert.src = null; });
+ this.alert.play();
+ }
+
+ render() {
+ const { intl } = this.props;
+
+ const { responseDelay } = this.state;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.activityCheckTitle)}
+ {intl.formatMessage(intlMessages.activityCheckLabel, { 0: responseDelay })}
+
+
+
+ );
+ }
+}
+
+ActivityCheck.propTypes = propTypes;
+
+export default ActivityCheck;
diff --git a/src/2.7.12/imports/ui/components/activity-check/container.jsx b/src/2.7.12/imports/ui/components/activity-check/container.jsx
new file mode 100644
index 00000000..740b7b0e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/activity-check/container.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import { injectIntl } from 'react-intl';
+import ActivityCheck from './component';
+
+const ActivityCheckContainer = (props) => ;
+
+export default injectIntl(ActivityCheckContainer);
diff --git a/src/2.7.12/imports/ui/components/activity-check/styles.js b/src/2.7.12/imports/ui/components/activity-check/styles.js
new file mode 100644
index 00000000..b7eedbb8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/activity-check/styles.js
@@ -0,0 +1,23 @@
+import styled from 'styled-components';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+
+const ActivityModalContent = styled.div`
+ flex-direction: column;
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ padding: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ padding: 0.5rem;
+ text-align: center;
+
+ & > p {
+ font-size: ${fontSizeLarge};
+ margin: 0.5em 0;
+ }
+`;
+
+export default {
+ ActivityModalContent,
+};
diff --git a/src/2.7.12/imports/ui/components/app/component.jsx b/src/2.7.12/imports/ui/components/app/component.jsx
new file mode 100644
index 00000000..34b69e88
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/app/component.jsx
@@ -0,0 +1,685 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { throttle } from '/imports/utils/throttle';
+import { defineMessages, injectIntl } from 'react-intl';
+import ReactModal from 'react-modal';
+import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
+import PollingContainer from '/imports/ui/components/polling/container';
+import logger from '/imports/startup/client/logger';
+import ActivityCheckContainer from '/imports/ui/components/activity-check/container';
+import UserInfoContainer from '/imports/ui/components/user-info/container';
+import BreakoutRoomInvitation from '/imports/ui/components/breakout-room/invitation/container';
+import { Meteor } from 'meteor/meteor';
+import ToastContainer from '/imports/ui/components/common/toast/container';
+import PadsSessionsContainer from '/imports/ui/components/pads/sessions/container';
+import WakeLockContainer from '../wake-lock/container';
+import NotificationsBarContainer from '../notifications-bar/container';
+import AudioContainer from '../audio/container';
+import ChatAlertContainer from '../chat/alert/container';
+import BannerBarContainer from '/imports/ui/components/banner-bar/container';
+import RaiseHandNotifier from '/imports/ui/components/raisehand-notifier/container';
+import ManyWebcamsNotifier from '/imports/ui/components/video-provider/many-users-notify/container';
+import AudioCaptionsSpeechContainer from '/imports/ui/components/audio/captions/speech/container';
+import UploaderContainer from '/imports/ui/components/presentation/presentation-uploader/container';
+import CaptionsSpeechContainer from '/imports/ui/components/captions/speech/container';
+import RandomUserSelectContainer from '/imports/ui/components/common/modal/random-user/container';
+import ScreenReaderAlertContainer from '../screenreader-alert/container';
+import WebcamContainer from '../webcam/container';
+import PresentationAreaContainer from '../presentation/presentation-area/container';
+import ScreenshareContainer from '../screenshare/container';
+import ExternalVideoContainer from '../external-video-player/container';
+import EmojiRainContainer from '../emoji-rain/container';
+import Styled from './styles';
+import { DEVICE_TYPE, ACTIONS, SMALL_VIEWPORT_BREAKPOINT, PANELS } from '../layout/enums';
+import {
+ isMobile, isTablet, isTabletPortrait, isTabletLandscape, isDesktop,
+} from '../layout/utils';
+import LayoutEngine from '../layout/layout-manager/layoutEngine';
+import NavBarContainer from '../nav-bar/container';
+import SidebarNavigationContainer from '../sidebar-navigation/container';
+import SidebarContentContainer from '../sidebar-content/container';
+import { makeCall } from '/imports/ui/services/api';
+import ConnectionStatusService from '/imports/ui/components/connection-status/service';
+import Settings from '/imports/ui/services/settings';
+import { registerTitleView } from '/imports/utils/dom-utils';
+import Notifications from '../notifications/container';
+import GlobalStyles from '/imports/ui/stylesheets/styled-components/globalStyles';
+import ActionsBarContainer from '../actions-bar/container';
+import PushLayoutEngine from '../layout/push-layout/pushLayoutEngine';
+import AudioService from '/imports/ui/components/audio/service';
+import NotesContainer from '/imports/ui/components/notes/container';
+import DEFAULT_VALUES from '../layout/defaultValues';
+import AppService from '/imports/ui/components/app/service';
+import TimerService from '/imports/ui/components/timer/service';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+
+const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
+const APP_CONFIG = Meteor.settings.public.app;
+const DESKTOP_FONT_SIZE = APP_CONFIG.desktopFontSize;
+const MOBILE_FONT_SIZE = APP_CONFIG.mobileFontSize;
+const LAYOUT_CONFIG = Meteor.settings.public.layout;
+const CONFIRMATION_ON_LEAVE = Meteor.settings.public.app.askForConfirmationOnLeave;
+
+const intlMessages = defineMessages({
+ userListLabel: {
+ id: 'app.userList.label',
+ description: 'Aria-label for Userlist Nav',
+ },
+ chatLabel: {
+ id: 'app.chat.label',
+ description: 'Aria-label for Chat Section',
+ },
+ actionsBarLabel: {
+ id: 'app.actionsBar.label',
+ description: 'Aria-label for ActionsBar Section',
+ },
+ clearedEmoji: {
+ id: 'app.toast.clearedEmoji.label',
+ description: 'message for cleared emoji status',
+ },
+ clearedReaction: {
+ id: 'app.toast.clearedReactions.label',
+ description: 'message for cleared reactions',
+ },
+ setEmoji: {
+ id: 'app.toast.setEmoji.label',
+ description: 'message when a user emoji has been set',
+ },
+ raisedHand: {
+ id: 'app.toast.setEmoji.raiseHand',
+ description: 'toast message for raised hand notification',
+ },
+ loweredHand: {
+ id: 'app.toast.setEmoji.lowerHand',
+ description: 'toast message for lowered hand notification',
+ },
+ away: {
+ id: 'app.toast.setEmoji.away',
+ description: 'toast message for set away notification',
+ },
+ notAway: {
+ id: 'app.toast.setEmoji.notAway',
+ description: 'toast message for remove away notification',
+ },
+ meetingMuteOn: {
+ id: 'app.toast.meetingMuteOn.label',
+ description: 'message used when meeting has been muted',
+ },
+ meetingMuteOff: {
+ id: 'app.toast.meetingMuteOff.label',
+ description: 'message used when meeting has been unmuted',
+ },
+ pollPublishedLabel: {
+ id: 'app.whiteboard.annotations.poll',
+ description: 'message displayed when a poll is published',
+ },
+ defaultViewLabel: {
+ id: 'app.title.defaultViewLabel',
+ description: 'view name apended to document title',
+ },
+ promotedLabel: {
+ id: 'app.toast.promotedLabel',
+ description: 'notification message when promoted',
+ },
+ demotedLabel: {
+ id: 'app.toast.demotedLabel',
+ description: 'notification message when demoted',
+ },
+});
+
+const propTypes = {
+ actionsbar: PropTypes.element,
+ captions: PropTypes.element,
+ darkTheme: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ actionsbar: null,
+ captions: null,
+};
+
+const isLayeredView = window.matchMedia(`(max-width: ${SMALL_VIEWPORT_BREAKPOINT}px)`);
+
+class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ enableResize: !window.matchMedia(MOBILE_MEDIA).matches,
+ isAudioModalOpen: false,
+ isRandomUserSelectModalOpen: false,
+ isVideoPreviewModalOpen: false,
+ presentationFitToWidth: false,
+ };
+
+ this.isTimerEnabled = TimerService.isEnabled();
+ this.timeOffsetInterval = null;
+
+ this.setPresentationFitToWidth = this.setPresentationFitToWidth.bind(this);
+ this.handleWindowResize = throttle(this.handleWindowResize).bind(this);
+ this.shouldAriaHide = this.shouldAriaHide.bind(this);
+ this.setAudioModalIsOpen = this.setAudioModalIsOpen.bind(this);
+ this.setRandomUserSelectModalIsOpen = this.setRandomUserSelectModalIsOpen.bind(this);
+ this.setVideoPreviewModalIsOpen = this.setVideoPreviewModalIsOpen.bind(this);
+
+ this.throttledDeviceType = throttle(() => this.setDeviceType(),
+ 50, { trailing: true, leading: true }).bind(this);
+ }
+
+ componentDidMount() {
+ const {
+ notify,
+ intl,
+ layoutContextDispatch,
+ isRTL,
+ transcriptionSettings,
+ } = this.props;
+ const { browserName } = browserInfo;
+ const { osName } = deviceInfo;
+
+ registerTitleView(intl.formatMessage(intlMessages.defaultViewLabel));
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_IS_RTL,
+ value: isRTL,
+ });
+
+ ReactModal.setAppElement('#app');
+
+ const fontSize = isMobile() ? MOBILE_FONT_SIZE : DESKTOP_FONT_SIZE;
+ document.getElementsByTagName('html')[0].style.fontSize = fontSize;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_FONT_SIZE,
+ value: parseInt(fontSize.slice(0, -2), 10),
+ });
+
+ const body = document.getElementsByTagName('body')[0];
+
+ if (browserName) {
+ body.classList.add(`browser-${browserName.split(' ').pop()
+ .toLowerCase()}`);
+ }
+
+ body.classList.add(`os-${osName.split(' ').shift().toLowerCase()}`);
+
+ this.handleWindowResize();
+ window.addEventListener('resize', this.handleWindowResize, false);
+ window.addEventListener('localeChanged', () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_IS_RTL,
+ value: Settings.application.isRTL,
+ });
+ });
+ window.ondragover = (e) => { e.preventDefault(); };
+ window.ondrop = (e) => { e.preventDefault(); };
+
+ if (CONFIRMATION_ON_LEAVE) {
+ window.onbeforeunload = (event) => {
+ AudioService.muteMicrophone();
+ event.stopImmediatePropagation();
+ event.preventDefault();
+ // eslint-disable-next-line no-param-reassign
+ event.returnValue = '';
+ };
+ }
+
+ if (deviceInfo.isMobile) makeCall('setMobileUser');
+
+ ConnectionStatusService.startRoundTripTime();
+
+ if (this.isTimerEnabled) {
+ TimerService.fetchTimeOffset();
+ this.timeOffsetInterval = setInterval(TimerService.fetchTimeOffset,
+ TimerService.OFFSET_INTERVAL);
+ }
+
+ if (transcriptionSettings) {
+ const { partialUtterances, minUtteranceLength } = transcriptionSettings;
+ if (partialUtterances !== undefined || minUtteranceLength !== undefined) {
+ logger.info({ logCode: 'app_component_set_speech_options' }, 'Setting initial speech options');
+
+ Settings.transcription.partialUtterances = partialUtterances ? true : false;
+ Settings.transcription.minUtteranceLength = parseInt(minUtteranceLength, 10);
+
+ SpeechService.setSpeechOptions(Settings.transcription.partialUtterances, Settings.transcription.minUtteranceLength);
+ }
+ }
+
+ logger.info({ logCode: 'app_component_componentdidmount' }, 'Client loaded successfully');
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ notify,
+ currentUserEmoji,
+ currentUserAway,
+ currentUserRaiseHand,
+ intl,
+ deviceType,
+ mountRandomUserModal,
+ selectedLayout,
+ sidebarContentIsOpen,
+ layoutContextDispatch,
+ numCameras,
+ presentationIsOpen,
+ } = this.props;
+
+ this.renderDarkMode();
+
+ if (mountRandomUserModal) this.setRandomUserSelectModalIsOpen(true);
+
+ if (prevProps.currentUserEmoji.status !== currentUserEmoji.status
+ && currentUserEmoji.status !== 'raiseHand'
+ && currentUserEmoji.status !== 'away'
+ ) {
+ const formattedEmojiStatus = intl.formatMessage({ id: `app.actionsBar.emojiMenu.${currentUserEmoji.status}Label` })
+ || currentUserEmoji.status;
+
+ if (currentUserEmoji.status === 'none') {
+ notify(
+ intl.formatMessage(intlMessages.clearedEmoji),
+ 'info',
+ 'clear_status',
+ );
+ } else {
+ notify(
+ intl.formatMessage(intlMessages.setEmoji, ({ 0: formattedEmojiStatus })),
+ 'info',
+ 'user',
+ );
+ }
+ }
+
+ if (prevProps.currentUserAway !== currentUserAway) {
+ if (currentUserAway === true) {
+ notify(intl.formatMessage(intlMessages.away), 'info', 'user');
+ } else {
+ notify(intl.formatMessage(intlMessages.notAway), 'info', 'clear_status');
+ }
+ }
+
+ if (prevProps.currentUserRaiseHand !== currentUserRaiseHand) {
+ if (currentUserRaiseHand === true) {
+ notify(intl.formatMessage(intlMessages.raisedHand), 'info', 'user');
+ } else {
+ notify(intl.formatMessage(intlMessages.loweredHand), 'info', 'clear_status');
+ }
+ }
+
+ if (deviceType === null || prevProps.deviceType !== deviceType) this.throttledDeviceType();
+
+ if (
+ selectedLayout !== prevProps.selectedLayout
+ && selectedLayout?.toLowerCase?.()?.includes?.('focus')
+ && !sidebarContentIsOpen
+ && deviceType !== DEVICE_TYPE.MOBILE
+ && numCameras > 0
+ && presentationIsOpen
+ ) {
+ setTimeout(() => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: DEFAULT_VALUES.idChatOpen,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+ }, 0);
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.handleWindowResize, false);
+ window.onbeforeunload = null;
+ ConnectionStatusService.stopRoundTripTime();
+
+ if (this.timeOffsetInterval) {
+ clearInterval(this.timeOffsetInterval);
+ }
+ }
+
+ setPresentationFitToWidth(presentationFitToWidth) {
+ this.setState({ presentationFitToWidth });
+ }
+
+ handleWindowResize() {
+ const { enableResize } = this.state;
+ const shouldEnableResize = !window.matchMedia(MOBILE_MEDIA).matches;
+ if (enableResize === shouldEnableResize) return;
+
+ this.setState({ enableResize: shouldEnableResize });
+ this.throttledDeviceType();
+ }
+
+ setDeviceType() {
+ const { deviceType, layoutContextDispatch } = this.props;
+ let newDeviceType = null;
+ if (isMobile()) newDeviceType = DEVICE_TYPE.MOBILE;
+ if (isTablet()) newDeviceType = DEVICE_TYPE.TABLET;
+ if (isTabletPortrait()) newDeviceType = DEVICE_TYPE.TABLET_PORTRAIT;
+ if (isTabletLandscape()) newDeviceType = DEVICE_TYPE.TABLET_LANDSCAPE;
+ if (isDesktop()) newDeviceType = DEVICE_TYPE.DESKTOP;
+
+ if (newDeviceType !== deviceType) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_DEVICE_TYPE,
+ value: newDeviceType,
+ });
+ }
+ }
+
+ shouldAriaHide() {
+ const { sidebarNavigationIsOpen, sidebarContentIsOpen, isPhone } = this.props;
+ return sidebarNavigationIsOpen
+ && sidebarContentIsOpen
+ && (isPhone || isLayeredView.matches);
+ }
+
+ renderCaptions() {
+ const {
+ captions,
+ captionsStyle,
+ } = this.props;
+
+ if (!captions) return null;
+
+ return (
+
+ {captions}
+
+ );
+ }
+
+ renderAudioCaptions() {
+ const {
+ audioCaptions,
+ captionsStyle,
+ } = this.props;
+
+ if (!audioCaptions) return null;
+
+ return (
+
+ {audioCaptions}
+
+ );
+ }
+
+ renderActionsBar() {
+ const {
+ intl,
+ actionsBarStyle,
+ hideActionsBar,
+ setPushLayout,
+ setMeetingLayout,
+ presentationIsOpen,
+ selectedLayout,
+ } = this.props;
+
+ const { showPushLayoutButton } = LAYOUT_CONFIG;
+
+ if (hideActionsBar) return null;
+
+ return (
+
+
+
+ );
+ }
+
+ renderActivityCheck() {
+ const { User } = this.props;
+
+ const { inactivityCheck, responseDelay } = User;
+
+ return (inactivityCheck ? (
+
+ ) : null);
+ }
+
+ renderUserInformation() {
+ const { UserInfo, User } = this.props;
+
+ return (UserInfo.length > 0 ? (
+
+ ) : null);
+ }
+
+ renderDarkMode() {
+ const { darkTheme } = this.props;
+
+ AppService.setDarkTheme(darkTheme);
+ }
+
+ mountPushLayoutEngine() {
+ const {
+ cameraWidth,
+ cameraHeight,
+ cameraIsResizing,
+ cameraPosition,
+ focusedCamera,
+ horizontalPosition,
+ isMeetingLayoutResizing,
+ isPresenter,
+ isModerator,
+ layoutContextDispatch,
+ meetingLayout,
+ meetingLayoutCameraPosition,
+ meetingLayoutFocusedCamera,
+ meetingLayoutVideoRate,
+ meetingPresentationIsOpen,
+ meetingLayoutUpdatedAt,
+ presentationIsOpen,
+ presentationVideoRate,
+ pushLayout,
+ pushLayoutMeeting,
+ selectedLayout,
+ setMeetingLayout,
+ setPushLayout,
+ shouldShowScreenshare,
+ shouldShowExternalVideo,
+ } = this.props;
+
+ return (
+
+ );
+ }
+
+ setAudioModalIsOpen(value) {
+ this.setState({isAudioModalOpen: value});
+ }
+
+ setVideoPreviewModalIsOpen(value) {
+ this.setState({isVideoPreviewModalOpen: value});
+ }
+
+ setRandomUserSelectModalIsOpen(value) {
+ const {setMountRandomUserModal} = this.props;
+ this.setState({isRandomUserSelectModalOpen: value});
+ setMountRandomUserModal(false);
+ }
+
+ render() {
+ const {
+ customStyle,
+ customStyleUrl,
+ audioAlertEnabled,
+ pushAlertEnabled,
+ shouldShowPresentation,
+ shouldShowScreenshare,
+ shouldShowExternalVideo,
+ shouldShowSharedNotes,
+ isPresenter,
+ selectedLayout,
+ presentationIsOpen,
+ darkTheme,
+ } = this.props;
+
+ const { isAudioModalOpen, isRandomUserSelectModalOpen, isVideoPreviewModalOpen, presentationFitToWidth } = this.state;
+
+ return (
+ <>
+
+ {this.mountPushLayoutEngine()}
+ {selectedLayout ? : null}
+
+
+ {this.renderActivityCheck()}
+ {this.renderUserInformation()}
+
+
+
+
+
+
+
+
+ {shouldShowPresentation ? : null}
+ {shouldShowScreenshare ? : null}
+ {
+ shouldShowExternalVideo
+ ?
+ : null
+ }
+ {shouldShowSharedNotes
+ ? (
+
+ ) : null}
+ {this.renderCaptions()}
+
+ {this.renderAudioCaptions()}
+
+
+
+
+
+ {(audioAlertEnabled || pushAlertEnabled)
+ && (
+
+ )}
+
+
+
+
+
+ {this.renderActionsBar()}
+
+ {customStyleUrl ? : null}
+ {customStyle ? : null}
+ {isRandomUserSelectModalOpen ? this.setRandomUserSelectModalIsOpen(false),
+ priority: "low",
+ setIsOpen: this.setRandomUserSelectModalIsOpen,
+ isOpen: isRandomUserSelectModalOpen,
+ }}
+ /> : null}
+
+ >
+ );
+ }
+}
+
+App.propTypes = propTypes;
+App.defaultProps = defaultProps;
+
+export default injectIntl(App);
diff --git a/src/2.7.12/imports/ui/components/app/container.jsx b/src/2.7.12/imports/ui/components/app/container.jsx
new file mode 100644
index 00000000..52006d38
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/app/container.jsx
@@ -0,0 +1,335 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users';
+import Meetings, { LayoutMeetings } from '/imports/api/meetings';
+import AudioCaptionsLiveContainer from '/imports/ui/components/audio/captions/history/container';
+import AudioCaptionsService from '/imports/ui/components/audio/captions/service';
+import { notify } from '/imports/ui/services/notification';
+import CaptionsContainer from '/imports/ui/components/captions/live/container';
+import CaptionsService from '/imports/ui/components/captions/service';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import deviceInfo from '/imports/utils/deviceInfo';
+import UserInfos from '/imports/api/users-infos';
+import Settings from '/imports/ui/services/settings';
+import MediaService from '/imports/ui/components/media/service';
+import LayoutService from '/imports/ui/components/layout/service';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+import {
+ layoutSelect,
+ layoutSelectInput,
+ layoutSelectOutput,
+ layoutDispatch,
+} from '../layout/context';
+import { isEqual } from 'radash';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+import {
+ getFontSize,
+ getBreakoutRooms,
+} from './service';
+
+import App from './component';
+
+const CUSTOM_STYLE_URL = Meteor.settings.public.app.customStyleUrl;
+
+const endMeeting = (code, ejectedReason) => {
+ Session.set('codeError', code);
+ Session.set('errorMessageDescription', ejectedReason);
+ Session.set('isMeetingEnded', true);
+};
+
+const AppContainer = (props) => {
+ function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+ }
+
+ const layoutType = useRef(null);
+
+ const {
+ actionsbar,
+ selectedLayout,
+ pushLayout,
+ pushLayoutMeeting,
+ currentUserId,
+ shouldShowPresentation: propsShouldShowPresentation,
+ presentationRestoreOnUpdate,
+ isPresenter,
+ randomlySelectedUser,
+ isModalOpen,
+ meetingLayout,
+ meetingLayoutUpdatedAt,
+ meetingPresentationIsOpen,
+ isMeetingLayoutResizing,
+ meetingLayoutCameraPosition,
+ meetingLayoutFocusedCamera,
+ meetingLayoutVideoRate,
+ isSharedNotesPinned,
+ ...otherProps
+ } = props;
+
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
+ const actionsBarStyle = layoutSelectOutput((i) => i.actionBar);
+ const captionsStyle = layoutSelectOutput((i) => i.captions);
+ const cameraDock = layoutSelectOutput((i) => i.cameraDock);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const presentation = layoutSelectInput((i) => i.presentation);
+ const deviceType = layoutSelect((i) => i.deviceType);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { sidebarContentPanel, isOpen: sidebarContentIsOpen } = sidebarContent;
+ const { sidebarNavPanel, isOpen: sidebarNavigationIsOpen } = sidebarNavigation;
+ const { isOpen } = presentation;
+ const presentationIsOpen = isOpen;
+
+ const shouldShowPresentation = (propsShouldShowPresentation
+ && (presentationIsOpen || presentationRestoreOnUpdate)) && isPresentationEnabled();
+
+ const { focusedId } = cameraDock;
+
+ useEffect(() => {
+ if (
+ layoutContextDispatch
+ && (typeof meetingLayout !== 'undefined')
+ && (layoutType.current !== meetingLayout)
+ && isSharedNotesPinned
+ ) {
+ layoutType.current = meetingLayout;
+ MediaService.setPresentationIsOpen(layoutContextDispatch, true);
+ }
+ }, [meetingLayout, layoutContextDispatch, layoutType]);
+
+ const horizontalPosition = cameraDock.position === 'contentLeft' || cameraDock.position === 'contentRight';
+ // this is not exactly right yet
+ let presentationVideoRate;
+ if (horizontalPosition) {
+ presentationVideoRate = cameraDock.width / window.innerWidth;
+ } else {
+ presentationVideoRate = cameraDock.height / window.innerHeight;
+ }
+ presentationVideoRate = parseFloat(presentationVideoRate.toFixed(2));
+
+ const prevRandomUser = usePrevious(randomlySelectedUser);
+
+ const [mountRandomUserModal, setMountRandomUserModal] = useState(false);
+
+ useEffect(() => {
+ setMountRandomUserModal(!isPresenter
+ && !isEqual(prevRandomUser, randomlySelectedUser)
+ && randomlySelectedUser.length > 0
+ && !isModalOpen);
+ }, [isPresenter, prevRandomUser, randomlySelectedUser, isModalOpen]);
+
+ const setPushLayout = () => {
+ LayoutService.setPushLayout(pushLayout);
+ }
+
+ const setMeetingLayout = () => {
+ const { isResizing } = cameraDockInput;
+ LayoutService.setMeetingLayout({
+ layout: selectedLayout,
+ presentationIsOpen,
+ isResizing,
+ cameraPosition: cameraDock.position,
+ focusedCamera: focusedId,
+ presentationVideoRate,
+ pushLayout,
+ });
+ };
+
+ useEffect(() => {
+ MediaService.buildLayoutWhenPresentationAreaIsDisabled(layoutContextDispatch)
+ });
+
+ return currentUserId
+ ? (
+
+ )
+ : null;
+};
+
+const currentUserEmoji = (currentUser) => (currentUser
+ ? {
+ status: currentUser.emoji,
+ changedAt: currentUser.emojiTime,
+ }
+ : {
+ status: 'none',
+ changedAt: null,
+ }
+);
+
+export default withTracker(() => {
+ Users.find({ userId: Auth.userID, meetingId: Auth.meetingID }).observe({
+ removed(userData) {
+ // wait 3secs (before endMeeting), client will try to authenticate again
+ const delayForReconnection = userData.ejected ? 0 : 3000;
+ setTimeout(() => {
+ const queryCurrentUser = Users.find({ userId: Auth.userID, meetingId: Auth.meetingID });
+ if (queryCurrentUser.count() === 0) {
+ if (userData.ejected) {
+ endMeeting('403', userData.ejectedReason);
+ } else {
+ // Either authentication process hasn't finished yet or user did authenticate but Users
+ // collection is unsynchronized. In both cases user may be able to rejoin.
+ const description = Auth.isAuthenticating || Auth.loggedIn
+ ? 'able_to_rejoin_user_disconnected_reason'
+ : null;
+ endMeeting('503', description);
+ }
+ }
+ }, delayForReconnection);
+ },
+ });
+
+ const currentUser = Users.findOne(
+ { userId: Auth.userID },
+ {
+ fields:
+ {
+ approved: 1, emoji: 1, raiseHand: 1, away: 1, userId: 1, presenter: 1, role: 1,
+ },
+ },
+ );
+
+ const currentMeeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ {
+ fields: {
+ randomlySelectedUser: 1,
+ layout: 1,
+ },
+ });
+ const {
+ randomlySelectedUser,
+ } = currentMeeting;
+
+ const meetingLayoutObj = LayoutMeetings.findOne({ meetingId: Auth.meetingID }) || {};
+ const {
+ layout: meetingLayout,
+ pushLayout: pushLayoutMeeting,
+ layoutUpdatedAt: meetingLayoutUpdatedAt,
+ presentationIsOpen: meetingPresentationIsOpen,
+ isResizing: isMeetingLayoutResizing,
+ cameraPosition: meetingLayoutCameraPosition,
+ focusedCamera: meetingLayoutFocusedCamera,
+ presentationVideoRate: meetingLayoutVideoRate,
+ } = meetingLayoutObj;
+
+ const UserInfo = UserInfos.find({
+ meetingId: Auth.meetingID,
+ requesterUserId: Auth.userID,
+ }).fetch();
+
+ const AppSettings = Settings.application;
+ const { selectedLayout, pushLayout } = AppSettings;
+ const { viewScreenshare } = Settings.dataSaving;
+ const shouldShowSharedNotes = MediaService.shouldShowSharedNotes();
+ const shouldShowExternalVideo = MediaService.shouldShowExternalVideo();
+ const shouldShowScreenshare = MediaService.shouldShowScreenshare()
+ && (viewScreenshare || currentUser?.presenter);
+ let customStyleUrl = getFromUserSettings('bbb_custom_style_url', false);
+
+ if (!customStyleUrl && CUSTOM_STYLE_URL) {
+ customStyleUrl = CUSTOM_STYLE_URL;
+ }
+
+ const LAYOUT_CONFIG = Meteor.settings.public.layout;
+
+ const isPresenter = currentUser?.presenter;
+
+ const transcriptionSettings = {
+ partialUtterances: getFromUserSettings('bbb_transcription_partial_utterances'),
+ minUtteranceLength: getFromUserSettings('bbb_transcription_min_utterance_length'),
+ };
+
+ return {
+ captions: CaptionsService.isCaptionsActive() ? : null,
+ audioCaptions: AudioCaptionsService.getAudioCaptions() ? : null,
+ fontSize: getFontSize(),
+ hasBreakoutRooms: getBreakoutRooms().length > 0,
+ customStyle: getFromUserSettings('bbb_custom_style', false),
+ customStyleUrl,
+ UserInfo,
+ notify,
+ isPhone: deviceInfo.isPhone,
+ isRTL: document.documentElement.getAttribute('dir') === 'rtl',
+ currentUserEmoji: currentUserEmoji(currentUser),
+ currentUserAway: currentUser?.away,
+ currentUserRaiseHand: currentUser?.raiseHand,
+ randomlySelectedUser,
+ currentUserId: currentUser?.userId,
+ isPresenter,
+ isModerator: currentUser?.role === ROLE_MODERATOR,
+ meetingLayout,
+ meetingLayoutUpdatedAt,
+ meetingPresentationIsOpen,
+ isMeetingLayoutResizing,
+ meetingLayoutCameraPosition,
+ meetingLayoutFocusedCamera,
+ meetingLayoutVideoRate,
+ selectedLayout,
+ pushLayout,
+ pushLayoutMeeting,
+ audioAlertEnabled: AppSettings.chatAudioAlerts,
+ pushAlertEnabled: AppSettings.chatPushAlerts,
+ darkTheme: AppSettings.darkTheme,
+ shouldShowScreenshare,
+ shouldShowPresentation: !shouldShowScreenshare && !shouldShowExternalVideo && !shouldShowSharedNotes,
+ shouldShowExternalVideo,
+ shouldShowSharedNotes,
+ isLargeFont: Session.get('isLargeFont'),
+ presentationRestoreOnUpdate: getFromUserSettings(
+ 'bbb_force_restore_presentation_on_new_events',
+ Meteor.settings.public.presentation.restoreOnUpdate,
+ ),
+ hidePresentationOnJoin: getFromUserSettings('bbb_hide_presentation_on_join', LAYOUT_CONFIG.hidePresentationOnJoin),
+ hideActionsBar: getFromUserSettings('bbb_hide_actions_bar', false),
+ ignorePollNotifications: Session.get('ignorePollNotifications'),
+ isSharedNotesPinned: MediaService.shouldShowSharedNotes(),
+ transcriptionSettings,
+ };
+})(AppContainer);
diff --git a/src/2.7.12/imports/ui/components/app/service.js b/src/2.7.12/imports/ui/components/app/service.js
new file mode 100644
index 00000000..f1dbbdd7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/app/service.js
@@ -0,0 +1,52 @@
+import Breakouts from '/imports/api/breakouts';
+import Meetings from '/imports/api/meetings';
+import Settings from '/imports/ui/services/settings';
+import Auth from '/imports/ui/services/auth/index';
+import deviceInfo from '/imports/utils/deviceInfo';
+import Styled from './styles';
+import DarkReader from 'darkreader';
+import logger from '/imports/startup/client/logger';
+
+const getFontSize = () => {
+ const applicationSettings = Settings.application;
+ return applicationSettings ? applicationSettings.fontSize : '16px';
+};
+
+const getBreakoutRooms = () => Breakouts.find().fetch();
+
+function meetingIsBreakout() {
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'meetingProp.isBreakout': 1 } });
+ return (meeting && meeting.meetingProp.isBreakout);
+}
+
+const setDarkTheme = (value) => {
+ if (value && !DarkReader.isEnabled()) {
+ DarkReader.enable(
+ { brightness: 100, contrast: 90 },
+ { invert: [Styled.DtfInvert], ignoreInlineStyle: [Styled.DtfCss], ignoreImageAnalysis: [Styled.DtfImages] },
+ )
+ logger.info({
+ logCode: 'dark_mode',
+ }, 'Dark mode is on.');
+ }
+
+ if (!value && DarkReader.isEnabled()){
+ DarkReader.disable();
+ logger.info({
+ logCode: 'dark_mode',
+ }, 'Dark mode is off.');
+ }
+}
+
+const isDarkThemeEnabled = () => {
+ return DarkReader.isEnabled()
+}
+
+export {
+ getFontSize,
+ meetingIsBreakout,
+ getBreakoutRooms,
+ setDarkTheme,
+ isDarkThemeEnabled,
+};
diff --git a/src/2.7.12/imports/ui/components/app/styles.js b/src/2.7.12/imports/ui/components/app/styles.js
new file mode 100644
index 00000000..99358771
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/app/styles.js
@@ -0,0 +1,98 @@
+import styled from 'styled-components';
+import { barsPadding } from '/imports/ui/stylesheets/styled-components/general';
+import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders';
+import { colorBackground } from '/imports/ui/stylesheets/styled-components/palette';
+
+const CaptionsWrapper = styled.div`
+ height: auto;
+ bottom: 100px;
+ left: 20%;
+ z-index: 5;
+ pointer-events: none;
+ user-select:none;
+`;
+
+const ActionsBar = styled.section`
+ flex: 1;
+ padding: ${barsPadding};
+ background-color: ${colorBackground};
+ position: relative;
+ order: 3;
+`;
+
+const Layout = styled(FlexColumn)``;
+
+const DtfInvert = `
+ body {
+ background-color: var(--darkreader-neutral-background) !important;
+ }
+ header[id="Navbar"] {
+ background-color: var(--darkreader-neutral-background) !important;
+ }
+ section[id="ActionsBar"] {
+ background-color: var(--darkreader-neutral-background) !important;
+ }
+ select {
+ border: 0.1rem solid #FFFFFF !important;
+ }
+ select[data-test="skipSlide"] {
+ border: unset !important;
+ }
+ div[data-test="presentationContainer"] {
+ background-color: var(--darkreader-neutral-background) !important;
+ }
+ select {
+ border-top: unset !important;
+ border-right: unset !important;
+ border-left: unset !important;
+ }
+ .tl-container {
+ background-color: var(--tl-background) !important;
+ }
+ #TD-Tools button, #TD-TopPanel-Undo, #TD-TopPanel-Redo, #TD-Styles {
+ border-color: transparent !important;
+ }
+ [id="TD-StylesMenu"],
+ [id="TD-Styles-Color-Container"],
+ div[data-test="brandingArea"],
+ #connectionBars > div
+`;
+
+const DtfCss = `
+ [id="colorPicker"],
+ path,
+ svg
+`;
+
+const DtfImages = `
+ svg
+`;
+
+const TextMeasure = styled.pre`
+ white-space: pre;
+ width: auto;
+ border: 1px solid red;
+ padding: 4px;
+ margin: 0px;
+ letter-spacing: -0.03em;
+ opacity: 0;
+ position: absolute;
+ top: -500px;
+ left: 0px;
+ z-index: 9999;
+ pointer-events: none;
+ user-select: none;
+ alignment-baseline: mathematical;
+ dominant-baseline: mathematical;
+ font-family: "Source Code Pro";
+`;
+
+export default {
+ CaptionsWrapper,
+ ActionsBar,
+ Layout,
+ DtfInvert,
+ DtfCss,
+ DtfImages,
+ TextMeasure,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-controls/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-controls/component.jsx
new file mode 100644
index 00000000..df076956
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-controls/component.jsx
@@ -0,0 +1,179 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import deviceInfo from '/imports/utils/deviceInfo';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import InputStreamLiveSelectorContainer from './input-stream-live-selector/container';
+import MutedAlert from '/imports/ui/components/muted-alert/component';
+import Styled from './styles';
+import Button from '/imports/ui/components/common/button/component';
+import AudioModalContainer from '../audio-modal/container';
+
+const intlMessages = defineMessages({
+ joinAudio: {
+ id: 'app.audio.joinAudio',
+ description: 'Join audio button label',
+ },
+ leaveAudio: {
+ id: 'app.audio.leaveAudio',
+ description: 'Leave audio button label',
+ },
+ muteAudio: {
+ id: 'app.actionsBar.muteLabel',
+ description: 'Mute audio button label',
+ },
+ unmuteAudio: {
+ id: 'app.actionsBar.unmuteLabel',
+ description: 'Unmute audio button label',
+ },
+});
+
+const propTypes = {
+ shortcuts: PropTypes.objectOf(PropTypes.string).isRequired,
+ handleToggleMuteMicrophone: PropTypes.func.isRequired,
+ handleLeaveAudio: PropTypes.func.isRequired,
+ disable: PropTypes.bool.isRequired,
+ muted: PropTypes.bool.isRequired,
+ showMute: PropTypes.bool.isRequired,
+ inAudio: PropTypes.bool.isRequired,
+ listenOnly: PropTypes.bool.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ talking: PropTypes.bool.isRequired,
+};
+
+class AudioControls extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isAudioModalOpen: false,
+ };
+
+ this.renderButtonsAndStreamSelector = this.renderButtonsAndStreamSelector.bind(this);
+ this.renderJoinLeaveButton = this.renderJoinLeaveButton.bind(this);
+ this.setAudioModalIsOpen = this.setAudioModalIsOpen.bind(this);
+ }
+
+ renderJoinButton() {
+ const {
+ disable,
+ intl,
+ shortcuts,
+ joinListenOnly,
+ isConnected
+ } = this.props;
+
+ return (
+ this.handleJoinAudio(joinListenOnly, isConnected)}
+ disabled={disable}
+ hideLabel
+ aria-label={intl.formatMessage(intlMessages.joinAudio)}
+ label={intl.formatMessage(intlMessages.joinAudio)}
+ data-test="joinAudio"
+ color="default"
+ ghost
+ icon="no_audio"
+ size="lg"
+ circle
+ accessKey={shortcuts.joinaudio}
+ />
+ );
+ }
+
+ renderButtonsAndStreamSelector(_enableDynamicDeviceSelection) {
+ const {
+ handleLeaveAudio, handleToggleMuteMicrophone, muted, disable, talking,
+ } = this.props;
+
+ const { isMobile } = deviceInfo;
+
+ return (
+
+ );
+ }
+
+ renderJoinLeaveButton() {
+ const {
+ inAudio,
+ } = this.props;
+
+ let { enableDynamicAudioDeviceSelection } = Meteor.settings.public.app;
+
+ if (typeof enableDynamicAudioDeviceSelection === 'undefined') {
+ enableDynamicAudioDeviceSelection = true;
+ }
+
+ if (inAudio) {
+ return this.renderButtonsAndStreamSelector(enableDynamicAudioDeviceSelection);
+ }
+
+ return this.renderJoinButton();
+ }
+
+ handleJoinAudio(joinListenOnly, isConnected) {
+ (isConnected()
+ ? joinListenOnly()
+ : this.setAudioModalIsOpen(true)
+ )}
+
+ setAudioModalIsOpen(value) {
+ this.setState({ isAudioModalOpen: value })
+ }
+
+ render() {
+ const {
+ showMute,
+ muted,
+ isVoiceUser,
+ listenOnly,
+ inputStream,
+ isViewer,
+ isPresenter,
+ } = this.props;
+
+ const { isAudioModalOpen } = this.state;
+
+ const MUTE_ALERT_CONFIG = Meteor.settings.public.app.mutedAlert;
+ const { enabled: muteAlertEnabled } = MUTE_ALERT_CONFIG;
+
+ return (
+
+ {isVoiceUser && inputStream && muteAlertEnabled && !listenOnly && muted && showMute ? (
+
+ ) : null}
+ {
+ this.renderJoinLeaveButton()
+ }
+ {
+ isAudioModalOpen ? : null
+ }
+
+ );
+ }
+}
+
+AudioControls.propTypes = propTypes;
+
+export default withShortcutHelper(injectIntl(AudioControls), ['joinAudio',
+ 'leaveAudio', 'toggleMute']);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-controls/container.jsx b/src/2.7.12/imports/ui/components/audio/audio-controls/container.jsx
new file mode 100644
index 00000000..d003dc26
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-controls/container.jsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import AudioManager from '/imports/ui/services/audio-manager';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import { withUsersConsumer } from '/imports/ui/components/components-data/users-context/context';
+import logger from '/imports/startup/client/logger';
+import Auth from '/imports/ui/services/auth';
+import Storage from '/imports/ui/services/storage/session';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import AudioControls from './component';
+import {
+ setUserSelectedMicrophone,
+ setUserSelectedListenOnly,
+} from '../audio-modal/service';
+import { layoutSelect } from '/imports/ui/components/layout/context';
+
+import Service from '../service';
+import AppService from '/imports/ui/components/app/service';
+
+const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
+const APP_CONFIG = Meteor.settings.public.app;
+
+const AudioControlsContainer = (props) => {
+ const {
+ users, lockSettings, userLocks, children, ...newProps
+ } = props;
+ const isRTL = layoutSelect((i) => i.isRTL);
+ return ;
+};
+
+const handleLeaveAudio = () => {
+ const meetingIsBreakout = AppService.meetingIsBreakout();
+
+ if (!meetingIsBreakout) {
+ setUserSelectedMicrophone(false);
+ setUserSelectedListenOnly(false);
+ }
+
+ const skipOnFistJoin = getFromUserSettings('bbb_skip_check_audio_on_first_join', APP_CONFIG.skipCheckOnJoin);
+ if (skipOnFistJoin && !Storage.getItem('getEchoTest')) {
+ Storage.setItem('getEchoTest', true);
+ }
+
+ Service.forceExitAudio();
+ logger.info({
+ logCode: 'audiocontrols_leave_audio',
+ extraInfo: { logType: 'user_action' },
+ }, 'audio connection closed by user');
+};
+
+const {
+ isVoiceUser,
+ isConnected,
+ isListenOnly,
+ isEchoTest,
+ isMuted,
+ isConnecting,
+ isHangingUp,
+ isTalking,
+ toggleMuteMicrophone,
+ joinListenOnly,
+} = Service;
+
+export default withUsersConsumer(
+ lockContextContainer(
+ withTracker(({ userLocks, users }) => {
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const isViewer = currentUser.role === ROLE_VIEWER;
+ const isPresenter = currentUser.presenter;
+ const { status } = Service.getBreakoutAudioTransferStatus();
+
+ if (status === AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.RETURNING) {
+ Service.setBreakoutAudioTransferStatus({
+ status: AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ });
+ Service.recoverMicState();
+ }
+
+ return ({
+ showMute: isConnected() && !isListenOnly() && !isEchoTest() && !userLocks.userMic,
+ muted: isConnected() && !isListenOnly() && isMuted(),
+ inAudio: isConnected() && !isEchoTest(),
+ listenOnly: isConnected() && isListenOnly(),
+ disable: isConnecting() || isHangingUp() || !Meteor.status().connected,
+ talking: isTalking() && !isMuted(),
+ isVoiceUser: isVoiceUser(),
+ handleToggleMuteMicrophone: () => toggleMuteMicrophone(),
+ joinListenOnly,
+ handleLeaveAudio,
+ inputStream: AudioManager.inputStream,
+ isViewer,
+ isPresenter,
+ isConnected,
+ });
+ })(AudioControlsContainer),
+ ),
+);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx
new file mode 100644
index 00000000..1d2b1b05
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx
@@ -0,0 +1,509 @@
+import React, { Component } from 'react';
+import logger from '/imports/startup/client/logger';
+import Auth from '/imports/ui/services/auth';
+import Settings from '/imports/ui/services/settings';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Button from '/imports/ui/components/common/button/component';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+
+import Styled from './styles';
+
+const AUDIO_INPUT = 'audioinput';
+const AUDIO_OUTPUT = 'audiooutput';
+const DEFAULT_DEVICE = 'default';
+const DEVICE_LABEL_MAX_LENGTH = 40;
+const SET_SINK_ID_SUPPORTED = 'setSinkId' in HTMLMediaElement.prototype;
+
+const intlMessages = defineMessages({
+ changeAudioDevice: {
+ id: 'app.audio.changeAudioDevice',
+ description: 'Change audio device button label',
+ },
+ leaveAudio: {
+ id: 'app.audio.leaveAudio',
+ description: 'Leave audio dropdown item label',
+ },
+ loading: {
+ id: 'app.audio.loading',
+ description: 'Loading audio dropdown item label',
+ },
+ noDeviceFound: {
+ id: 'app.audio.noDeviceFound',
+ description: 'No device found',
+ },
+ microphones: {
+ id: 'app.audio.microphones',
+ description: 'Input audio dropdown item label',
+ },
+ speakers: {
+ id: 'app.audio.speakers',
+ description: 'Output audio dropdown item label',
+ },
+ muteAudio: {
+ id: 'app.actionsBar.muteLabel',
+ description: 'Mute audio button label',
+ },
+ unmuteAudio: {
+ id: 'app.actionsBar.unmuteLabel',
+ description: 'Unmute audio button label',
+ },
+ deviceChangeFailed: {
+ id: 'app.audioNotification.deviceChangeFailed',
+ description: 'Device change failed',
+ },
+ defaultOutputDeviceLabel: {
+ id: 'app.audio.audioSettings.defaultOutputDeviceLabel',
+ description: 'Default output device label',
+ },
+});
+
+const propTypes = {
+ liveChangeInputDevice: PropTypes.func.isRequired,
+ handleLeaveAudio: PropTypes.func.isRequired,
+ liveChangeOutputDevice: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ shortcuts: PropTypes.objectOf(PropTypes.string).isRequired,
+ currentInputDeviceId: PropTypes.string.isRequired,
+ currentOutputDeviceId: PropTypes.string.isRequired,
+ isListenOnly: PropTypes.bool.isRequired,
+ isAudioConnected: PropTypes.bool.isRequired,
+ _enableDynamicDeviceSelection: PropTypes.bool.isRequired,
+ handleToggleMuteMicrophone: PropTypes.func.isRequired,
+ muted: PropTypes.bool.isRequired,
+ disable: PropTypes.bool.isRequired,
+ talking: PropTypes.bool,
+ notify: PropTypes.func.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ talking: false,
+};
+
+class InputStreamLiveSelector extends Component {
+ static truncateDeviceName(deviceName) {
+ if (deviceName && (deviceName.length <= DEVICE_LABEL_MAX_LENGTH)) {
+ return deviceName;
+ }
+ return `${deviceName.substring(0, DEVICE_LABEL_MAX_LENGTH - 3)}...`;
+ }
+
+ constructor(props) {
+ super(props);
+ this.updateDeviceList = this.updateDeviceList.bind(this);
+ this.renderDeviceList = this.renderDeviceList.bind(this);
+ this.renderAudioButton = this.renderAudioButton.bind(this);
+ this.renderMuteToggleButton = this.renderMuteToggleButton.bind(this);
+ this.renderButtonsWithSelectorDevice = this.renderButtonsWithSelectorDevice.bind(this);
+ this.renderButtonsWithoutSelectorDevice = this.renderButtonsWithoutSelectorDevice.bind(this);
+ this.state = {
+ audioInputDevices: null,
+ audioOutputDevices: null,
+ selectedInputDeviceId: null,
+ selectedOutputDeviceId: null,
+ };
+ }
+
+ componentDidMount() {
+ this.updateDeviceList().then(() => {
+ navigator.mediaDevices
+ .addEventListener('devicechange', this.updateDeviceList);
+ this.setCurrentDevices();
+ });
+ }
+
+ componentWillUnmount() {
+ navigator.mediaDevices.removeEventListener('devicechange',
+ this.updateDeviceList);
+ }
+
+ onDeviceListClick(deviceId, deviceKind, callback) {
+ if (!deviceId) return;
+
+ const { intl, notify } = this.props;
+
+ if (deviceKind === AUDIO_INPUT) {
+ callback(deviceId).then(() => {
+ this.setState({ selectedInputDeviceId: deviceId });
+ }).catch((error) => {
+ notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
+ });
+ } else {
+ callback(deviceId, true).then(() => {
+ this.setState({ selectedOutputDeviceId: deviceId });
+ }).catch((error) => {
+ notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
+ });
+ }
+ }
+
+ setCurrentDevices() {
+ const {
+ currentInputDeviceId,
+ currentOutputDeviceId,
+ } = this.props;
+
+ const {
+ audioInputDevices,
+ audioOutputDevices,
+ } = this.state;
+
+ if (!audioInputDevices
+ || !audioInputDevices[0]
+ || !audioOutputDevices
+ || !audioOutputDevices[0]) return;
+
+ const _currentInputDeviceId = audioInputDevices.find(
+ (d) => d.deviceId === currentInputDeviceId,
+ ) ? currentInputDeviceId : audioInputDevices[0].deviceId;
+
+ const _currentOutputDeviceId = audioOutputDevices.find(
+ (d) => d.deviceId === currentOutputDeviceId,
+ ) ? currentOutputDeviceId : audioOutputDevices[0].deviceId;
+
+ this.setState({
+ selectedInputDeviceId: _currentInputDeviceId,
+ selectedOutputDeviceId: _currentOutputDeviceId,
+ });
+ }
+
+ fallbackInputDevice(fallbackDevice) {
+ if (!fallbackDevice || !fallbackDevice.deviceId) return;
+
+ const {
+ liveChangeInputDevice,
+ } = this.props;
+
+ logger.info({
+ logCode: 'audio_device_live_selector',
+ extraInfo: {
+ userId: Auth.userID,
+ meetingId: Auth.meetingID,
+ },
+ }, 'Current input device was removed. Fallback to default device');
+ liveChangeInputDevice(fallbackDevice.deviceId).then(() => {
+ this.setState({ selectedInputDeviceId: fallbackDevice.deviceId });
+ }).catch((error) => {
+ notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
+ });
+ }
+
+ fallbackOutputDevice(fallbackDevice) {
+ if (!fallbackDevice || !fallbackDevice.deviceId) return;
+
+ const {
+ liveChangeOutputDevice,
+ } = this.props;
+
+ logger.info({
+ logCode: 'audio_device_live_selector',
+ extraInfo: {
+ userId: Auth.userID,
+ meetingId: Auth.meetingID,
+ },
+ }, 'Current output device was removed. Fallback to default device');
+ liveChangeOutputDevice(fallbackDevice.deviceId, true).then(() => {
+ this.setState({ selectedOutputDeviceId: fallbackDevice.deviceId });
+ }).catch((error) => {
+ notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
+ });
+ }
+
+ updateRemovedDevices(audioInputDevices, audioOutputDevices) {
+ const {
+ selectedInputDeviceId,
+ selectedOutputDeviceId,
+ } = this.state;
+
+ if (selectedInputDeviceId
+ && (selectedInputDeviceId !== DEFAULT_DEVICE)
+ && !audioInputDevices.find((d) => d.deviceId === selectedInputDeviceId)) {
+ this.fallbackInputDevice(audioInputDevices[0]);
+ }
+
+ if (selectedOutputDeviceId
+ && (selectedOutputDeviceId !== DEFAULT_DEVICE)
+ && !audioOutputDevices.find((d) => d.deviceId === selectedOutputDeviceId)) {
+ this.fallbackOutputDevice(audioOutputDevices[0]);
+ }
+ }
+
+ updateDeviceList() {
+ const {
+ isAudioConnected,
+ } = this.props;
+
+ return navigator.mediaDevices.enumerateDevices()
+ .then((devices) => {
+ const audioInputDevices = devices.filter((i) => i.kind === AUDIO_INPUT);
+ const audioOutputDevices = devices.filter((i) => i.kind === AUDIO_OUTPUT);
+
+ this.setState({
+ audioInputDevices,
+ audioOutputDevices,
+ });
+
+ if (isAudioConnected) {
+ this.updateRemovedDevices(audioInputDevices, audioOutputDevices);
+ }
+ });
+ }
+
+ renderDeviceList(deviceKind, list, callback, title, currentDeviceId) {
+ const {
+ intl,
+ } = this.props;
+ const listLength = list ? list.length : -1;
+ const listTitle = [
+ {
+ key: `audioDeviceList-${deviceKind}`,
+ label: title,
+ iconRight: (deviceKind === 'audioinput') ? 'unmute' : 'volume_level_2',
+ disabled: true,
+ customStyles: Styled.DisabledLabel,
+ divider: true,
+ },
+ ];
+
+ let deviceList = [];
+
+ if (listLength > 0) {
+ deviceList = list.map((device, index) => (
+ {
+ key: `${device.deviceId}-${deviceKind}`,
+ dataTest: `${deviceKind}-${index + 1}`,
+ label: InputStreamLiveSelector.truncateDeviceName(device.label),
+ customStyles: (device.deviceId === currentDeviceId) && Styled.SelectedLabel,
+ iconRight: (device.deviceId === currentDeviceId) ? 'check' : null,
+ onClick: () => this.onDeviceListClick(device.deviceId, deviceKind, callback),
+ }
+ ));
+ } else if (deviceKind === AUDIO_OUTPUT && !SET_SINK_ID_SUPPORTED && listLength === 0) {
+ // If the browser doesn't support setSinkId, show the chosen output device
+ // as a placeholder Default - like it's done in audio/device-selector
+ deviceList = [
+ {
+ key: `defaultDeviceKey-${deviceKind}`,
+ label: intl.formatMessage(intlMessages.defaultOutputDeviceLabel),
+ customStyles: Styled.SelectedLabel,
+ iconRight: 'check',
+ disabled: true,
+ },
+ ];
+ } else {
+ deviceList = [
+ {
+ key: `noDeviceFoundKey-${deviceKind}-`,
+ label: listLength < 0
+ ? intl.formatMessage(intlMessages.loading)
+ : intl.formatMessage(intlMessages.noDeviceFound),
+ },
+ ];
+ }
+
+ return listTitle.concat(deviceList);
+ }
+
+ renderMuteToggleButton() {
+ const {
+ intl,
+ shortcuts,
+ handleToggleMuteMicrophone,
+ muted,
+ disable,
+ talking,
+ } = this.props;
+
+ const label = muted ? intl.formatMessage(intlMessages.unmuteAudio)
+ : intl.formatMessage(intlMessages.muteAudio);
+
+ const { animations } = Settings.application;
+
+ return (
+ {
+ e.stopPropagation();
+ handleToggleMuteMicrophone();
+ }}
+ disabled={disable}
+ hideLabel
+ label={label}
+ aria-label={label}
+ color={!muted ? 'primary' : 'default'}
+ ghost={muted}
+ icon={muted ? 'mute' : 'unmute'}
+ size="lg"
+ circle
+ accessKey={shortcuts.togglemute}
+ $talking={talking || undefined}
+ animations={animations}
+ data-test={muted ? 'unmuteMicButton' : 'muteMicButton'}
+ />
+ );
+ }
+
+ renderAudioButton() {
+ const {
+ handleLeaveAudio,
+ intl,
+ shortcuts,
+ isListenOnly,
+ _enableDynamicDeviceSelection,
+ isMobile,
+ } = this.props;
+
+ const actAsSelectorTrigger = _enableDynamicDeviceSelection && isMobile;
+ return (
+ null
+ : (e) => {
+ e.stopPropagation();
+ handleLeaveAudio();
+ }}
+ />
+ );
+ }
+
+ renderMenuTrigger() {
+ const { intl, isMobile, isListenOnly } = this.props;
+
+ return (
+ <>
+ {!isListenOnly && !isMobile
+ ? this.renderMuteToggleButton()
+ : this.renderAudioButton()}
+
+ >
+ );
+ }
+
+ renderButtonsWithSelectorDevice() {
+ const {
+ audioInputDevices,
+ audioOutputDevices,
+ selectedInputDeviceId,
+ selectedOutputDeviceId,
+ } = this.state;
+
+ const {
+ liveChangeInputDevice,
+ handleLeaveAudio,
+ liveChangeOutputDevice,
+ intl,
+ currentInputDeviceId,
+ currentOutputDeviceId,
+ isListenOnly,
+ shortcuts,
+ isMobile,
+ } = this.props;
+
+ const inputDeviceList = !isListenOnly
+ ? this.renderDeviceList(
+ AUDIO_INPUT,
+ audioInputDevices,
+ liveChangeInputDevice,
+ intl.formatMessage(intlMessages.microphones),
+ selectedInputDeviceId || currentInputDeviceId,
+ ) : [];
+
+ const outputDeviceList = this.renderDeviceList(
+ AUDIO_OUTPUT,
+ audioOutputDevices,
+ liveChangeOutputDevice,
+ intl.formatMessage(intlMessages.speakers),
+ selectedOutputDeviceId || currentOutputDeviceId,
+ false,
+ );
+
+ const leaveAudioOption = {
+ icon: 'logout',
+ label: intl.formatMessage(intlMessages.leaveAudio),
+ key: 'leaveAudioOption',
+ dataTest: 'leaveAudio',
+ customStyles: Styled.DangerColor,
+ dividerTop: true,
+ onClick: () => handleLeaveAudio(),
+ };
+
+ const dropdownListComplete = inputDeviceList.concat(outputDeviceList).concat(leaveAudioOption);
+ const customStyles = { top: '-1rem' };
+
+ return (
+ <>
+ {!isListenOnly ? (
+ handleLeaveAudio()}
+ aria-hidden="true"
+ />
+ ) : null}
+ {(!isListenOnly && isMobile) && this.renderMuteToggleButton()}
+
+ >
+ );
+ }
+
+ renderButtonsWithoutSelectorDevice() {
+ const { isListenOnly } = this.props;
+ return isListenOnly
+ ? this.renderAudioButton()
+ : (
+ <>
+ {this.renderMuteToggleButton()}
+ {this.renderAudioButton()}
+ >
+ );
+ }
+
+ render() {
+ const { _enableDynamicDeviceSelection } = this.props;
+
+ return _enableDynamicDeviceSelection
+ ? this.renderButtonsWithSelectorDevice()
+ : this.renderButtonsWithoutSelectorDevice();
+ }
+}
+
+InputStreamLiveSelector.propTypes = propTypes;
+InputStreamLiveSelector.defaultProps = defaultProps;
+
+export default withShortcutHelper(injectIntl(InputStreamLiveSelector),
+ ['leaveAudio', 'toggleMute']);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx b/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx
new file mode 100644
index 00000000..7d71d372
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx
@@ -0,0 +1,23 @@
+import React, { PureComponent } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import InputStreamLiveSelector from './component';
+import Service from '../../service';
+
+class InputStreamLiveSelectorContainer extends PureComponent {
+ render() {
+ return (
+
+ );
+ }
+}
+
+export default withTracker(({ handleLeaveAudio }) => ({
+ isAudioConnected: Service.isConnected(),
+ isListenOnly: Service.isListenOnly(),
+ currentInputDeviceId: Service.inputDeviceId(),
+ currentOutputDeviceId: Service.outputDeviceId(),
+ liveChangeInputDevice: Service.liveChangeInputDevice,
+ liveChangeOutputDevice: Service.changeOutputDevice,
+ notify: Service.notify,
+ handleLeaveAudio,
+}))(InputStreamLiveSelectorContainer);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/styles.js b/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/styles.js
new file mode 100644
index 00000000..2e5e1ee3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-controls/input-stream-live-selector/styles.js
@@ -0,0 +1,83 @@
+import styled, { css, keyframes } from 'styled-components';
+import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ colorPrimary,
+ colorDanger,
+ colorGrayDark,
+ colorOffWhite,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const pulse = keyframes`
+ 0% {
+ box-shadow: 0 0 0 0 white;
+ }
+ 70% {
+ box-shadow: 0 0 0 0.5625rem transparent;
+ }
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+`;
+
+const AudioDropdown = styled(ButtonEmoji)`
+ span {
+ i {
+ width: 0px !important;
+ bottom: 1px;
+ }
+ }
+`;
+
+const MuteToggleButton = styled(Button)`
+
+ ${({ ghost }) => ghost && `
+ span {
+ box-shadow: none;
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ `}
+
+ ${({ $talking }) => $talking && `
+ border-radius: 50%;
+ `}
+
+ ${({ $talking, animations }) => $talking && animations && css`
+ animation: ${pulse} 1s infinite ease-in;
+ `}
+
+ ${({ $talking, animations }) => $talking && !animations && css`
+ & span {
+ content: '';
+ outline: none !important;
+ background-clip: padding-box;
+ box-shadow: 0 0 0 4px rgba(255,255,255,.5);
+ }
+ `}
+`;
+
+const DangerColor = {
+ color: colorDanger,
+ paddingLeft: 12,
+};
+
+const SelectedLabel = {
+ color: colorPrimary,
+ backgroundColor: colorOffWhite,
+};
+
+const DisabledLabel = {
+ color: colorGrayDark,
+ fontWeight: 'bold',
+ opacity: 1,
+};
+
+export default {
+ AudioDropdown,
+ MuteToggleButton,
+ DangerColor,
+ SelectedLabel,
+ DisabledLabel,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-controls/styles.js b/src/2.7.12/imports/ui/components/audio/audio-controls/styles.js
new file mode 100644
index 00000000..fb524c94
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-controls/styles.js
@@ -0,0 +1,102 @@
+import styled, { css, keyframes } from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { smPaddingX, smPaddingY } from '/imports/ui/stylesheets/styled-components/general';
+
+const pulse = keyframes`
+ 0% {
+ box-shadow: 0 0 0 0 white;
+ }
+ 70% {
+ box-shadow: 0 0 0 0.5625rem transparent;
+ }
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+`;
+
+const LeaveButtonWithoutLiveStreamSelector = styled(Button)`
+ ${({ ghost }) => ghost && `
+ span {
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ `}
+`;
+
+const MuteToggleButton = styled(Button)`
+ margin-right: ${smPaddingX};
+ margin-left: 0;
+
+ @media ${smallOnly} {
+ margin-right: ${smPaddingY};
+ }
+
+ [dir="rtl"] & {
+ margin-left: ${smPaddingX};
+ margin-right: 0;
+
+ @media ${smallOnly} {
+ margin-left: ${smPaddingY};
+ }
+ }
+
+ ${({ ghost }) => ghost && `
+ span {
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ `}
+
+ ${({ talking }) => talking && `
+ border-radius: 50%;
+ `}
+
+ ${({ talking, animations }) => talking && animations && css`
+ animation: ${pulse} 1s infinite ease-in;
+ `}
+
+ ${({ talking, animations }) => talking && !animations && css`
+ & span {
+ content: '';
+ outline: none !important;
+ background-clip: padding-box;
+ box-shadow: 0 0 0 4px rgba(255,255,255,.5);
+ }
+ `}
+`;
+
+const Container = styled.span`
+ display: flex;
+ flex-flow: row;
+ position: relative;
+
+ & > div {
+ position: relative;
+ }
+
+ & > :last-child {
+ margin-left: ${smPaddingX};
+ margin-right: 0;
+
+ @media ${smallOnly} {
+ margin-left: ${smPaddingY};
+ }
+
+ [dir="rtl"] & {
+ margin-left: 0;
+ margin-right: ${smPaddingX};
+
+ @media ${smallOnly} {
+ margin-right: ${smPaddingY};
+ }
+ }
+ }
+`;
+
+export default {
+ LeaveButtonWithoutLiveStreamSelector,
+ MuteToggleButton,
+ Container,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-dial/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-dial/component.jsx
new file mode 100644
index 00000000..3f7abfdb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-dial/component.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ audioDialDescription: {
+ id: 'app.audioDial.audioDialDescription',
+ description: 'Text description for the audio help',
+ },
+ audioDialConfrenceText: {
+ id: 'app.audioDial.audioDialConfrenceText',
+ description: 'audio settings back button label',
+ },
+ tipIndicator: {
+ id: 'app.audioDial.tipIndicator',
+ description: 'Indicator for the tip message',
+ },
+ tipMessage: {
+ id: 'app.audioDial.tipMessage',
+ description: 'Tip message explaining how to mute/unmute yourself',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ formattedDialNum: PropTypes.string.isRequired,
+ telVoice: PropTypes.string.isRequired,
+};
+
+class AudioDial extends React.PureComponent {
+ render() {
+ const {
+ intl,
+ formattedDialNum,
+ telVoice,
+ } = this.props;
+
+ const formattedTelVoice = telVoice.replace(/(?=(\d{3})+(?!\d))/g, ' ');
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.audioDialDescription)}
+
+ {formattedDialNum}
+
+ {intl.formatMessage(intlMessages.audioDialConfrenceText)}
+
+ {formattedTelVoice}
+
+
+ {`${intl.formatMessage(intlMessages.tipIndicator)}: `}
+
+ {intl.formatMessage(intlMessages.tipMessage)}
+
+
+ );
+ }
+}
+
+AudioDial.propTypes = propTypes;
+export default injectIntl(AudioDial);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-dial/styles.js b/src/2.7.12/imports/ui/components/audio/audio-dial/styles.js
new file mode 100644
index 00000000..42ca2c00
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-dial/styles.js
@@ -0,0 +1,55 @@
+import styled from 'styled-components';
+import {
+ lgPaddingX,
+ smPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { colorGrayLightest } from '/imports/ui/stylesheets/styled-components/palette';
+
+const Help = styled.span`
+ display: flex;
+ flex-flow: column;
+`;
+
+const Text = styled.div`
+ text-align: center;
+ margin-bottom: ${lgPaddingX};
+`;
+
+const DialText = styled.div`
+ text-align: center;
+ margin-bottom: ${lgPaddingX};
+ font-size: 2rem;
+ direction: ltr;
+`;
+
+const ConferenceText = styled.div`
+ text-align: center;
+ margin-bottom: ${smPaddingY};
+`;
+
+const Telvoice = styled.div`
+ text-align: center;
+ font-size: 2rem;
+ direction: ltr;
+ margin-bottom: 0;
+`;
+
+const TipBox = styled.div`
+ background-color: ${colorGrayLightest};
+ padding: 1.2rem;
+ margin-top: 2rem;
+`;
+
+const TipIndicator = styled.span`
+ font-weight: bold;
+`;
+
+export default {
+ Help,
+ Text,
+ DialText,
+ ConferenceText,
+ Telvoice,
+ TipBox,
+ TipIndicator,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-modal/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-modal/component.jsx
new file mode 100644
index 00000000..5d3f045e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-modal/component.jsx
@@ -0,0 +1,672 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Session } from 'meteor/session';
+import {
+ defineMessages, injectIntl, FormattedMessage,
+} from 'react-intl';
+import Styled from './styles';
+import PermissionsOverlay from '../permissions-overlay/component';
+import AudioSettings from '../audio-settings/component';
+import EchoTest from '../echo-test/component';
+import Help from '../help/component';
+import AudioDial from '../audio-dial/component';
+import AudioAutoplayPrompt from '../autoplay/component';
+import Settings from '/imports/ui/services/settings';
+import CaptionsSelectContainer from '/imports/ui/components/audio/captions/select/container';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ closeModal: PropTypes.func.isRequired,
+ joinMicrophone: PropTypes.func.isRequired,
+ joinListenOnly: PropTypes.func.isRequired,
+ joinEchoTest: PropTypes.func.isRequired,
+ exitAudio: PropTypes.func.isRequired,
+ leaveEchoTest: PropTypes.func.isRequired,
+ changeInputDevice: PropTypes.func.isRequired,
+ changeOutputDevice: PropTypes.func.isRequired,
+ isEchoTest: PropTypes.bool.isRequired,
+ isConnecting: PropTypes.bool.isRequired,
+ isConnected: PropTypes.bool.isRequired,
+ isUsingAudio: PropTypes.bool.isRequired,
+ isListenOnly: PropTypes.bool.isRequired,
+ inputDeviceId: PropTypes.string,
+ outputDeviceId: PropTypes.string,
+ formattedDialNum: PropTypes.string.isRequired,
+ showPermissionsOvelay: PropTypes.bool.isRequired,
+ listenOnlyMode: PropTypes.bool.isRequired,
+ joinFullAudioImmediately: PropTypes.bool,
+ forceListenOnlyAttendee: PropTypes.bool.isRequired,
+ audioLocked: PropTypes.bool.isRequired,
+ resolve: PropTypes.func,
+ isMobileNative: PropTypes.bool.isRequired,
+ isIE: PropTypes.bool.isRequired,
+ formattedTelVoice: PropTypes.string.isRequired,
+ autoplayBlocked: PropTypes.bool.isRequired,
+ handleAllowAutoplay: PropTypes.func.isRequired,
+ changeInputStream: PropTypes.func.isRequired,
+ localEchoEnabled: PropTypes.bool.isRequired,
+ showVolumeMeter: PropTypes.bool.isRequired,
+ notify: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ inputDeviceId: null,
+ outputDeviceId: null,
+ resolve: null,
+ joinFullAudioImmediately: false,
+};
+
+const intlMessages = defineMessages({
+ microphoneLabel: {
+ id: 'app.audioModal.microphoneLabel',
+ description: 'Join mic audio button label',
+ },
+ listenOnlyLabel: {
+ id: 'app.audioModal.listenOnlyLabel',
+ description: 'Join listen only audio button label',
+ },
+ listenOnlyDesc: {
+ id: 'app.audioModal.listenOnlyDesc',
+ description: 'Join listen only audio button description',
+ },
+ microphoneDesc: {
+ id: 'app.audioModal.microphoneDesc',
+ description: 'Join mic audio button description',
+ },
+ closeLabel: {
+ id: 'app.audioModal.closeLabel',
+ description: 'close audio modal button label',
+ },
+ audioChoiceLabel: {
+ id: 'app.audioModal.audioChoiceLabel',
+ description: 'Join audio modal title',
+ },
+ iOSError: {
+ id: 'app.audioModal.iOSBrowser',
+ description: 'Audio/Video Not supported warning',
+ },
+ iOSErrorDescription: {
+ id: 'app.audioModal.iOSErrorDescription',
+ description: 'Audio/Video not supported description',
+ },
+ iOSErrorRecommendation: {
+ id: 'app.audioModal.iOSErrorRecommendation',
+ description: 'Audio/Video recommended action',
+ },
+ echoTestTitle: {
+ id: 'app.audioModal.echoTestTitle',
+ description: 'Title for the echo test',
+ },
+ settingsTitle: {
+ id: 'app.audioModal.settingsTitle',
+ description: 'Title for the audio modal',
+ },
+ helpTitle: {
+ id: 'app.audioModal.helpTitle',
+ description: 'Title for the audio help',
+ },
+ audioDialTitle: {
+ id: 'app.audioModal.audioDialTitle',
+ description: 'Title for the audio dial',
+ },
+ connecting: {
+ id: 'app.audioModal.connecting',
+ description: 'Message for audio connecting',
+ },
+ ariaModalTitle: {
+ id: 'app.audioModal.ariaTitle',
+ description: 'aria label for modal title',
+ },
+ autoplayPromptTitle: {
+ id: 'app.audioModal.autoplayBlockedDesc',
+ description: 'Message for autoplay audio block',
+ },
+});
+
+class AudioModal extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ content: null,
+ hasError: false,
+ errorInfo: null,
+ };
+
+ this.handleGoToAudioOptions = this.handleGoToAudioOptions.bind(this);
+ this.handleGoToAudioSettings = this.handleGoToAudioSettings.bind(this);
+ this.handleRetryGoToEchoTest = this.handleRetryGoToEchoTest.bind(this);
+ this.handleGoToEchoTest = this.handleGoToEchoTest.bind(this);
+ this.handleJoinMicrophone = this.handleJoinMicrophone.bind(this);
+ this.handleJoinLocalEcho = this.handleJoinLocalEcho.bind(this);
+ this.handleJoinListenOnly = this.handleJoinListenOnly.bind(this);
+ this.skipAudioOptions = this.skipAudioOptions.bind(this);
+
+ this.contents = {
+ echoTest: {
+ title: intlMessages.echoTestTitle,
+ component: () => this.renderEchoTest(),
+ },
+ settings: {
+ title: intlMessages.settingsTitle,
+ component: () => this.renderAudioSettings(),
+ },
+ help: {
+ title: intlMessages.helpTitle,
+ component: () => this.renderHelp(),
+ },
+ audioDial: {
+ title: intlMessages.audioDialTitle,
+ component: () => this.renderAudioDial(),
+ },
+ autoplayBlocked: {
+ title: intlMessages.autoplayPromptTitle,
+ component: () => this.renderAutoplayOverlay(),
+ },
+ };
+ this.failedMediaElements = [];
+ }
+
+ componentDidMount() {
+ const {
+ forceListenOnlyAttendee,
+ joinFullAudioImmediately,
+ listenOnlyMode,
+ audioLocked,
+ isUsingAudio,
+ } = this.props;
+
+ if (!isUsingAudio) {
+ if (forceListenOnlyAttendee || audioLocked) return this.handleJoinListenOnly();
+
+ if (joinFullAudioImmediately && !listenOnlyMode) return this.handleJoinMicrophone();
+
+ if (!listenOnlyMode) return this.handleGoToEchoTest();
+ }
+ return false;
+ }
+
+ componentDidUpdate(prevProps) {
+ const { autoplayBlocked, closeModal } = this.props;
+
+ if (autoplayBlocked !== prevProps.autoplayBlocked) {
+ if (autoplayBlocked) {
+ this.setContent({ content: 'autoplayBlocked' });
+ } else {
+ closeModal();
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ const {
+ isEchoTest,
+ exitAudio,
+ resolve,
+ } = this.props;
+
+ if (isEchoTest) {
+ exitAudio();
+ }
+ if (resolve) resolve();
+ Session.set('audioModalIsOpen', false);
+ }
+
+ handleGoToAudioOptions() {
+ this.setState({
+ content: null,
+ hasError: true,
+ disableActions: false,
+ });
+ }
+
+ handleGoToAudioSettings() {
+ const { leaveEchoTest } = this.props;
+ leaveEchoTest().then(() => {
+ this.setState({
+ content: 'settings',
+ });
+ });
+ }
+
+ handleRetryGoToEchoTest() {
+ this.setState({
+ hasError: false,
+ content: null,
+ errorInfo: null,
+ });
+
+ return this.handleGoToEchoTest();
+ }
+
+ handleGoToLocalEcho() {
+ // Simplified echo test: this will return the AudioSettings with:
+ // - withEcho: true
+ // Echo test will be local and done in the AudioSettings view instead of the
+ // old E2E -> yes/no -> join view
+ this.setState({
+ content: 'settings',
+ });
+ }
+
+ handleGoToEchoTest() {
+ const { AudioError } = this.props;
+ const { MIC_ERROR } = AudioError;
+ const noSSL = !window.location.protocol.includes('https');
+
+ if (noSSL) {
+ return this.setState({
+ content: 'help',
+ errorInfo: {
+ errCode: MIC_ERROR.NO_SSL,
+ errMessage: 'NoSSL',
+ },
+ });
+ }
+
+ const {
+ joinEchoTest,
+ isConnecting,
+ localEchoEnabled,
+ } = this.props;
+
+ const {
+ disableActions,
+ } = this.state;
+
+ if (disableActions && isConnecting) return null;
+
+ if (localEchoEnabled) return this.handleGoToLocalEcho();
+
+ this.setState({
+ hasError: false,
+ disableActions: true,
+ errorInfo: null,
+ });
+
+ return joinEchoTest().then(() => {
+ this.setState({
+ content: 'echoTest',
+ disableActions: false,
+ });
+ }).catch((err) => {
+ this.handleJoinAudioError(err);
+ });
+ }
+
+ handleJoinListenOnly() {
+ const {
+ joinListenOnly,
+ isConnecting,
+ } = this.props;
+
+ const {
+ disableActions,
+ } = this.state;
+
+ if (disableActions && isConnecting) return null;
+
+ this.setState({
+ disableActions: true,
+ hasError: false,
+ errorInfo: null,
+ });
+
+ return joinListenOnly().then(() => {
+ this.setState({
+ disableActions: false,
+ });
+ }).catch((err) => {
+ this.handleJoinAudioError(err);
+ });
+ }
+
+ handleJoinLocalEcho(inputStream) {
+ const { changeInputStream } = this.props;
+ // Reset the modal to a connecting state - this kind of sucks?
+ // prlanzarin Apr 04 2022
+ this.setState({
+ content: null,
+ });
+ if (inputStream) changeInputStream(inputStream);
+ this.handleJoinMicrophone();
+ }
+
+ handleJoinMicrophone() {
+ const {
+ joinMicrophone,
+ isConnecting,
+ } = this.props;
+
+ const {
+ disableActions,
+ } = this.state;
+
+ if (disableActions && isConnecting) return;
+
+ this.setState({
+ hasError: false,
+ disableActions: true,
+ errorInfo: null,
+ });
+
+ joinMicrophone().then(() => {
+ this.setState({
+ disableActions: false,
+ });
+ }).catch((err) => {
+ this.handleJoinAudioError(err);
+ });
+ }
+
+ handleJoinAudioError(err) {
+ const { type, errCode, errMessage } = err;
+
+ switch (type) {
+ case 'MEDIA_ERROR':
+ this.setState({
+ content: 'help',
+ disableActions: false,
+ errorInfo: {
+ errCode,
+ errMessage,
+ }
+ });
+ break;
+ case 'CONNECTION_ERROR':
+ default:
+ this.setState({
+ disableActions: false,
+ errorInfo: {
+ errCode,
+ errMessage: type,
+ },
+ });
+ break;
+ }
+ }
+
+ setContent(content) {
+ this.setState(content);
+ }
+
+ skipAudioOptions() {
+ const {
+ isConnecting,
+ } = this.props;
+
+ const {
+ content,
+ hasError,
+ } = this.state;
+
+ return isConnecting && !content && !hasError;
+ }
+
+ renderAudioOptions() {
+ const {
+ intl,
+ listenOnlyMode,
+ forceListenOnlyAttendee,
+ joinFullAudioImmediately,
+ audioLocked,
+ isMobileNative,
+ formattedDialNum,
+ isRTL,
+ } = this.props;
+
+ const showMicrophone = forceListenOnlyAttendee || audioLocked;
+
+ const arrow = isRTL ? '←' : '→';
+ const dialAudioLabel = `${intl.formatMessage(intlMessages.audioDialTitle)} ${arrow}`;
+
+ return (
+
+
+ {!showMicrophone && !isMobileNative
+ && (
+ <>
+
+
+ {intl.formatMessage(intlMessages.microphoneDesc)}
+
+ >
+ )}
+ {listenOnlyMode
+ && (
+ <>
+
+
+ {intl.formatMessage(intlMessages.listenOnlyDesc)}
+
+ >
+ )}
+
+ {formattedDialNum ? (
+ {
+ this.setState({
+ content: 'audioDial',
+ });
+ }}
+ />
+ ) : null}
+
+
+ );
+ }
+
+ renderContent() {
+ const {
+ isEchoTest,
+ intl,
+ } = this.props;
+
+ const { content } = this.state;
+ const { animations } = Settings.application;
+
+ if (this.skipAudioOptions()) {
+ return (
+
+
+ {intl.formatMessage(intlMessages.connecting)}
+
+
+
+ );
+ }
+ return content ? this.contents[content].component() : this.renderAudioOptions();
+ }
+
+ renderEchoTest() {
+ return (
+
+ );
+ }
+
+ renderAudioSettings() {
+ const {
+ isConnecting,
+ isConnected,
+ isEchoTest,
+ inputDeviceId,
+ outputDeviceId,
+ joinEchoTest,
+ changeInputDevice,
+ changeOutputDevice,
+ localEchoEnabled,
+ showVolumeMeter,
+ notify,
+ AudioError,
+ } = this.props;
+ const { MIC_ERROR } = AudioError;
+
+ const confirmationCallback = !localEchoEnabled
+ ? this.handleRetryGoToEchoTest
+ : this.handleJoinLocalEcho;
+
+ const handleGUMFailure = (error) => {
+ const errCode = error?.name === 'NotAllowedError'
+ ? MIC_ERROR.NO_PERMISSION
+ : 0
+ this.setState({
+ content: 'help',
+ disableActions: false,
+ errorInfo: {
+ errCode,
+ errMessage: error?.name || 'NotAllowedError',
+ },
+ });
+ };
+
+ return (
+
+ );
+ }
+
+ renderHelp() {
+ const { errorInfo } = this.state;
+ const { AudioError, getTroubleshootingLink, isListenOnly } = this.props;
+
+ const audioErr = {
+ ...AudioError,
+ code: errorInfo?.errCode,
+ message: errorInfo?.errMessage,
+ };
+
+ return (
+
+ );
+ }
+
+ renderAudioDial() {
+ const { formattedDialNum, formattedTelVoice } = this.props;
+ return (
+
+ );
+ }
+
+ renderAutoplayOverlay() {
+ const { handleAllowAutoplay } = this.props;
+ return (
+
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ showPermissionsOvelay,
+ closeModal,
+ isIE,
+ isOpen,
+ priority,
+ setIsOpen,
+ } = this.props;
+
+ const { content } = this.state;
+
+ return (
+ <>
+ {showPermissionsOvelay ? : null}
+
+ {isIE ? (
+
+ Chrome ,
+ 1: Firefox ,
+ }}
+ />
+
+ ) : null}
+
+ {this.renderContent()}
+
+
+ >
+ );
+ }
+}
+
+AudioModal.propTypes = propTypes;
+AudioModal.defaultProps = defaultProps;
+
+export default injectIntl(AudioModal);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-modal/container.jsx b/src/2.7.12/imports/ui/components/audio/audio-modal/container.jsx
new file mode 100644
index 00000000..7d5a56e7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-modal/container.jsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import browserInfo from '/imports/utils/browserInfo';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import AudioModal from './component';
+import Meetings from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import AudioError from '/imports/ui/services/audio-manager/error-codes';
+import AppService from '/imports/ui/components/app/service';
+import {
+ joinMicrophone,
+ closeModal,
+ joinListenOnly,
+ leaveEchoTest,
+} from './service';
+import Storage from '/imports/ui/services/storage/session';
+import Service from '../service';
+import AudioModalService from '/imports/ui/components/audio/audio-modal/service';
+
+const AudioModalContainer = (props) => ;
+
+const APP_CONFIG = Meteor.settings.public.app;
+
+const invalidDialNumbers = ['0', '613-555-1212', '613-555-1234', '0000'];
+const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
+
+export default lockContextContainer(withTracker(({ userLocks, setIsOpen }) => {
+ const listenOnlyMode = getFromUserSettings('bbb_listen_only_mode', APP_CONFIG.listenOnlyMode);
+ const forceListenOnly = getFromUserSettings('bbb_force_listen_only', APP_CONFIG.forceListenOnly);
+ const skipCheck = getFromUserSettings('bbb_skip_check_audio', APP_CONFIG.skipCheck);
+ const skipCheckOnJoin = getFromUserSettings('bbb_skip_check_audio_on_first_join', APP_CONFIG.skipCheckOnJoin);
+ const autoJoin = getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin);
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, { fields: { voiceProp: 1 } });
+ const getEchoTest = Storage.getItem('getEchoTest');
+
+ let formattedDialNum = '';
+ let formattedTelVoice = '';
+ let combinedDialInNum = '';
+ if (meeting && meeting.voiceProp) {
+ const { dialNumber, telVoice } = meeting.voiceProp;
+ if (invalidDialNumbers.indexOf(dialNumber) < 0) {
+ formattedDialNum = dialNumber;
+ formattedTelVoice = telVoice;
+ combinedDialInNum = `${dialNumber.replace(/\D+/g, '')},,,${telVoice.replace(/\D+/g, '')}`;
+ }
+ }
+
+ const meetingIsBreakout = AppService.meetingIsBreakout();
+
+ const joinFullAudioImmediately = (
+ autoJoin
+ && (
+ skipCheck
+ || (skipCheckOnJoin && !getEchoTest)
+ ))
+ || (
+ skipCheck
+ || (skipCheckOnJoin && !getEchoTest)
+ );
+
+ const forceListenOnlyAttendee = forceListenOnly && !Service.isUserModerator();
+
+ const { isIe } = browserInfo;
+
+ return ({
+ meetingIsBreakout,
+ closeModal: () => closeModal(() => setIsOpen(false)),
+ joinMicrophone: (skipEchoTest) => joinMicrophone(skipEchoTest || skipCheck || skipCheckOnJoin),
+ joinListenOnly,
+ leaveEchoTest,
+ changeInputDevice: (inputDeviceId) => Service
+ .changeInputDevice(inputDeviceId),
+ changeInputStream: (inputStream) => Service.changeInputStream(inputStream),
+ changeOutputDevice: (outputDeviceId, isLive) => Service
+ .changeOutputDevice(outputDeviceId, isLive),
+ joinEchoTest: () => Service.joinEchoTest(),
+ exitAudio: () => Service.exitAudio(),
+ isConnecting: Service.isConnecting(),
+ isConnected: Service.isConnected(),
+ isUsingAudio: Service.isUsingAudio(),
+ isEchoTest: Service.isEchoTest(),
+ inputDeviceId: Service.inputDeviceId(),
+ outputDeviceId: Service.outputDeviceId(),
+ showPermissionsOvelay: Service.isWaitingPermissions(),
+ showVolumeMeter: Service.showVolumeMeter,
+ localEchoEnabled: Service.localEchoEnabled,
+ listenOnlyMode,
+ formattedDialNum,
+ formattedTelVoice,
+ combinedDialInNum,
+ audioLocked: userLocks.userMic,
+ joinFullAudioImmediately,
+ forceListenOnlyAttendee,
+ isMobileNative: navigator.userAgent.toLowerCase().includes('bbbnative'),
+ isIE: isIe,
+ autoplayBlocked: Service.autoplayBlocked(),
+ handleAllowAutoplay: () => Service.handleAllowAutoplay(),
+ notify: Service.notify,
+ isRTL,
+ AudioError,
+ getTroubleshootingLink: AudioModalService.getTroubleshootingLink,
+ isListenOnly: Service.isListenOnly(),
+ });
+})(AudioModalContainer));
diff --git a/src/2.7.12/imports/ui/components/audio/audio-modal/service.js b/src/2.7.12/imports/ui/components/audio/audio-modal/service.js
new file mode 100644
index 00000000..824b4f44
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-modal/service.js
@@ -0,0 +1,91 @@
+import Service from '../service';
+import Storage from '/imports/ui/services/storage/session';
+
+const CLIENT_DID_USER_SELECTED_MICROPHONE_KEY = 'clientUserSelectedMicrophone';
+const CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY = 'clientUserSelectedListenOnly';
+const TROUBLESHOOTING_LINKS = Meteor.settings.public.media.audioTroubleshootingLinks;
+
+export const setUserSelectedMicrophone = (value) => (
+ Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, !!value)
+);
+
+export const setUserSelectedListenOnly = (value) => (
+ Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, !!value)
+);
+
+export const didUserSelectedMicrophone = () => (
+ !!Storage.getItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY)
+);
+
+export const didUserSelectedListenOnly = () => (
+ !!Storage.getItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY)
+);
+
+export const joinMicrophone = (skipEchoTest = false) => {
+ Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, true);
+ Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, false);
+
+ const call = new Promise((resolve, reject) => {
+ try {
+ if ((skipEchoTest && !Service.isConnected()) || Service.localEchoEnabled) {
+ return resolve(Service.joinMicrophone());
+ }
+
+ return resolve(Service.transferCall());
+ } catch {
+ return reject(Service.exitAudio);
+ }
+ });
+
+ return call.then(() => {
+ document.dispatchEvent(new Event("CLOSE_MODAL_AUDIO"));
+ }).catch((error) => {
+ throw error;
+ });
+};
+
+export const joinListenOnly = () => {
+ Storage.setItem(CLIENT_DID_USER_SELECTED_MICROPHONE_KEY, false);
+ Storage.setItem(CLIENT_DID_USER_SELECTED_LISTEN_ONLY_KEY, true);
+
+ return Service.joinListenOnly().then(() => {
+ // Autoplay block wasn't triggered. Close the modal. If autoplay was
+ // blocked, that'll be handled in the modal component when then
+ // prop transitions to a state where it was handled OR the user opts
+ // to close the modal.
+ if (!Service.autoplayBlocked()) {
+ document.dispatchEvent(new Event("CLOSE_MODAL_AUDIO"));
+ }
+ }).catch((error) => {
+ throw error;
+ });
+};
+
+export const leaveEchoTest = () => {
+ if (!Service.isEchoTest()) {
+ return Promise.resolve();
+ }
+ return Service.exitAudio();
+};
+
+export const closeModal = (callback) => {
+ if (Service.isConnecting()) {
+ Service.forceExitAudio();
+ }
+ callback();
+};
+
+const getTroubleshootingLink = (errorCode) => {
+ if (TROUBLESHOOTING_LINKS) return TROUBLESHOOTING_LINKS[errorCode] || TROUBLESHOOTING_LINKS[0];
+ return null;
+};
+
+export default {
+ joinMicrophone,
+ closeModal,
+ joinListenOnly,
+ leaveEchoTest,
+ didUserSelectedMicrophone,
+ didUserSelectedListenOnly,
+ getTroubleshootingLink,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-modal/styles.js b/src/2.7.12/imports/ui/components/audio/audio-modal/styles.js
new file mode 100644
index 00000000..382b474f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-modal/styles.js
@@ -0,0 +1,150 @@
+import styled, { css, keyframes } from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ mdPaddingY,
+ btnSpacing,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+
+const AudioOptions = styled.span`
+ margin-top: auto;
+ margin-bottom: auto;
+ display: flex;
+ justify-content: center;
+`;
+
+const AudioModalButton = styled(Button)`
+ i {
+ color: #3c5764;
+ }
+
+ // Modifies the audio button icon colour
+ & span:first-child {
+ display: inline-block;
+ color: #1b3c4b;
+ background-color: #f1f8ff;
+ box-shadow: none;
+ border: 5px solid #f1f8ff;
+ font-size: 3.5rem;
+
+ @media ${smallOnly} {
+ font-size: 2.5rem;
+ }
+ }
+
+ // When hovering over a button of class audioBtn, change the border colour of first span-child
+ &:hover span:first-child,
+ &:focus span:first-child {
+ border: 5px solid ${colorPrimary};
+ background-color: #f1f8ff;
+ }
+
+ // Modifies the button label text
+ & span:last-child {
+ display: block;
+ color: black;
+ font-size: 1rem;
+ font-weight: 600;
+ margin-top: ${btnSpacing};
+ line-height: 1.5;
+ }
+`;
+
+const AudioDial = styled(Button)`
+ margin: 0 auto;
+ margin-top: ${mdPaddingY};
+ display: block;
+`;
+
+const Connecting = styled.div`
+ margin-top: auto;
+ margin-bottom: auto;
+ font-size: 2rem;
+`;
+
+const ellipsis = keyframes`
+ to {
+ width: 1.5em;
+ }
+`;
+
+const ConnectingAnimation = styled.span`
+ margin: auto;
+ display: inline-block;
+ width: 1.5em;
+
+ &:after {
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: bottom;
+ content: "\\2026"; /* ascii code for the ellipsis character */
+ width: 0;
+ margin-left: 0.25em;
+
+ ${({ animations }) => animations && css`
+ animation: ${ellipsis} steps(4, end) 900ms infinite;
+ `}
+ }
+`;
+
+const AudioModal = styled(ModalSimple)`
+ padding: 1rem;
+ min-height: 20rem;
+`;
+
+const BrowserWarning = styled.p`
+ margin: ${lineHeightComputed};
+ text-align: center;
+ padding: 0.5rem;
+ border-width: 3px;
+ border-style: solid;
+ border-radius: 0.25rem;
+`;
+
+const Content = styled.div`
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ padding: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ padding: 0.5rem 0;
+
+ button:first-child {
+ margin: 0 3rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 3rem;
+ }
+
+ @media ${smallOnly} {
+ margin: 0 1rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1rem;
+ }
+ }
+ }
+
+ button:only-child {
+ margin: inherit 0 inherit inherit;
+
+ [dir="rtl"] & {
+ margin: inherit inherit inherit 0 !important;
+ }
+ }
+`;
+
+export default {
+ AudioOptions,
+ AudioModalButton,
+ AudioDial,
+ Connecting,
+ ConnectingAnimation,
+ AudioModal,
+ BrowserWarning,
+ Content,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-settings/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-settings/component.jsx
new file mode 100644
index 00000000..7cbf1ec3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-settings/component.jsx
@@ -0,0 +1,377 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Button from '/imports/ui/components/common/button/component';
+import AudioTestContainer from '/imports/ui/components/audio/audio-test/container';
+import Styled from './styles';
+import logger from '/imports/startup/client/logger';
+import AudioStreamVolume from '/imports/ui/components/audio/audio-stream-volume/component';
+import LocalEchoContainer from '/imports/ui/components/audio/local-echo/container';
+import DeviceSelector from '/imports/ui/components/audio/device-selector/component';
+import {
+ getAudioConstraints,
+ doGUM,
+} from '/imports/api/audio/client/bridge/service';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ changeInputDevice: PropTypes.func.isRequired,
+ changeOutputDevice: PropTypes.func.isRequired,
+ handleBack: PropTypes.func.isRequired,
+ handleConfirmation: PropTypes.func.isRequired,
+ handleGUMFailure: PropTypes.func.isRequired,
+ isConnecting: PropTypes.bool.isRequired,
+ inputDeviceId: PropTypes.string.isRequired,
+ outputDeviceId: PropTypes.string.isRequired,
+ produceStreams: PropTypes.bool,
+ withEcho: PropTypes.bool,
+ withVolumeMeter: PropTypes.bool,
+ notify: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ produceStreams: false,
+ withEcho: false,
+ withVolumeMeter: false,
+};
+
+const intlMessages = defineMessages({
+ backLabel: {
+ id: 'app.audio.backLabel',
+ description: 'audio settings back button label',
+ },
+ descriptionLabel: {
+ id: 'app.audio.audioSettings.descriptionLabel',
+ description: 'audio settings description label',
+ },
+ micSourceLabel: {
+ id: 'app.audio.audioSettings.microphoneSourceLabel',
+ description: 'Label for mic source',
+ },
+ speakerSourceLabel: {
+ id: 'app.audio.audioSettings.speakerSourceLabel',
+ description: 'Label for speaker source',
+ },
+ streamVolumeLabel: {
+ id: 'app.audio.audioSettings.microphoneStreamLabel',
+ description: 'Label for stream volume',
+ },
+ retryLabel: {
+ id: 'app.audio.joinAudio',
+ description: 'Confirmation button label',
+ },
+ deviceChangeFailed: {
+ id: 'app.audioNotification.deviceChangeFailed',
+ description: 'Device change failed',
+ },
+});
+
+class AudioSettings extends React.Component {
+ constructor(props) {
+ super(props);
+
+ const {
+ inputDeviceId,
+ outputDeviceId,
+ } = props;
+
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.handleOutputChange = this.handleOutputChange.bind(this);
+ this.handleConfirmationClick = this.handleConfirmationClick.bind(this);
+
+ this.state = {
+ inputDeviceId,
+ outputDeviceId,
+ // If streams need to be produced, device selectors and audio join are
+ // blocked until at least one stream is generated
+ producingStreams: props.produceStreams,
+ stream: null,
+ };
+
+ this._isMounted = false;
+ }
+
+ componentDidMount() {
+ const { inputDeviceId, outputDeviceId } = this.state;
+
+ Session.set('inEchoTest', true);
+ this._isMounted = true;
+ // Guarantee initial in/out devices are initialized on all ends
+ this.setInputDevice(inputDeviceId);
+ this.setOutputDevice(outputDeviceId);
+ }
+
+ componentWillUnmount() {
+ const { stream } = this.state;
+
+ Session.set('inEchoTest', false);
+ this._mounted = false;
+
+ if (stream) {
+ MediaStreamUtils.stopMediaStreamTracks(stream);
+ }
+ }
+
+ handleInputChange(deviceId) {
+ this.setInputDevice(deviceId);
+ }
+
+ handleOutputChange(deviceId) {
+ this.setOutputDevice(deviceId);
+ }
+
+ handleConfirmationClick() {
+ const { stream } = this.state;
+ const {
+ produceStreams,
+ handleConfirmation,
+ } = this.props;
+
+ // Stream generation disabled or there isn't any stream: just run the provided callback
+ if (!produceStreams || !stream) return handleConfirmation();
+
+ // Stream generation enabled and there is a valid input stream => call
+ // the confirmation callback with the input stream as arg so it can be used
+ // in upstream components. The rationale is no surplus gUM calls.
+ // We're cloning it because the original will be cleaned up on unmount here.
+ const clonedStream = stream.clone();
+ return handleConfirmation(clonedStream);
+ }
+
+ setInputDevice(deviceId) {
+ const {
+ handleGUMFailure,
+ changeInputDevice,
+ produceStreams,
+ intl,
+ notify,
+ } = this.props;
+ const { inputDeviceId: currentInputDeviceId } = this.state;
+
+ try {
+ changeInputDevice(deviceId)
+ // Only generate input streams if they're going to be used with something
+ // In this case, the volume meter or local echo test.
+ if (produceStreams) {
+ this.generateInputStream(deviceId).then((stream) => {
+ // Extract the deviceId again from the stream to guarantee consistency
+ // between stream DID vs chosen DID. That's necessary in scenarios where,
+ // eg, there's no default/pre-set deviceId ('') and the browser's
+ // default device has been altered by the user (browser default != system's
+ // default).
+ const extractedDeviceId = MediaStreamUtils.extractDeviceIdFromStream(stream, 'audio');
+ if (extractedDeviceId && extractedDeviceId !== deviceId) changeInputDevice(extractedDeviceId);
+
+ // Component unmounted after gUM resolution -> skip echo rendering
+ if (!this._isMounted) return;
+
+ this.setState({
+ inputDeviceId: extractedDeviceId,
+ stream,
+ producingStreams: false,
+ });
+ }).catch((error) => {
+ logger.warn({
+ logCode: 'audiosettings_gum_failed',
+ extraInfo: {
+ deviceId,
+ errorMessage: error.message,
+ errorName: error.name,
+ },
+ }, `Audio settings gUM failed: ${error.name}`);
+ handleGUMFailure(error);
+ });
+ } else {
+ this.setState({
+ inputDeviceId: deviceId,
+ });
+ }
+ } catch (error) {
+ logger.debug({
+ logCode: 'audiosettings_input_device_change_failure',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ deviceId: currentInputDeviceId,
+ newDeviceId: deviceId,
+ },
+ }, `Audio settings: error changing input device - {${error.name}: ${error.message}}`);
+ notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
+ }
+ }
+
+ setOutputDevice(deviceId) {
+ const {
+ changeOutputDevice,
+ withEcho,
+ intl,
+ notify,
+ } = this.props;
+ const { outputDeviceId: currentOutputDeviceId } = this.state;
+
+ // withEcho usage (isLive arg): if local echo is enabled we need the device
+ // change to be performed seamlessly (which is what the isLive parameter guarantes)
+ changeOutputDevice(deviceId, withEcho).then(() => {
+ this.setState({
+ outputDeviceId: deviceId,
+ });
+ }).catch((error) => {
+ logger.debug({
+ logCode: 'audiosettings_output_device_change_failure',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ deviceId: currentOutputDeviceId,
+ newDeviceId: deviceId,
+ },
+ }, `Audio settings: error changing output device - {${error.name}: ${error.message}}`);
+ notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
+ });
+ }
+
+ generateInputStream(inputDeviceId) {
+ const { stream } = this.state;
+
+ if (inputDeviceId && stream) {
+ const currentDeviceId = MediaStreamUtils.extractDeviceIdFromStream(stream, 'audio');
+
+ if (currentDeviceId === inputDeviceId) return Promise.resolve(stream);
+
+ MediaStreamUtils.stopMediaStreamTracks(stream);
+ }
+
+ const constraints = {
+ audio: getAudioConstraints({ deviceId: inputDeviceId }),
+ };
+
+ return doGUM(constraints, true);
+ }
+
+ renderOutputTest() {
+ const { withEcho, intl } = this.props;
+ const { stream } = this.state;
+
+ return (
+
+
+
+ {!withEcho
+ ?
+ : (
+
+ )}
+
+
+
+ );
+ }
+
+ renderVolumeMeter() {
+ const { withVolumeMeter, intl } = this.props;
+ const { stream } = this.state;
+
+ return withVolumeMeter ? (
+
+
+ {intl.formatMessage(intlMessages.streamVolumeLabel)}
+
+
+
+ ) : null;
+ }
+
+ renderDeviceSelectors() {
+ const { inputDeviceId, outputDeviceId, producingStreams } = this.state;
+ const { intl, isConnecting } = this.props;
+ const blocked = producingStreams || isConnecting;
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages.micSourceLabel)}
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.speakerSourceLabel)}
+
+
+
+
+
+ );
+ }
+
+ render() {
+ const {
+ isConnecting,
+ intl,
+ handleBack,
+ } = this.props;
+ const { producingStreams } = this.state;
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages.descriptionLabel)}
+
+
+ {this.renderDeviceSelectors()}
+ {this.renderOutputTest()}
+ {this.renderVolumeMeter()}
+
+
+
+
+
+
+
+ );
+ }
+}
+
+AudioSettings.propTypes = propTypes;
+AudioSettings.defaultProps = defaultProps;
+
+export default injectIntl(AudioSettings);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-settings/styles.js b/src/2.7.12/imports/ui/components/audio/audio-settings/styles.js
new file mode 100644
index 00000000..97446d81
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-settings/styles.js
@@ -0,0 +1,178 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const FormWrapper = styled.div`
+ min-width: 0;
+`;
+
+const Form = styled.div`
+ display: flex;
+ flex-flow: column;
+ margin-top: 1.5rem;
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ margin-bottom: 0.7rem;
+`;
+
+const EnterAudio = styled.div`
+ margin-top: 1.5rem;
+ display: flex;
+ justify-content: flex-end;
+`;
+
+const AudioNote = styled.div`
+ @media ${smallOnly} {
+ font-size: 0.8rem;
+ }
+`;
+
+const Col = styled.div`
+ min-width: 0;
+
+ display: flex;
+ flex-grow: 1;
+ flex-basis: 0;
+ margin: 0 1rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1rem;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ margin-left: inherit;
+ padding: 0 0.1rem 0 4rem;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 0;
+ padding: 0 4rem 0 0.1rem;
+ }
+ }
+`;
+
+const FormElement = styled.div`
+ position: relative;
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+`;
+
+const LabelSmall = styled.label`
+ color: black;
+ font-size: 0.85rem;
+ font-weight: 600;
+
+ & > :first-child {
+ margin-top: 0.5rem;
+ }
+`;
+
+const LabelSmallFullWidth = styled(LabelSmall)`
+ width: 100%;
+`;
+
+const SpacedLeftCol = styled.div`
+ min-width: 0;
+
+ display: flex;
+ flex-grow: 1;
+ flex-basis: 0;
+ margin: 0 1rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1rem;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ margin-left: inherit;
+ padding: 0 0.1rem 0 4rem;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 0;
+ padding: 0 4rem 0 0.1rem;
+ }
+ }
+
+ & label {
+ flex-grow: 1;
+ flex-basis: 0;
+ margin-right: 0;
+ margin-left: inherit;
+ padding: 0 0.1rem 0 4rem;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 0;
+ padding: 0 4rem 0 0.1rem;
+ }
+ }
+
+ &:before {
+ content: "";
+ display: block;
+ flex-grow: 1;
+ flex-basis: 0;
+ margin-right: 1rem;
+ margin-left: inherit;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 1rem;
+ }
+ }
+
+ &:last-child {
+ margin-right: 0;
+ margin-left: inherit;
+ padding-right: 0;
+ padding-left: 0;
+
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: inherit;
+ }
+ }
+`;
+
+const BackButton = styled(Button)`
+ margin: 0 0.5rem 0 0;
+ border: none;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 0.5rem;
+ }
+
+ @media ${smallOnly} {
+ margin:0 auto 0 0;
+
+ [dir="rtl"] & {
+ margin:0 0 0 auto;
+ }
+ }
+
+ &:first-child {
+ margin: 0 0.5rem 0 0 !important;
+ }
+`;
+
+export default {
+ FormWrapper,
+ Form,
+ Row,
+ EnterAudio,
+ AudioNote,
+ Col,
+ FormElement,
+ LabelSmall,
+ LabelSmallFullWidth,
+ SpacedLeftCol,
+ BackButton,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-stream-volume/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-stream-volume/component.jsx
new file mode 100644
index 00000000..604754d5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-stream-volume/component.jsx
@@ -0,0 +1,98 @@
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import hark from 'hark';
+import Styled from './styles';
+
+const VOL_POLLING_INTERVAL_MS = 100;
+const VOL_FLOOR = 0;
+const VOL_CEIL = 50;
+const DB_AMPL = 65;
+
+const propTypes = {
+ stream: PropTypes.shape({
+ active: PropTypes.bool,
+ id: PropTypes.string,
+ }),
+ volumeFloor: PropTypes.number,
+ volumeRange: PropTypes.number,
+ optimum: PropTypes.number,
+ high: PropTypes.number,
+ low: PropTypes.number,
+};
+
+const AudioStreamVolume = ({
+ volumeFloor,
+ volumeRange,
+ low,
+ optimum,
+ high,
+ stream,
+}) => {
+ const harkObserver = useRef(null);
+ const volumeRef = useRef(0);
+ const [volume, setVolume] = useState(0);
+
+ const handleVolumeChange = (dbVolume) => {
+ const previousVolume = volumeRef.current;
+ // Normalize it into 0 - range . DB_AMPL is the "relevance factor" -
+ // original formula is / 20
+ const linearVolume = (10 ** (dbVolume / DB_AMPL)) * (volumeRange);
+ // If the current linear volume is lower than 1/10 of the total volume range,
+ // ignore to minimize re-renders. Otherwise: generate the next volume val
+ // by smoothing the transition with the previous value and rounding it up
+ const nextVolume = (linearVolume <= (volumeRange / 10))
+ ? volumeFloor
+ : Math.round((0.65 * previousVolume) + (0.35 * linearVolume));
+
+ if (previousVolume !== nextVolume) {
+ volumeRef.current = nextVolume;
+ setVolume(nextVolume);
+ }
+ };
+
+ const observeVolumeChanges = (_stream) => {
+ if (_stream) {
+ harkObserver.current = hark(_stream, { interval: VOL_POLLING_INTERVAL_MS });
+ harkObserver.current.on('volume_change', handleVolumeChange);
+ }
+ };
+
+ const stopObservingVolumeChanges = () => {
+ harkObserver.current?.stop();
+ harkObserver.current = null;
+ };
+
+ useEffect(() => {
+ observeVolumeChanges();
+ return stopObservingVolumeChanges;
+ }, []);
+
+ useEffect(() => {
+ stopObservingVolumeChanges();
+ observeVolumeChanges(stream);
+ }, [stream]);
+
+ return (
+ 0 ? 'hasVolume' : 'hasNoVolume'}
+ min={volumeFloor}
+ low={low}
+ max={high * 1.25}
+ optimum={optimum}
+ high={high}
+ value={volume}
+ />
+ );
+};
+
+AudioStreamVolume.propTypes = propTypes;
+AudioStreamVolume.defaultProps = {
+ volumeFloor: VOL_FLOOR,
+ volumeRange: VOL_CEIL,
+ low: VOL_FLOOR,
+ optimum: Math.round(0.3 * VOL_CEIL),
+ high: Math.round(0.4 * VOL_CEIL),
+ stream: null,
+};
+
+export default React.memo(AudioStreamVolume);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-stream-volume/styles.js b/src/2.7.12/imports/ui/components/audio/audio-stream-volume/styles.js
new file mode 100644
index 00000000..8ddaef9a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-stream-volume/styles.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const VolumeMeter = styled.meter`
+ display: flex;
+ flex-flow: row;
+ justify-content: space-between;
+ width: 100%;
+`;
+
+export default {
+ VolumeMeter,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/audio-test/component.jsx b/src/2.7.12/imports/ui/components/audio/audio-test/component.jsx
new file mode 100644
index 00000000..ae65467d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-test/component.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import Settings from '/imports/ui/services/settings';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handlePlayAudioSample: PropTypes.func.isRequired,
+ outputDeviceId: PropTypes.string,
+};
+
+const defaultProps = {
+ outputDeviceId: null,
+};
+
+const intlMessages = defineMessages({
+ playSoundLabel: {
+ id: 'app.audio.playSoundLabel',
+ description: 'Play sound button label',
+ },
+});
+
+class AudioTest extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handlePlayAudioSample = props.handlePlayAudioSample.bind(this);
+ }
+
+ render() {
+ const {
+ outputDeviceId,
+ intl,
+ } = this.props;
+
+ const { animations } = Settings.application;
+
+ return (
+ this.handlePlayAudioSample(outputDeviceId)}
+ animations={animations}
+ />
+ );
+ }
+}
+
+AudioTest.propTypes = propTypes;
+AudioTest.defaultProps = defaultProps;
+
+export default injectIntl(AudioTest);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-test/container.jsx b/src/2.7.12/imports/ui/components/audio/audio-test/container.jsx
new file mode 100644
index 00000000..7a8121ef
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-test/container.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/audio/service';
+import AudioTest from './component';
+
+const AudioTestContainer = (props) => ;
+
+export default withTracker(() => ({
+ outputDeviceId: Service.outputDeviceId(),
+ handlePlayAudioSample: (deviceId) => {
+ const sound = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename + Meteor.settings.public.app.instanceId}/resources/sounds/audioSample.mp3`);
+ sound.addEventListener('ended', () => { sound.src = null; });
+ if (deviceId && sound.setSinkId) sound.setSinkId(deviceId);
+ sound.play();
+ },
+}))(AudioTestContainer);
diff --git a/src/2.7.12/imports/ui/components/audio/audio-test/styles.js b/src/2.7.12/imports/ui/components/audio/audio-test/styles.js
new file mode 100644
index 00000000..e7e96ae3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/audio-test/styles.js
@@ -0,0 +1,34 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+
+const TestAudioButton = styled(Button)`
+ margin: 0 !important;
+ background-color: transparent;
+ color: ${colorPrimary};
+ font-weight: normal;
+ border: none;
+
+ i {
+ color: ${colorPrimary};
+
+ ${({ animations }) => animations && `
+ transition: all .2s ease-in-out;
+ `}
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ border: none;
+ background-color: transparent !important;
+ color: #0c5cb2 !important;
+ i {
+ color: #0c5cb2;
+ }
+ }
+`;
+
+export default {
+ TestAudioButton,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/autoplay/component.jsx b/src/2.7.12/imports/ui/components/audio/autoplay/component.jsx
new file mode 100644
index 00000000..33c32e67
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/autoplay/component.jsx
@@ -0,0 +1,48 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ confirmLabel: {
+ id: 'app.audioModal.playAudio',
+ description: 'Play audio prompt for autoplay',
+ },
+ confirmAriaLabel: {
+ id: 'app.audioModal.playAudio.arialabel',
+ description: 'Provides better context for play audio prompt btn label',
+ },
+});
+
+const propTypes = {
+ handleAllowAutoplay: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+class AudioAutoplayPrompt extends PureComponent {
+ render() {
+ const {
+ intl,
+ handleAllowAutoplay,
+ } = this.props;
+ return (
+
+
+
+ );
+ }
+}
+
+export default injectIntl(AudioAutoplayPrompt);
+
+AudioAutoplayPrompt.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/audio/autoplay/styles.js b/src/2.7.12/imports/ui/components/audio/autoplay/styles.js
new file mode 100644
index 00000000..6a0b563a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/autoplay/styles.js
@@ -0,0 +1,24 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+
+const AutoplayPrompt = styled.span`
+ margin-top: auto;
+ margin-bottom: auto;
+`;
+
+const AutoplayButton = styled(Button)`
+ &:focus {
+ outline: none !important;
+ }
+
+ span:last-child {
+ color: black;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+`;
+
+export default {
+ AutoplayPrompt,
+ AutoplayButton,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/captions/button/component.jsx b/src/2.7.12/imports/ui/components/audio/captions/button/component.jsx
new file mode 100644
index 00000000..55f39288
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/button/component.jsx
@@ -0,0 +1,267 @@
+import React, { useRef, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Service from '/imports/ui/components/audio/captions/service';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+import ServiceOldCaptions from '/imports/ui/components/captions/service';
+import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Styled from './styles';
+import OldCaptionsService from '/imports/ui/components/captions/service';
+
+const TRANSCRIPTION_DEFAULT_PAD = Meteor.settings.public.captions.defaultPad;
+
+const intlMessages = defineMessages({
+ start: {
+ id: 'app.audio.captions.button.start',
+ description: 'Start audio captions',
+ },
+ stop: {
+ id: 'app.audio.captions.button.stop',
+ description: 'Stop audio captions',
+ },
+ transcriptionSettings: {
+ id: 'app.audio.captions.button.transcriptionSettings',
+ description: 'Audio captions settings modal',
+ },
+ transcription: {
+ id: 'app.audio.captions.button.transcription',
+ description: 'Audio speech transcription label',
+ },
+ transcriptionOn: {
+ id: 'app.switch.onLabel',
+ },
+ transcriptionOff: {
+ id: 'app.switch.offLabel',
+ },
+ language: {
+ id: 'app.audio.captions.button.language',
+ description: 'Audio speech recognition language label',
+ },
+ autoDetect: {
+ id: 'app.audio.captions.button.autoDetect',
+ description: 'Audio speech recognition language auto detect',
+ },
+ 'de-DE': {
+ id: 'app.audio.captions.select.de-DE',
+ description: 'Audio speech recognition german language',
+ },
+ 'en-US': {
+ id: 'app.audio.captions.select.en-US',
+ description: 'Audio speech recognition english language',
+ },
+ 'es-ES': {
+ id: 'app.audio.captions.select.es-ES',
+ description: 'Audio speech recognition spanish language',
+ },
+ 'ca-ES': {
+ id: 'app.audio.captions.select.ca-ES',
+ description: 'Audio speech recognition catalan language',
+ },
+ 'fr-FR': {
+ id: 'app.audio.captions.select.fr-FR',
+ description: 'Audio speech recognition french language',
+ },
+ 'hi-ID': {
+ id: 'app.audio.captions.select.hi-ID',
+ description: 'Audio speech recognition indian language',
+ },
+ 'it-IT': {
+ id: 'app.audio.captions.select.it-IT',
+ description: 'Audio speech recognition italian language',
+ },
+ 'ja-JP': {
+ id: 'app.audio.captions.select.ja-JP',
+ description: 'Audio speech recognition japanese language',
+ },
+ 'pt-BR': {
+ id: 'app.audio.captions.select.pt-BR',
+ description: 'Audio speech recognition portuguese language',
+ },
+ 'ru-RU': {
+ id: 'app.audio.captions.select.ru-RU',
+ description: 'Audio speech recognition russian language',
+ },
+ 'zh-CN': {
+ id: 'app.audio.captions.select.zh-CN',
+ description: 'Audio speech recognition chinese language',
+ },
+});
+
+const DEFAULT_LOCALE = 'en-US';
+const DISABLED = '';
+
+const CaptionsButton = ({
+ intl,
+ active,
+ isRTL,
+ enabled,
+ currentSpeechLocale,
+ availableVoices,
+ isSupported,
+ isVoiceUser,
+}) => {
+ const usePrevious = (value) => {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+ }
+
+ const isTranscriptionDisabled = () => (
+ currentSpeechLocale === DISABLED
+ );
+
+ const fallbackLocale = availableVoices.includes(navigator.language)
+ ? navigator.language : DEFAULT_LOCALE;
+
+ const getSelectedLocaleValue = (isTranscriptionDisabled() ? fallbackLocale : currentSpeechLocale);
+
+ const selectedLocale = useRef(getSelectedLocaleValue);
+
+ useEffect(() => {
+ if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue;
+ }, [currentSpeechLocale]);
+
+ const prevEnabled = usePrevious(enabled);
+
+ if (!enabled) return null;
+ if (!prevEnabled && enabled) {
+ OldCaptionsService.createCaptions(TRANSCRIPTION_DEFAULT_PAD);
+ }
+
+ const shouldRenderChevron = isSupported && isVoiceUser;
+
+ const getAvailableLocales = () => (
+ availableVoices.map((availableVoice) => (
+ {
+ icon: '',
+ label: intlMessages[availableVoice] ? intl.formatMessage(intlMessages[availableVoice]) : availableVoice,
+ key: availableVoice,
+ iconRight: selectedLocale.current === availableVoice ? 'check' : null,
+ customStyles: (selectedLocale.current === availableVoice) && Styled.SelectedLabel,
+ disabled: isTranscriptionDisabled(),
+ dividerTop: !SpeechService.isGladia() && availableVoice === availableVoices[0],
+ onClick: () => {
+ selectedLocale.current = availableVoice;
+ SpeechService.setSpeechLocale(selectedLocale.current);
+ },
+ }
+ ))
+ );
+
+ const autoLanguage = SpeechService.isGladia() ? {
+ icon: '',
+ label: intl.formatMessage(intlMessages.autoDetect),
+ key: 'auto',
+ iconRight: selectedLocale.current === 'auto' ? 'check' : null,
+ customStyles: (selectedLocale.current === 'auto') && Styled.SelectedLabel,
+ disabled: isTranscriptionDisabled(),
+ dividerTop: true,
+ onClick: () => {
+ selectedLocale.current = 'auto';
+ SpeechService.setSpeechLocale(selectedLocale.current);
+ },
+ } : undefined;
+
+ const toggleTranscription = () => {
+ SpeechService.setSpeechLocale(isTranscriptionDisabled() ? selectedLocale.current : DISABLED);
+ };
+
+ const getAvailableLocalesList = () => (
+ [{
+ key: 'availableLocalesList',
+ label: intl.formatMessage(intlMessages.language),
+ customStyles: Styled.TitleLabel,
+ disabled: true,
+ dividerTop: false,
+ },
+ autoLanguage,
+ ...getAvailableLocales(),
+ {
+ key: 'divider',
+ label: intl.formatMessage(intlMessages.transcription),
+ customStyles: Styled.TitleLabel,
+ disabled: true,
+ }, {
+ key: 'transcriptionStatus',
+ label: intl.formatMessage(
+ isTranscriptionDisabled()
+ ? intlMessages.transcriptionOn
+ : intlMessages.transcriptionOff,
+ ),
+ customStyles: isTranscriptionDisabled()
+ ? Styled.EnableTrascription : Styled.DisableTrascription,
+ disabled: false,
+ dividerTop: true,
+ onClick: toggleTranscription,
+ }].filter((e) => e) // filter undefined elements because of 'autoLanguage'
+ );
+
+ const onToggleClick = (e) => {
+ e.stopPropagation();
+ Service.setAudioCaptions(!active);
+ };
+
+ const startStopCaptionsButton = (
+
+ );
+
+ return (
+ shouldRenderChevron
+ ? (
+
+
+ { startStopCaptionsButton }
+
+ >
+ )}
+ actions={getAvailableLocalesList()}
+ opts={{
+ id: 'default-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ />
+
+ ) : startStopCaptionsButton
+ );
+};
+
+CaptionsButton.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ active: PropTypes.bool.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+ enabled: PropTypes.bool.isRequired,
+ currentSpeechLocale: PropTypes.string.isRequired,
+ availableVoices: PropTypes.arrayOf(PropTypes.string).isRequired,
+ isSupported: PropTypes.bool.isRequired,
+ isVoiceUser: PropTypes.bool.isRequired,
+};
+
+export default injectIntl(CaptionsButton);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/button/container.jsx b/src/2.7.12/imports/ui/components/audio/captions/button/container.jsx
new file mode 100644
index 00000000..44a08b88
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/button/container.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/audio/captions/service';
+import Button from './component';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+import AudioService from '/imports/ui/components/audio/service';
+
+const Container = (props) => ;
+
+export default withTracker(() => {
+ const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
+ const availableVoices = SpeechService.getSpeechVoices();
+ const currentSpeechLocale = SpeechService.getSpeechLocale();
+ const isSupported = availableVoices.length > 0;
+ const isVoiceUser = AudioService.isVoiceUser();
+ return {
+ isRTL,
+ enabled: Service.hasAudioCaptions(),
+ active: Service.getAudioCaptions(),
+ currentSpeechLocale,
+ availableVoices,
+ isSupported,
+ isVoiceUser,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/button/styles.js b/src/2.7.12/imports/ui/components/audio/captions/button/styles.js
new file mode 100644
index 00000000..d82aaf7f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/button/styles.js
@@ -0,0 +1,61 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import Toggle from '/imports/ui/components/common/switch/component';
+import {
+ colorWhite,
+ colorPrimary,
+ colorOffWhite,
+ colorDangerDark,
+ colorSuccess,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const ClosedCaptionToggleButton = styled(Button)`
+ ${({ ghost }) => ghost && `
+ span {
+ box-shadow: none;
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ i {
+ margin-top: .4rem;
+ }
+ `}
+`;
+
+const SpanButtonWrapper = styled.span`
+ position: relative;
+`;
+
+const TranscriptionToggle = styled(Toggle)`
+ display: flex;
+ justify-content: flex-start;
+ padding-left: 1em;
+`;
+
+const TitleLabel = {
+ fontWeight: 'bold',
+ opacity: 1,
+};
+
+const EnableTrascription = {
+ color: colorSuccess,
+};
+
+const DisableTrascription = {
+ color: colorDangerDark,
+};
+
+const SelectedLabel = {
+ color: colorPrimary,
+ backgroundColor: colorOffWhite,
+};
+
+export default {
+ ClosedCaptionToggleButton,
+ SpanButtonWrapper,
+ TranscriptionToggle,
+ TitleLabel,
+ EnableTrascription,
+ DisableTrascription,
+ SelectedLabel,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/captions/history/component.jsx b/src/2.7.12/imports/ui/components/audio/captions/history/component.jsx
new file mode 100644
index 00000000..0e874ad9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/history/component.jsx
@@ -0,0 +1,33 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import LiveCaptions from '../live/container';
+
+class CaptionsHistory extends PureComponent {
+ constructor(props) {
+ super(props);
+ }
+
+ componentDidUpdate(prevProps) {
+ }
+
+ componentWillUnmount() {
+ }
+
+ render() {
+ const { captions } = this.props;
+
+ let i = 0;
+ return captions.map((c) => {
+ i += 1;
+ return
+ });
+ }
+}
+
+export default CaptionsHistory;
diff --git a/src/2.7.12/imports/ui/components/audio/captions/history/container.jsx b/src/2.7.12/imports/ui/components/audio/captions/history/container.jsx
new file mode 100644
index 00000000..46129043
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/history/container.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/audio/captions/service';
+import CaptionsHistory from './component';
+
+const Container = (props) => ;
+
+export default withTracker(() => {
+ const captions = Service.getAudioCaptionsData();
+
+ const lastCaption = captions?.length ? captions[captions.length-1] : {};
+
+ return {
+ captions,
+ lastTranscript: lastCaption?.transcript,
+ lastTranscriptId: lastCaption?.transcriptId,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/live/component.jsx b/src/2.7.12/imports/ui/components/audio/captions/live/component.jsx
new file mode 100644
index 00000000..eaa1a54a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/live/component.jsx
@@ -0,0 +1,107 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import UserContainer from './user/container';
+
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+
+class LiveCaptions extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = { clear: true };
+ this.timer = null;
+ }
+
+ componentDidUpdate(prevProps) {
+ const { clear } = this.state;
+ const { index, nCaptions } = this.props;
+
+ if (clear) {
+ const { transcript } = this.props;
+ if (prevProps.transcript !== transcript) {
+ // eslint-disable-next-line react/no-did-update-set-state
+ this.setState({ clear: false });
+ }
+ } else {
+ this.resetTimer();
+ this.timer = setTimeout(() => this.setState({ clear: true }), (CAPTIONS_CONFIG.time / nCaptions) * (index+1));
+ }
+ }
+
+ componentWillUnmount() {
+ this.resetTimer();
+ }
+
+ resetTimer() {
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+
+ render() {
+ const {
+ transcript,
+ transcriptId,
+ index,
+ nCaptions,
+ } = this.props;
+
+ const { clear } = this.state;
+
+ const hasContent = transcript.length > 0 && !clear;
+
+ const wrapperStyles = {
+ display: 'flex',
+ };
+
+ const captionStyles = {
+ whiteSpace: 'pre-line',
+ wordWrap: 'break-word',
+ fontFamily: 'Verdana, Arial, Helvetica, sans-serif',
+ fontSize: '1.5rem',
+ background: '#000000a0',
+ color: 'white',
+ padding: hasContent ? '.5rem' : undefined,
+ };
+
+ const visuallyHidden = {
+ position: 'absolute',
+ overflow: 'hidden',
+ clip: 'rect(0 0 0 0)',
+ height: '1px',
+ width: '1px',
+ margin: '-1px',
+ padding: '0',
+ border: '0',
+ };
+
+ return (
+
+ {clear ? null : (
+
+ )}
+
+ {clear ? '' : transcript}
+
+
+ {clear ? '' : transcript}
+
+
+ );
+ }
+}
+
+LiveCaptions.propTypes = {
+ transcript: PropTypes.string.isRequired,
+ transcriptId: PropTypes.string.isRequired,
+};
+
+export default LiveCaptions;
diff --git a/src/2.7.12/imports/ui/components/audio/captions/live/container.jsx b/src/2.7.12/imports/ui/components/audio/captions/live/container.jsx
new file mode 100644
index 00000000..05cf839f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/live/container.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/audio/captions/service';
+import LiveCaptions from './component';
+
+const Container = (props) => ;
+
+export default withTracker((props) => {
+ return props;
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/live/user/component.jsx b/src/2.7.12/imports/ui/components/audio/captions/live/user/component.jsx
new file mode 100644
index 00000000..f07fb82c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/live/user/component.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+
+const User = ({
+ avatar,
+ background,
+ color,
+ moderator,
+ name,
+}) => (
+
+
+ {name.slice(0, 2)}
+
+
+);
+
+User.propTypes = {
+ avatar: PropTypes.string.isRequired,
+ background: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+ moderator: PropTypes.bool.isRequired,
+ name: PropTypes.string.isRequired,
+};
+
+export default User;
diff --git a/src/2.7.12/imports/ui/components/audio/captions/live/user/container.jsx b/src/2.7.12/imports/ui/components/audio/captions/live/user/container.jsx
new file mode 100644
index 00000000..cf5d0146
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/live/user/container.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Users from '/imports/api/users';
+import User from './component';
+
+const MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const Container = (props) => ;
+
+const getUser = (userId) => {
+ const user = Users.findOne(
+ { userId },
+ {
+ fields: {
+ avatar: 1,
+ color: 1,
+ role: 1,
+ name: 1,
+ },
+ },
+ );
+
+ if (user) {
+ return {
+ avatar: user.avatar,
+ color: user.color,
+ moderator: user.role === MODERATOR,
+ name: user.name,
+ };
+ }
+
+ return {
+ avatar: '',
+ color: '',
+ moderator: false,
+ name: '',
+ };
+};
+
+export default withTracker(({ transcriptId }) => {
+ const userId = transcriptId.split('-')[0];
+
+ return getUser(userId);
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/select/component.jsx b/src/2.7.12/imports/ui/components/audio/captions/select/component.jsx
new file mode 100644
index 00000000..43bcb8e0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/select/component.jsx
@@ -0,0 +1,146 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.audio.captions.speech.title',
+ description: 'Audio speech recognition title',
+ },
+ disabled: {
+ id: 'app.audio.captions.speech.disabled',
+ description: 'Audio speech recognition disabled',
+ },
+ auto: {
+ id: 'app.audio.captions.speech.auto',
+ description: 'Audio speech recognition auto',
+ },
+ unsupported: {
+ id: 'app.audio.captions.speech.unsupported',
+ description: 'Audio speech recognition unsupported',
+ },
+ 'ca-ES': {
+ id: 'app.audio.captions.select.ca-ES',
+ description: 'Audio speech recognition catalan language',
+ },
+ 'de-DE': {
+ id: 'app.audio.captions.select.de-DE',
+ description: 'Audio speech recognition german language',
+ },
+ 'en-US': {
+ id: 'app.audio.captions.select.en-US',
+ description: 'Audio speech recognition english language',
+ },
+ 'es-ES': {
+ id: 'app.audio.captions.select.es-ES',
+ description: 'Audio speech recognition spanish language',
+ },
+ 'fr-FR': {
+ id: 'app.audio.captions.select.fr-FR',
+ description: 'Audio speech recognition french language',
+ },
+ 'hi-ID': {
+ id: 'app.audio.captions.select.hi-ID',
+ description: 'Audio speech recognition indian language',
+ },
+ 'it-IT': {
+ id: 'app.audio.captions.select.it-IT',
+ description: 'Audio speech recognition italian language',
+ },
+ 'ja-JP': {
+ id: 'app.audio.captions.select.ja-JP',
+ description: 'Audio speech recognition japanese language',
+ },
+ 'pt-BR': {
+ id: 'app.audio.captions.select.pt-BR',
+ description: 'Audio speech recognition portuguese language',
+ },
+ 'ru-RU': {
+ id: 'app.audio.captions.select.ru-RU',
+ description: 'Audio speech recognition russian language',
+ },
+ 'zh-CN': {
+ id: 'app.audio.captions.select.zh-CN',
+ description: 'Audio speech recognition chinese language',
+ },
+});
+
+const Select = ({
+ intl,
+ enabled,
+ locale,
+ voices,
+}) => {
+ const useLocaleHook = SpeechService.useFixedLocale();
+ if (!enabled || useLocaleHook) return null;
+
+ if (voices.length === 0) {
+ return (
+
+ {`*${intl.formatMessage(intlMessages.unsupported)}`}
+
+ );
+ }
+
+ const onChange = (e) => {
+ const { value } = e.target;
+ SpeechService.setSpeechLocale(value);
+ };
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+
+ {intl.formatMessage(intlMessages.disabled)}
+
+ {SpeechService.isGladia() ?
+
+ {intl.formatMessage(intlMessages.auto)}
+
+ : null
+ }
+ {voices.map((v) => (
+
+ {intlMessages[v] ? intl.formatMessage(intlMessages[v]) : v}
+
+ ))}
+
+
+ );
+};
+
+Select.propTypes = {
+ enabled: PropTypes.bool.isRequired,
+ locale: PropTypes.string.isRequired,
+ voices: PropTypes.arrayOf(PropTypes.string).isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+export default injectIntl(Select);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/select/container.jsx b/src/2.7.12/imports/ui/components/audio/captions/select/container.jsx
new file mode 100644
index 00000000..4c9e5ccc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/select/container.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/audio/captions/speech/service';
+import Select from './component';
+
+const Container = (props) => ;
+
+export default withTracker(() => ({
+ enabled: Service.isEnabled(),
+ locale: Service.getSpeechLocale(),
+ voices: Service.getSpeechVoices(),
+}))(Container);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/service.js b/src/2.7.12/imports/ui/components/audio/captions/service.js
new file mode 100644
index 00000000..00885200
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/service.js
@@ -0,0 +1,73 @@
+import AudioCaptions from '/imports/api/audio-captions';
+import Auth from '/imports/ui/services/auth';
+
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+const CAPTIONS_ALWAYS_VISIBLE = Meteor.settings.public.app.audioCaptions.alwaysVisible;
+const CHARACTERS_PER_LINE = CAPTIONS_CONFIG.lineLimit;
+const LINES_PER_MESSAGE = CAPTIONS_CONFIG.lines;
+const CAPTION_TIME = CAPTIONS_CONFIG.time;
+const CAPTION_LIMIT = CAPTIONS_CONFIG.captionLimit;
+
+function splitTranscript(obj) {
+ const transcripts = [];
+ const words = obj.transcript.split(' ');
+
+ let currentLine = '';
+ let result = '';
+
+ for (const word of words) {
+ if ((currentLine + word).length <= CHARACTERS_PER_LINE) {
+ currentLine += word + ' ';
+ } else {
+ result += currentLine.trim() + '\n';
+ currentLine = word + ' ';
+ }
+
+ if (result.split('\n').length > LINES_PER_MESSAGE) {
+ transcripts.push(result)
+ result = ''
+ }
+ }
+
+ if (result.length) {
+ transcripts.push(result)
+ }
+ transcripts.push(currentLine.trim())
+
+ return transcripts.map((t) => { return { ...obj, transcript: t} });
+}
+
+const getAudioCaptionsData = () => {
+ // the correct way woulde to use { limit: CAPTION_LIMIT } but something
+ // is up with this mongo query and it does not seem to work
+ let audioCaptions = AudioCaptions.find({ meetingId: Auth.meetingID}, { sort: { lastUpdate: -1 } }).fetch().slice(-CAPTION_LIMIT);
+
+ const recentEnough = (c) => (new Date().getTime()/1000 - c.lastUpdated) < CAPTIONS_CONFIG.time/1000;
+
+ audioCaptions = audioCaptions.filter(recentEnough).map((c) => {
+ const splits = splitTranscript(c);
+ return splits;
+ });
+
+ return audioCaptions.flat().filter((c) => c.transcript).slice(-CAPTION_LIMIT);
+};
+
+const getAudioCaptions = () => Session.get('audioCaptions') || false;
+
+const setAudioCaptions = (value) => Session.set('audioCaptions', value);
+
+const hasAudioCaptions = () => {
+ const audioCaptions = AudioCaptions.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: {} },
+ );
+
+ return CAPTIONS_ALWAYS_VISIBLE || !!audioCaptions;
+};
+
+export default {
+ getAudioCaptionsData,
+ getAudioCaptions,
+ setAudioCaptions,
+ hasAudioCaptions,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/captions/speech/component.jsx b/src/2.7.12/imports/ui/components/audio/captions/speech/component.jsx
new file mode 100644
index 00000000..a6bd4c7e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/speech/component.jsx
@@ -0,0 +1,151 @@
+import { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import logger from '/imports/startup/client/logger';
+import Service from './service';
+
+class Speech extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onEnd = this.onEnd.bind(this);
+ this.onError = this.onError.bind(this);
+ this.onResult = this.onResult.bind(this);
+
+ this.result = {
+ id: Service.generateId(),
+ transcript: '',
+ isFinal: true,
+ };
+
+ this.idle = true;
+
+ this.speechRecognition = Service.initSpeechRecognition();
+
+ if (this.speechRecognition) {
+ this.speechRecognition.onend = () => this.onEnd();
+ this.speechRecognition.onerror = (event) => this.onError(event);
+ this.speechRecognition.onresult = (event) => this.onResult(event);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ locale,
+ connected,
+ talking,
+ } = this.props;
+
+ // Connected
+ if (!prevProps.connected && connected) {
+ this.start(locale);
+ }
+
+ // Disconnected
+ if (prevProps.connected && !connected) {
+ this.stop();
+ }
+
+ // Switch locale
+ if (prevProps.locale !== locale) {
+ if (prevProps.connected && connected) {
+ this.stop();
+ this.start(locale);
+ }
+ }
+
+ // Recovery from idle
+ if (!prevProps.talking && talking) {
+ if (prevProps.connected && connected) {
+ if (this.idle) {
+ this.start(locale);
+ }
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this.stop();
+ }
+
+ onEnd() {
+ this.stop();
+ }
+
+ onError(event) {
+ this.stop();
+
+ logger.error({
+ logCode: 'captions_speech_recognition',
+ extraInfo: {
+ error: event.error,
+ message: event.message,
+ },
+ }, 'Captions speech recognition error');
+ }
+
+ onResult(event) {
+ const {
+ resultIndex,
+ results,
+ } = event;
+
+ const { id } = this.result;
+ const { transcript } = results[resultIndex][0];
+ const { isFinal } = results[resultIndex];
+
+ this.result.transcript = transcript;
+ this.result.isFinal = isFinal;
+
+ const { locale } = this.props;
+ if (isFinal) {
+ Service.updateFinalTranscript(id, transcript, locale);
+ this.result.id = Service.generateId();
+ } else {
+ Service.updateInterimTranscript(id, transcript, locale);
+ }
+ }
+
+ start(locale) {
+ if (this.speechRecognition && Service.isLocaleValid(locale)) {
+ this.speechRecognition.lang = locale;
+ try {
+ this.result.id = Service.generateId();
+ this.speechRecognition.start();
+ this.idle = false;
+ } catch (event) {
+ this.onError(event);
+ }
+ }
+ }
+
+ stop() {
+ this.idle = true;
+ if (this.speechRecognition) {
+ const {
+ isFinal,
+ transcript,
+ } = this.result;
+
+ if (!isFinal) {
+ const { locale } = this.props;
+ const { id } = this.result;
+ Service.updateFinalTranscript(id, transcript, locale);
+ this.speechRecognition.abort();
+ } else {
+ this.speechRecognition.stop();
+ }
+ }
+ }
+
+ render() {
+ return null;
+ }
+}
+
+Speech.propTypes = {
+ locale: PropTypes.string.isRequired,
+ connected: PropTypes.bool.isRequired,
+ talking: PropTypes.bool.isRequired,
+};
+
+export default Speech;
diff --git a/src/2.7.12/imports/ui/components/audio/captions/speech/container.jsx b/src/2.7.12/imports/ui/components/audio/captions/speech/container.jsx
new file mode 100644
index 00000000..f3e7a38b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/speech/container.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from './service';
+import Speech from './component';
+
+const Container = (props) => ;
+
+export default withTracker(() => {
+ const {
+ locale,
+ connected,
+ talking,
+ } = Service.getStatus();
+
+ return {
+ locale,
+ connected,
+ talking,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/audio/captions/speech/service.js b/src/2.7.12/imports/ui/components/audio/captions/speech/service.js
new file mode 100644
index 00000000..ac9e9487
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/captions/speech/service.js
@@ -0,0 +1,194 @@
+import { diff } from '@mconf/bbb-diff';
+import { Session } from 'meteor/session';
+import Auth from '/imports/ui/services/auth';
+import { makeCall } from '/imports/ui/services/api';
+import logger from '/imports/startup/client/logger';
+import Users from '/imports/api/users';
+import AudioService from '/imports/ui/components/audio/service';
+import deviceInfo from '/imports/utils/deviceInfo';
+import { isLiveTranscriptionEnabled } from '/imports/ui/services/features';
+import { unique, throttle } from 'radash';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+
+const THROTTLE_TIMEOUT = 200;
+
+const CONFIG = Meteor.settings.public.app.audioCaptions;
+const ENABLED = CONFIG.enabled;
+const LANGUAGES = CONFIG.language.available;
+const VALID_ENVIRONMENT = !deviceInfo.isMobile || CONFIG.mobile;
+
+const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
+
+const hasSpeechRecognitionSupport = () => typeof SpeechRecognitionAPI !== 'undefined'
+ && typeof window.speechSynthesis !== 'undefined'
+ && VALID_ENVIRONMENT;
+
+const getSpeechVoices = () => {
+ return LANGUAGES;
+};
+
+const getSpeechProvider = () => {
+ return getFromUserSettings("bbb_transcription_provider", CONFIG.provider);
+};
+
+const setSpeechOptions = (partialUtterances, minUtteranceLength) => {
+ return makeCall('setSpeechOptions', partialUtterances, minUtteranceLength);
+};
+
+const setSpeechLocale = (value) => {
+ const voices = getSpeechVoices();
+
+ if (voices.includes(value) || value === '' || (value === 'auto' && isGladia())) {
+ makeCall('setSpeechLocale', value, getSpeechProvider());
+ } else {
+ logger.error({
+ logCode: 'captions_speech_locale',
+ }, 'Captions speech set locale error');
+ }
+};
+
+const setDefaultLocale = () => {
+ if (useFixedLocale() || localeAsDefaultSelected()) {
+ setSpeechLocale(getLocale());
+ } else {
+ setSpeechLocale(navigator.language);
+ }
+}
+
+const useFixedLocale = () => isEnabled() && CONFIG.language.forceLocale;
+
+const initSpeechRecognition = () => {
+ if (!isEnabled()) return null;
+
+ if (!isWebSpeechApi()) {
+ setDefaultLocale();
+ return;
+ }
+
+ if (hasSpeechRecognitionSupport()) {
+ // Effectivate getVoices
+ const speechRecognition = new SpeechRecognitionAPI();
+ speechRecognition.continuous = true;
+ speechRecognition.interimResults = true;
+
+ setDefaultLocale();
+
+ return speechRecognition;
+ }
+
+ logger.warn({
+ logCode: 'captions_speech_unsupported',
+ }, 'Captions speech unsupported');
+
+ return null;
+};
+
+let prevId = '';
+let prevTranscript = '';
+const updateTranscript = (id, transcript, locale, isFinal) => {
+ // If it's a new sentence
+ if (id !== prevId) {
+ prevId = id;
+ prevTranscript = '';
+ }
+
+ const transcriptDiff = diff(prevTranscript, transcript);
+
+ let start = 0;
+ let end = 0;
+ let text = '';
+ if (transcriptDiff) {
+ start = transcriptDiff.start;
+ end = transcriptDiff.end;
+ text = transcriptDiff.text;
+ }
+
+ // Stores current transcript as previous
+ prevTranscript = transcript;
+
+ makeCall('updateTranscript', id, start, end, text, transcript, locale, isFinal);
+};
+
+const throttledTranscriptUpdate = throttle({ interval: THROTTLE_TIMEOUT }, updateTranscript);
+
+const updateInterimTranscript = (id, transcript, locale) => {
+ throttledTranscriptUpdate(id, transcript, locale, false);
+};
+
+const updateFinalTranscript = (id, transcript, locale) => {
+ updateTranscript(id, transcript, locale, true);
+};
+
+const getSpeechLocale = (userId = Auth.userID) => {
+ const user = Users.findOne({ userId }, { fields: { speechLocale: 1 } });
+
+ if (user) return user.speechLocale;
+
+ return '';
+};
+
+const hasSpeechLocale = (userId = Auth.userID) => getSpeechLocale(userId) !== '';
+
+const isLocaleValid = (locale) => LANGUAGES.includes(locale);
+
+const isEnabled = () => isLiveTranscriptionEnabled();
+
+const isWebSpeechApi = () => getSpeechProvider() === 'webspeech';
+
+const isVosk = () => getSpeechProvider() === 'vosk';
+
+const isGladia = () => getSpeechProvider() === 'gladia';
+
+const isWhispering = () => getSpeechProvider() === 'whisper';
+
+const isDeepSpeech = () => getSpeechProvider() === 'deepSpeech'
+
+const isActive = () => isEnabled() && ((isWebSpeechApi() && hasSpeechLocale()) || isVosk() || isGladia() || isWhispering() || isDeepSpeech());
+
+const getStatus = () => {
+ const active = isActive();
+ const locale = getSpeechLocale();
+ const audio = AudioService.isConnected() && !AudioService.isEchoTest() && !AudioService.isMuted();
+ const connected = Meteor.status().connected && active && audio;
+ const talking = AudioService.isTalking();
+
+ return {
+ locale,
+ connected,
+ talking,
+ };
+};
+
+const generateId = () => `${Auth.userID}-${Date.now()}`;
+
+const localeAsDefaultSelected = () => CONFIG.language.defaultSelectLocale;
+
+const getLocale = () => {
+ const { locale } = CONFIG.language;
+ if (locale === 'browserLanguage') return navigator.language;
+ if (locale === 'disabled') return '';
+ return locale;
+};
+
+const stereoUnsupported = () => isActive() && (isVosk() || isGladia()) && !!getSpeechLocale();
+
+export default {
+ LANGUAGES,
+ hasSpeechRecognitionSupport,
+ initSpeechRecognition,
+ updateInterimTranscript,
+ updateFinalTranscript,
+ getSpeechVoices,
+ getSpeechLocale,
+ setSpeechLocale,
+ setSpeechOptions,
+ hasSpeechLocale,
+ isLocaleValid,
+ isEnabled,
+ isActive,
+ getStatus,
+ generateId,
+ useFixedLocale,
+ stereoUnsupported,
+ isGladia,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/container.jsx b/src/2.7.12/imports/ui/components/audio/container.jsx
new file mode 100644
index 00000000..86a2be70
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/container.jsx
@@ -0,0 +1,278 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import { Session } from 'meteor/session';
+import { injectIntl, defineMessages } from 'react-intl';
+import { range } from '/imports/utils/array-utils';
+import Auth from '/imports/ui/services/auth';
+import Breakouts from '/imports/api/breakouts';
+import AppService from '/imports/ui/components/app/service';
+import BreakoutsService from '/imports/ui/components/breakout-room/service';
+import { notify } from '/imports/ui/services/notification';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import VideoPreviewContainer from '/imports/ui/components/video-preview/container';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import {
+ joinMicrophone,
+ joinListenOnly,
+ didUserSelectedMicrophone,
+ didUserSelectedListenOnly,
+} from '/imports/ui/components/audio/audio-modal/service';
+
+import Service from './service';
+import AudioModalContainer from './audio-modal/container';
+import Settings from '/imports/ui/services/settings';
+
+const APP_CONFIG = Meteor.settings.public.app;
+const KURENTO_CONFIG = Meteor.settings.public.kurento;
+
+const intlMessages = defineMessages({
+ joinedAudio: {
+ id: 'app.audioManager.joinedAudio',
+ description: 'Joined audio toast message',
+ },
+ joinedEcho: {
+ id: 'app.audioManager.joinedEcho',
+ description: 'Joined echo test toast message',
+ },
+ leftAudio: {
+ id: 'app.audioManager.leftAudio',
+ description: 'Left audio toast message',
+ },
+ reconnectingAudio: {
+ id: 'app.audioManager.reconnectingAudio',
+ description: 'Reconnecting audio toast message',
+ },
+ genericError: {
+ id: 'app.audioManager.genericError',
+ description: 'Generic error message',
+ },
+ connectionError: {
+ id: 'app.audioManager.connectionError',
+ description: 'Connection error message',
+ },
+ requestTimeout: {
+ id: 'app.audioManager.requestTimeout',
+ description: 'Request timeout error message',
+ },
+ invalidTarget: {
+ id: 'app.audioManager.invalidTarget',
+ description: 'Invalid target error message',
+ },
+ mediaError: {
+ id: 'app.audioManager.mediaError',
+ description: 'Media error message',
+ },
+ BrowserNotSupported: {
+ id: 'app.audioNotification.audioFailedError1003',
+ description: 'browser not supported error messsage',
+ },
+ reconectingAsListener: {
+ id: 'app.audioNotificaion.reconnectingAsListenOnly',
+ description: 'ice negociation error messsage',
+ },
+});
+
+class AudioContainer extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.init = props.init.bind(this);
+ }
+
+ componentDidMount() {
+ const { meetingIsBreakout } = this.props;
+
+ this.init().then(() => {
+ if (meetingIsBreakout && !Service.isUsingAudio()) {
+ this.joinAudio();
+ }
+ });
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.userIsReturningFromBreakoutRoom(prevProps)) {
+ this.joinAudio();
+ }
+ }
+
+ /**
+ * Helper function to determine wheter user is returning from breakout room
+ * to main room.
+ * @param {Object} prevProps prevProps param from componentDidUpdate
+ * @return {boolean} True if user is returning from breakout room
+ * to main room. False, otherwise.
+ */
+ userIsReturningFromBreakoutRoom(prevProps) {
+ const { hasBreakoutRooms } = this.props;
+ const { hasBreakoutRooms: hadBreakoutRooms } = prevProps;
+ return hadBreakoutRooms && !hasBreakoutRooms;
+ }
+
+ /**
+ * Helper function that join (or not) user in audio. If user previously
+ * selected microphone, it will automatically join mic (without audio modal).
+ * If user previously selected listen only option in audio modal, then it will
+ * automatically join listen only.
+ */
+ joinAudio() {
+ if (Service.isConnected()) return;
+
+ const {
+ userSelectedMicrophone,
+ userSelectedListenOnly,
+ } = this.props;
+
+ if (userSelectedMicrophone) {
+ joinMicrophone(true);
+ return;
+ }
+
+ if (userSelectedListenOnly) joinListenOnly();
+ }
+
+ render() {
+ const { isAudioModalOpen, setAudioModalIsOpen,
+ setVideoPreviewModalIsOpen, isVideoPreviewModalOpen } = this.props;
+ return <>
+ {isAudioModalOpen ? : null}
+ {isVideoPreviewModalOpen ? {
+ setVideoPreviewModalIsOpen(false);
+ },
+ priority: "low",
+ setIsOpen: setVideoPreviewModalIsOpen,
+ isOpen: isVideoPreviewModalOpen
+ }}
+ /> : null}
+ >;
+ }
+}
+
+let didMountAutoJoin = false;
+
+const webRtcError = range(1001, 1011)
+ .reduce((acc, value) => ({
+ ...acc,
+ [value]: { id: `app.audioNotification.audioFailedError${value}` },
+ }), {});
+
+const messages = {
+ info: {
+ JOINED_AUDIO: intlMessages.joinedAudio,
+ JOINED_ECHO: intlMessages.joinedEcho,
+ LEFT_AUDIO: intlMessages.leftAudio,
+ RECONNECTING_AUDIO: intlMessages.reconnectingAudio,
+ },
+ error: {
+ GENERIC_ERROR: intlMessages.genericError,
+ CONNECTION_ERROR: intlMessages.connectionError,
+ REQUEST_TIMEOUT: intlMessages.requestTimeout,
+ INVALID_TARGET: intlMessages.invalidTarget,
+ MEDIA_ERROR: intlMessages.mediaError,
+ WEBRTC_NOT_SUPPORTED: intlMessages.BrowserNotSupported,
+ ...webRtcError,
+ },
+};
+
+export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, isAudioModalOpen, setAudioModalIsOpen,
+ setVideoPreviewModalIsOpen, isVideoPreviewModalOpen }) => {
+ const { microphoneConstraints } = Settings.application;
+ const autoJoin = getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin);
+ const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
+ const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam);
+ const { userWebcam, userMic } = userLocks;
+
+ const userSelectedMicrophone = didUserSelectedMicrophone();
+ const userSelectedListenOnly = didUserSelectedListenOnly();
+ const meetingIsBreakout = AppService.meetingIsBreakout();
+ const hasBreakoutRooms = AppService.getBreakoutRooms().length > 0;
+ const openAudioModal = () => setAudioModalIsOpen(true);
+
+ const openVideoPreviewModal = () => {
+ if (userWebcam) return;
+ setVideoPreviewModalIsOpen(true);
+ };
+
+ if (Service.isConnected() && !Service.isListenOnly()) {
+ Service.updateAudioConstraints(microphoneConstraints);
+
+ if (userMic && !Service.isMuted()) {
+ Service.toggleMuteMicrophone();
+ notify(intl.formatMessage(intlMessages.reconectingAsListener), 'info', 'volume_level_2');
+ }
+ }
+ const breakoutUserIsIn = BreakoutsService.getBreakoutUserIsIn(Auth.userID);
+ if(!!breakoutUserIsIn && !meetingIsBreakout) {
+ const userBreakout = Breakouts.find({id: breakoutUserIsIn.id})
+ userBreakout.observeChanges({
+ removed() {
+ // if the user joined a breakout room, the main room's audio was
+ // programmatically dropped to avoid interference. On breakout end,
+ // offer to rejoin main room audio only if the user is not in audio already
+ if (Service.isUsingAudio()
+ || userSelectedMicrophone
+ || userSelectedListenOnly) {
+ if (enableVideo && autoShareWebcam) {
+ openVideoPreviewModal();
+ }
+
+ return;
+ }
+ setTimeout(() => {
+ openAudioModal();
+ if (enableVideo && autoShareWebcam) {
+ openVideoPreviewModal();
+ }
+ }, 0);
+ },
+ });
+ }
+
+ return {
+ hasBreakoutRooms,
+ meetingIsBreakout,
+ userSelectedMicrophone,
+ userSelectedListenOnly,
+ isAudioModalOpen,
+ setAudioModalIsOpen,
+ init: async () => {
+ await Service.init(messages, intl);
+ const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
+ const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam);
+ if ((!autoJoin || didMountAutoJoin)) {
+ if (enableVideo && autoShareWebcam) {
+ openVideoPreviewModal();
+ }
+ return Promise.resolve(false);
+ }
+ Session.set('audioModalIsOpen', true);
+ if (enableVideo && autoShareWebcam) {
+ openAudioModal()
+ openVideoPreviewModal();
+ didMountAutoJoin = true;
+ } else if (!(
+ userSelectedMicrophone
+ && userSelectedListenOnly
+ && meetingIsBreakout)) {
+ openAudioModal();
+ didMountAutoJoin = true;
+ }
+ return Promise.resolve(true);
+ },
+ };
+})(AudioContainer)));
+
+AudioContainer.propTypes = {
+ hasBreakoutRooms: PropTypes.bool.isRequired,
+ meetingIsBreakout: PropTypes.bool.isRequired,
+ userSelectedListenOnly: PropTypes.bool.isRequired,
+ userSelectedMicrophone: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/device-selector/component.jsx b/src/2.7.12/imports/ui/components/audio/device-selector/component.jsx
new file mode 100644
index 00000000..3a53dae1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/device-selector/component.jsx
@@ -0,0 +1,171 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import logger from '/imports/startup/client/logger';
+import browserInfo from '/imports/utils/browserInfo';
+import {
+ defineMessages,
+} from 'react-intl';
+import Styled from './styles';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ kind: PropTypes.oneOf(['audioinput', 'audiooutput']),
+ onChange: PropTypes.func.isRequired,
+ blocked: PropTypes.bool,
+ deviceId: PropTypes.string,
+};
+
+const defaultProps = {
+ kind: 'audioinput',
+ blocked: false,
+ deviceId: '',
+};
+
+const intlMessages = defineMessages({
+ fallbackInputLabel: {
+ id: 'app.audio.audioSettings.fallbackInputLabel',
+ description: 'Audio input device label',
+ },
+ fallbackOutputLabel: {
+ id: 'app.audio.audioSettings.fallbackOutputLabel',
+ description: 'Audio output device label',
+ },
+ defaultOutputDeviceLabel: {
+ id: 'app.audio.audioSettings.defaultOutputDeviceLabel',
+ description: 'Default output device label',
+ },
+ findingDevicesLabel: {
+ id: 'app.audio.audioSettings.findingDevicesLabel',
+ description: 'Finding devices label',
+ },
+ noDeviceFoundLabel: {
+ id: 'app.audio.noDeviceFound',
+ description: 'No audio device found',
+ },
+});
+
+class DeviceSelector extends Component {
+ constructor(props) {
+ super(props);
+
+ this.handleSelectChange = this.handleSelectChange.bind(this);
+
+ this.state = {
+ devices: [],
+ options: [],
+ };
+ }
+
+ componentDidMount() {
+ const { blocked } = this.props;
+
+ if (!blocked) this.enumerate();
+ }
+
+ componentDidUpdate(prevProps) {
+ const { blocked } = this.props;
+
+ if (prevProps.blocked === true && blocked === false) this.enumerate();
+ }
+
+ handleEnumerateDevicesSuccess(deviceInfos) {
+ const { kind } = this.props;
+
+ const devices = deviceInfos.filter((d) => d.kind === kind);
+ logger.info({
+ logCode: 'audiodeviceselector_component_enumeratedevices_success',
+ extraInfo: {
+ deviceKind: kind,
+ devices,
+ },
+ }, 'Success on enumerateDevices() for audio');
+ this.setState({
+ devices,
+ options: devices.map((d, i) => ({
+ label: d.label || this.getFallbackLabel(i),
+ value: d.deviceId,
+ key: uniqueId('device-option-'),
+ })),
+ });
+ }
+
+ handleSelectChange(event) {
+ const { value } = event.target;
+ const { onChange } = this.props;
+ const { devices } = this.state;
+ const selectedDevice = devices.find((d) => d.deviceId === value);
+ onChange(selectedDevice.deviceId, selectedDevice, event);
+ }
+
+ getFallbackLabel(index) {
+ const { intl, kind } = this.props;
+ const label = kind === 'audioinput' ? intlMessages.fallbackInputLabel : intlMessages.fallbackOutputLabel;
+
+ return intl.formatMessage(label, { 0: index });
+ }
+
+ enumerate() {
+ const { kind } = this.props;
+
+ navigator.mediaDevices
+ .enumerateDevices()
+ .then(this.handleEnumerateDevicesSuccess.bind(this))
+ .catch(() => {
+ logger.error({
+ logCode: 'audiodeviceselector_component_enumeratedevices_error',
+ extraInfo: {
+ deviceKind: kind,
+ },
+ }, 'Error on enumerateDevices(): ');
+ });
+ }
+
+ render() {
+ const {
+ intl, kind, blocked, deviceId,
+ } = this.props;
+
+ const { options } = this.state;
+
+ let notFoundOption;
+
+ if (blocked) {
+ notFoundOption = {intl.formatMessage(intlMessages.findingDevicesLabel)} ;
+ } else if (kind === 'audiooutput' && !('setSinkId' in HTMLMediaElement.prototype)) {
+ const defaultOutputDeviceLabel = intl.formatMessage(intlMessages.defaultOutputDeviceLabel);
+ notFoundOption = {defaultOutputDeviceLabel} ;
+ } else {
+ const noDeviceFoundLabel = intl.formatMessage(intlMessages.noDeviceFoundLabel);
+ notFoundOption = {noDeviceFoundLabel} ;
+ }
+
+ return (
+
+ {
+ options.length
+ ? options.map((option) => (
+
+ {option.label}
+
+ ))
+ : notFoundOption
+ }
+
+ );
+ }
+}
+
+DeviceSelector.propTypes = propTypes;
+DeviceSelector.defaultProps = defaultProps;
+
+export default DeviceSelector;
diff --git a/src/2.7.12/imports/ui/components/audio/device-selector/styles.js b/src/2.7.12/imports/ui/components/audio/device-selector/styles.js
new file mode 100644
index 00000000..69932a1c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/device-selector/styles.js
@@ -0,0 +1,37 @@
+import styled from 'styled-components';
+import {
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorGrayLabel,
+ colorWhite,
+ colorGrayLighter,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const Select = styled.select`
+ background-color: ${colorWhite};
+ border: 0.1rem solid ${colorGrayLighter};
+ border-radius: ${borderSize};
+ color: ${colorGrayLabel};
+ width: 100%;
+ height: 2rem;
+ padding: 1px;
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+`;
+
+export default {
+ Select,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/echo-test/component.jsx b/src/2.7.12/imports/ui/components/audio/echo-test/component.jsx
new file mode 100644
index 00000000..f841d0f7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/echo-test/component.jsx
@@ -0,0 +1,89 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Session } from 'meteor/session';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ confirmLabel: {
+ id: 'app.audioModal.yes',
+ description: 'Hear yourself yes',
+ },
+ disconfirmLabel: {
+ id: 'app.audioModal.no',
+ description: 'Hear yourself no',
+ },
+ confirmAriaLabel: {
+ id: 'app.audioModal.yes.arialabel',
+ description: 'provides better context for yes btn label',
+ },
+ disconfirmAriaLabel: {
+ id: 'app.audioModal.no.arialabel',
+ description: 'provides better context for no btn label',
+ },
+});
+
+const propTypes = {
+ handleYes: PropTypes.func.isRequired,
+ handleNo: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+class EchoTest extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ disabled: false,
+ };
+ this.handleYes = props.handleYes.bind(this);
+ this.handleNo = props.handleNo.bind(this);
+ }
+
+ componentDidMount() {
+ Session.set('inEchoTest', true);
+ }
+
+ componentWillUnmount() {
+ Session.set('inEchoTest', false);
+ }
+
+ render() {
+ const {
+ intl,
+ } = this.props;
+ const { disabled } = this.state;
+ const disableYesButtonClicked = (callback) => () => {
+ this.setState({ disabled: true }, callback);
+ };
+ return (
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(EchoTest);
+
+EchoTest.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/audio/echo-test/styles.js b/src/2.7.12/imports/ui/components/audio/echo-test/styles.js
new file mode 100644
index 00000000..e19c4788
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/echo-test/styles.js
@@ -0,0 +1,41 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const EchoTest = styled.span`
+ margin-top: auto;
+ margin-bottom: auto;
+`;
+
+const EchoTestButton = styled(Button)`
+ &:focus {
+ outline: none !important;
+ }
+
+ &:first-child {
+ margin: 0 3rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 3rem;
+ }
+
+ @media ${smallOnly} {
+ margin: 0 1rem 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1rem;
+ }
+ }
+ }
+
+ span:last-child {
+ color: black;
+ font-size: 1rem;
+ font-weight: 600;
+ }
+`;
+
+export default {
+ EchoTest,
+ EchoTestButton,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/help/component.jsx b/src/2.7.12/imports/ui/components/audio/help/component.jsx
new file mode 100644
index 00000000..239f2fab
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/help/component.jsx
@@ -0,0 +1,192 @@
+import React, { Component } from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isListenOnly: PropTypes.bool.isRequired,
+ audioErr: PropTypes.shape({
+ code: PropTypes.number,
+ message: PropTypes.string,
+ MIC_ERROR: PropTypes.shape({
+ NO_SSL: PropTypes.number,
+ MAC_OS_BLOCK: PropTypes.number,
+ NO_PERMISSION: PropTypes.number,
+ }),
+ }).isRequired,
+ handleBack: PropTypes.func.isRequired,
+ troubleshootingLink: PropTypes.string,
+};
+
+const defaultProps = {
+ troubleshootingLink: '',
+};
+
+const intlMessages = defineMessages({
+ helpSubtitleMic: {
+ id: 'app.audioModal.helpSubtitleMic',
+ description: 'Text description for the audio help subtitle (microphones)',
+ },
+ helpSubtitleGeneric: {
+ id: 'app.audioModal.helpSubtitleGeneric',
+ description: 'Text description for the audio help subtitle (generic)',
+ },
+ helpPermissionStep1: {
+ id: 'app.audioModal.helpPermissionStep1',
+ description: 'Text description for the audio permission help step 1',
+ },
+ helpPermissionStep2: {
+ id: 'app.audioModal.helpPermissionStep2',
+ description: 'Text description for the audio permission help step 2',
+ },
+ helpPermissionStep3: {
+ id: 'app.audioModal.helpPermissionStep3',
+ description: 'Text description for the audio permission help step 3',
+ },
+ retryLabel: {
+ id: 'app.audio.audioSettings.retryLabel',
+ description: 'audio settings retry button label',
+ },
+ noSSL: {
+ id: 'app.audioModal.help.noSSL',
+ description: 'Text description for domain not using https',
+ },
+ macNotAllowed: {
+ id: 'app.audioModal.help.macNotAllowed',
+ description: 'Text description for mac needed to enable OS setting',
+ },
+ helpTroubleshoot: {
+ id: 'app.audioModal.help.troubleshoot',
+ description: 'Text description for help troubleshoot',
+ },
+ unknownError: {
+ id: 'app.audioModal.help.unknownError',
+ description: 'Text description for unknown error',
+ },
+ errorCode: {
+ id: 'app.audioModal.help.errorCode',
+ description: 'Text description for error code',
+ },
+});
+
+class Help extends Component {
+ getSubtitle() {
+ const { intl, isListenOnly } = this.props;
+
+ return !isListenOnly
+ ? intl.formatMessage(intlMessages.helpSubtitleMic)
+ : intl.formatMessage(intlMessages.helpSubtitleGeneric);
+ }
+
+ renderNoSSL() {
+ const { intl } = this.props;
+
+ return (
+
+ {intl.formatMessage(intlMessages.noSSL)}
+
+ );
+ }
+
+ renderMacNotAllowed() {
+ const { intl } = this.props;
+
+ return (
+
+ {intl.formatMessage(intlMessages.macNotAllowed)}
+
+ );
+ }
+
+ renderPermissionHelp() {
+ const { intl } = this.props;
+ return (
+ <>
+
+ {this.getSubtitle()}
+
+
+ {intl.formatMessage(intlMessages.helpPermissionStep1)}
+ {intl.formatMessage(intlMessages.helpPermissionStep2)}
+ {intl.formatMessage(intlMessages.helpPermissionStep3)}
+
+ >
+ );
+ }
+
+ renderGenericErrorHelp() {
+ const { intl, audioErr } = this.props;
+ const { code, message } = audioErr;
+
+ return (
+ <>
+
+ {this.getSubtitle()}
+
+
+ {intl.formatMessage(intlMessages.unknownError)}
+
+
+ {intl.formatMessage(intlMessages.errorCode, { 0: code, 1: message || 'UnknownError' })}
+
+ >
+ );
+ }
+
+ renderHelpMessage() {
+ const { audioErr } = this.props;
+ const { MIC_ERROR } = audioErr;
+
+ switch (audioErr.code) {
+ case MIC_ERROR.NO_SSL:
+ return this.renderNoSSL();
+ case MIC_ERROR.MAC_OS_BLOCK:
+ return this.renderMacNotAllowed();
+ case MIC_ERROR.NO_PERMISSION:
+ return this.renderPermissionHelp();
+ default:
+ return this.renderGenericErrorHelp();
+ }
+ }
+
+ render() {
+ const {
+ intl,
+ handleBack,
+ troubleshootingLink,
+ } = this.props;
+
+ return (
+
+ {this.renderHelpMessage()}
+ { troubleshootingLink && (
+
+
+ {intl.formatMessage(intlMessages.helpTroubleshoot)}
+
+
+ )}
+
+
+
+
+ );
+ }
+}
+
+Help.propTypes = propTypes;
+Help.defaultProps = defaultProps;
+
+export default injectIntl(Help);
diff --git a/src/2.7.12/imports/ui/components/audio/help/styles.js b/src/2.7.12/imports/ui/components/audio/help/styles.js
new file mode 100644
index 00000000..33c560dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/help/styles.js
@@ -0,0 +1,79 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { jumboPaddingY, smPaddingY } from '/imports/ui/stylesheets/styled-components/general';
+import {
+ fontSizeSmaller,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorLink,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const Help = styled.span`
+ display: flex;
+ flex-flow: column;
+ min-height: 10rem;
+`;
+
+const Text = styled.div`
+ text-align: center;
+ justify-content: center;
+ margin-top: auto;
+ margin-bottom: auto;
+`;
+
+const EnterAudio = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ margin-top: ${jumboPaddingY};
+`;
+
+const RetryButton = styled(Button)`
+ margin-right: 0.5rem;
+ margin-left: inherit;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 0.5rem;
+ }
+
+ @media ${smallOnly} {
+ margin-right: none;
+ margin-left: inherit;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: auto;
+ }
+ }
+`;
+
+const TroubleshootLink = styled.a`
+ color: ${colorLink};
+`;
+
+const UnknownError = styled.label`
+ font-size: ${fontSizeSmaller};
+ justify-content: center;
+ text-align: center;
+ margin-top: ${smPaddingY};
+ margin-bottom: ${smPaddingY};
+`;
+
+const PermissionHelpSteps = styled.ul`
+ text-align: left;
+ justify-content: center;
+ li {
+ margin-bottom: ${smPaddingY};
+ }
+`;
+
+export default {
+ Help,
+ Text,
+ EnterAudio,
+ RetryButton,
+ TroubleshootLink,
+ UnknownError,
+ PermissionHelpSteps,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/local-echo/component.jsx b/src/2.7.12/imports/ui/components/audio/local-echo/component.jsx
new file mode 100644
index 00000000..af3fa05f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/local-echo/component.jsx
@@ -0,0 +1,87 @@
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import Settings from '/imports/ui/services/settings';
+import Service from '/imports/ui/components/audio/local-echo/service';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ stream: PropTypes.shape({
+ active: PropTypes.bool,
+ id: PropTypes.string,
+ }),
+ initialHearingState: PropTypes.bool,
+};
+
+const defaultProps = {
+ stream: null,
+ initialHearingState: false,
+};
+
+const intlMessages = defineMessages({
+ stopAudioFeedbackLabel: {
+ id: 'app.audio.stopAudioFeedback',
+ description: 'Stop audio feedback button label',
+ },
+ testSpeakerLabel: {
+ id: 'app.audio.audioSettings.testSpeakerLabel',
+ description: 'Label for the speaker test button',
+ },
+});
+
+const LocalEcho = ({
+ intl,
+ stream,
+ initialHearingState,
+}) => {
+ const loopbackAgent = useRef(null);
+ const [hearing, setHearing] = useState(initialHearingState);
+ const { animations } = Settings.application;
+ const icon = hearing ? 'mute' : 'unmute';
+ const label = hearing ? intlMessages.stopAudioFeedbackLabel : intlMessages.testSpeakerLabel;
+
+ const applyHearingState = (_stream) => {
+ if (hearing) {
+ Service.playEchoStream(_stream, loopbackAgent.current);
+ } else {
+ Service.deattachEchoStream();
+ }
+ };
+
+ const cleanup = () => {
+ if (loopbackAgent.current) loopbackAgent.current.stop();
+ Service.deattachEchoStream();
+ };
+
+ useEffect(() => {
+ if (Service.useRTCLoopback()) {
+ loopbackAgent.current = Service.createAudioRTCLoopback();
+ }
+ return cleanup;
+ }, []);
+
+ useEffect(() => {
+ applyHearingState(stream);
+ }, [stream, hearing]);
+
+ return (
+ setHearing(!hearing)}
+ animations={animations}
+ />
+ );
+};
+
+LocalEcho.propTypes = propTypes;
+LocalEcho.defaultProps = defaultProps;
+
+export default injectIntl(React.memo(LocalEcho));
diff --git a/src/2.7.12/imports/ui/components/audio/local-echo/container.jsx b/src/2.7.12/imports/ui/components/audio/local-echo/container.jsx
new file mode 100644
index 00000000..b5af4c09
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/local-echo/container.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/audio/service';
+import LocalEcho from '/imports/ui/components/audio/local-echo/component';
+
+const LocalEchoContainer = (props) => ;
+
+export default withTracker(() => ({
+ initialHearingState: Service.localEchoInitHearingState,
+}))(LocalEchoContainer);
diff --git a/src/2.7.12/imports/ui/components/audio/local-echo/service.js b/src/2.7.12/imports/ui/components/audio/local-echo/service.js
new file mode 100644
index 00000000..229e684f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/local-echo/service.js
@@ -0,0 +1,121 @@
+import LocalPCLoopback from '/imports/ui/services/webrtc-base/local-pc-loopback';
+import browserInfo from '/imports/utils/browserInfo';
+
+const MEDIA_TAG = Meteor.settings.public.media.mediaTag;
+const USE_RTC_LOOPBACK_CHR = Meteor.settings.public.media.localEchoTest.useRtcLoopbackInChromium;
+const {
+ enabled: DELAY_ENABLED = true,
+ delayTime = 0.5,
+ maxDelayTime = 2,
+} = Meteor.settings.public.media.localEchoTest.delay;
+
+let audioContext = null;
+let sourceContext = null;
+let contextDestination = null;
+let stubAudioElement = null;
+let delayNode = null;
+
+const useRTCLoopback = () => (browserInfo.isChrome || browserInfo.isEdge) && USE_RTC_LOOPBACK_CHR;
+const createAudioRTCLoopback = () => new LocalPCLoopback({ audio: true });
+
+const cleanupDelayNode = () => {
+ if (delayNode) {
+ delayNode.disconnect();
+ delayNode = null;
+ }
+
+ if (sourceContext) {
+ sourceContext.disconnect();
+ sourceContext = null;
+ }
+
+ if (audioContext) {
+ audioContext.close();
+ audioContext = null;
+ }
+
+ if (contextDestination) {
+ contextDestination.disconnect();
+ contextDestination = null;
+ }
+
+ if (stubAudioElement) {
+ stubAudioElement.pause();
+ stubAudioElement.srcObject = null;
+ stubAudioElement = null;
+ }
+};
+
+const addDelayNode = (stream) => {
+ if (stream) {
+ if (delayNode || audioContext || sourceContext) cleanupDelayNode();
+ const audioElement = document.querySelector(MEDIA_TAG);
+ // Workaround: attach the stream to a muted stub audio element to be able to play it in
+ // Chromium-based browsers. See https://bugs.chromium.org/p/chromium/issues/detail?id=933677
+ stubAudioElement = new Audio();
+ stubAudioElement.muted = true;
+ stubAudioElement.srcObject = stream;
+
+ // Create a new AudioContext to be able to add a delay to the stream
+ audioContext = new AudioContext();
+ sourceContext = audioContext.createMediaStreamSource(stream);
+ contextDestination = audioContext.createMediaStreamDestination();
+ // Create a DelayNode to add a delay to the stream
+ delayNode = new DelayNode(audioContext, { delayTime, maxDelayTime });
+ // Connect the stream to the DelayNode and then to the MediaStreamDestinationNode
+ // to be able to play the stream.
+ sourceContext.connect(delayNode);
+ delayNode.connect(contextDestination);
+ delayNode.delayTime.setValueAtTime(delayTime, audioContext.currentTime);
+ // Play the stream with the delay in the default audio element (remote-media)
+ audioElement.srcObject = contextDestination.stream;
+ }
+};
+
+const deattachEchoStream = () => {
+ const audioElement = document.querySelector(MEDIA_TAG);
+
+ if (DELAY_ENABLED) {
+ audioElement.muted = false;
+ cleanupDelayNode();
+ }
+
+ audioElement.pause();
+ audioElement.srcObject = null;
+};
+
+const playEchoStream = async (stream, loopbackAgent = null) => {
+ if (stream) {
+ deattachEchoStream();
+ let streamToPlay = stream;
+
+ if (loopbackAgent) {
+ // Chromium based browsers need audio to go through PCs for echo cancellation
+ // to work. See https://bugs.chromium.org/p/chromium/issues/detail?id=687574
+ try {
+ await loopbackAgent.start(stream);
+ streamToPlay = loopbackAgent.loopbackStream;
+ } catch (error) {
+ loopbackAgent.stop();
+ }
+ }
+
+ if (DELAY_ENABLED) {
+ addDelayNode(streamToPlay);
+ } else {
+ // No delay: play the stream in the default audio element (remote-media),
+ // no strings attached.
+ const audioElement = document.querySelector(MEDIA_TAG);
+ audioElement.srcObject = streamToPlay;
+ audioElement.muted = false;
+ audioElement.play();
+ }
+ }
+};
+
+export default {
+ useRTCLoopback,
+ createAudioRTCLoopback,
+ deattachEchoStream,
+ playEchoStream,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/local-echo/styles.js b/src/2.7.12/imports/ui/components/audio/local-echo/styles.js
new file mode 100644
index 00000000..52397e02
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/local-echo/styles.js
@@ -0,0 +1,34 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ colorPrimary,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const LocalEchoTestButton = styled(Button)`
+ margin: 0 !important;
+ font-weight: normal;
+ border: none !important;
+
+ &:hover {
+ color: #0c5cb2;
+ }
+
+ i {
+ ${({ animations }) => animations && `
+ transition: all .2s ease-in-out;
+ `}
+ }
+
+ background-color: transparent !important;
+ color: ${colorPrimary} !important;
+
+ ${({ $hearing }) => $hearing && `
+ background-color: ${colorPrimary} !important;
+ color: ${colorWhite} !important;
+ `}
+`;
+
+export default {
+ LocalEchoTestButton,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/permissions-overlay/component.jsx b/src/2.7.12/imports/ui/components/audio/permissions-overlay/component.jsx
new file mode 100644
index 00000000..d921f0ac
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/permissions-overlay/component.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import browserInfo from '/imports/utils/browserInfo';
+import Settings from '/imports/ui/services/settings';
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ closeModal: PropTypes.func.isRequired,
+};
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.audio.permissionsOverlay.title',
+ description: 'Title for the overlay',
+ },
+ hint: {
+ id: 'app.audio.permissionsOverlay.hint',
+ description: 'Hint for the overlay',
+ },
+});
+
+const { isChrome, isFirefox, isSafari } = browserInfo;
+const { animations } = Settings.application;
+
+const PermissionsOverlay = ({ intl, closeModal }) => (
+
+
+ { intl.formatMessage(intlMessages.title) }
+
+ { intl.formatMessage(intlMessages.hint) }
+
+
+
+);
+
+PermissionsOverlay.propTypes = propTypes;
+
+export default injectIntl(PermissionsOverlay);
diff --git a/src/2.7.12/imports/ui/components/audio/permissions-overlay/styles.js b/src/2.7.12/imports/ui/components/audio/permissions-overlay/styles.js
new file mode 100644
index 00000000..9b54599b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/permissions-overlay/styles.js
@@ -0,0 +1,108 @@
+import styled, { css, keyframes } from 'styled-components';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import { colorBlack } from '/imports/ui/stylesheets/styled-components/palette';
+import { jumboPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+
+const bounce = keyframes`
+ 0%,
+ 20%,
+ 50%,
+ 80%,
+ 100% {
+ -ms-transform: translateY(0);
+ transform: translateY(0);
+ }
+ 40% {
+ -ms-transform: translateY(10px);
+ transform: translateY(10px);
+ }
+ 60% {
+ -ms-transform: translateY(5px);
+ transform: translateY(5px);
+ }
+`;
+
+const PermissionsOverlayModal = styled(ModalSimple)`
+ ${({ isFirefox }) => isFirefox && `
+ top: 8em;
+ left: 22em;
+ right: auto;
+
+ [dir="rtl"] & {
+ right: none;
+ left: none;
+ top: 15rem;
+ }
+ `}
+
+ ${({ isChrome }) => isChrome && `
+ top: 5.5em;
+ left: 18em;
+ right: auto;
+
+ [dir="rtl"] & {
+ right: none;
+ left: none;
+ top: 15rem;
+ }
+ `}
+
+ ${({ isSafari }) => isSafari && `
+ top: 150px;
+ left:0;
+ right:0;
+ margin-left: auto;
+ margin-right: auto;
+ `}
+
+ position: absolute;
+ background: none;
+ box-shadow: none;
+ color: #fff;
+ font-size: 16px;
+ font-weight: 400;
+ padding: 0 0 0 ${jumboPaddingX};
+ line-height: 18px;
+ width: 340px;
+
+ [dir="rtl"] & {
+ padding: 0 ${jumboPaddingX} 0 0;
+ }
+
+ small {
+ display: block;
+ font-size: 12px;
+ line-height: 14px;
+ margin-top: 3px;
+ opacity: .6;
+ }
+
+ &:after {
+ top: -65px;
+ left: -20px;
+ right: auto;
+ font-size: 20px;
+ display: block;
+ font-family: 'bbb-icons';
+ content: "\\E906";
+ position: relative;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: -20px;
+ }
+
+ ${({ animations }) => animations && css`
+ animation: ${bounce} 2s infinite;
+ `}
+ }
+`;
+
+const Content = styled.div`
+ color: ${colorBlack};
+`;
+
+export default {
+ PermissionsOverlayModal,
+ Content,
+};
diff --git a/src/2.7.12/imports/ui/components/audio/service.js b/src/2.7.12/imports/ui/components/audio/service.js
new file mode 100644
index 00000000..559b9151
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/audio/service.js
@@ -0,0 +1,174 @@
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import { throttle } from '/imports/utils/throttle';
+import { debounce } from '/imports/utils/debounce';
+import AudioManager from '/imports/ui/services/audio-manager';
+import Meetings from '/imports/api/meetings';
+import { makeCall } from '/imports/ui/services/api';
+import VoiceUsers from '/imports/api/voice-users';
+import logger from '/imports/startup/client/logger';
+import Storage from '../../services/storage/session';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const TOGGLE_MUTE_THROTTLE_TIME = Meteor.settings.public.media.toggleMuteThrottleTime;
+const SHOW_VOLUME_METER = Meteor.settings.public.media.showVolumeMeter;
+const {
+ enabled: LOCAL_ECHO_TEST_ENABLED,
+ initialHearingState: LOCAL_ECHO_INIT_HEARING_STATE,
+} = Meteor.settings.public.media.localEchoTest;
+
+const MUTED_KEY = 'muted';
+
+const recoverMicState = () => {
+ const muted = Storage.getItem(MUTED_KEY);
+
+ if ((muted === undefined) || (muted === null)) {
+ return;
+ }
+
+ logger.debug({
+ logCode: 'audio_recover_mic_state',
+ }, `Audio recover previous mic state: muted = ${muted}`);
+ makeCall('toggleVoice', null, muted);
+};
+
+const audioEventHandler = (event) => {
+ if (!event) {
+ return;
+ }
+
+ switch (event.name) {
+ case 'started':
+ if (!event.isListenOnly) recoverMicState();
+ break;
+ default:
+ break;
+ }
+};
+
+const init = (messages, intl) => {
+ AudioManager.setAudioMessages(messages, intl);
+ if (AudioManager.initialized) return Promise.resolve(false);
+ const meetingId = Auth.meetingID;
+ const userId = Auth.userID;
+ const { sessionToken } = Auth;
+ const User = Users.findOne({ userId }, { fields: { name: 1 } });
+ const username = User.name;
+ const Meeting = Meetings.findOne({ meetingId: Auth.meetingID }, { fields: { 'voiceProp.voiceConf': 1 } });
+ const voiceBridge = Meeting.voiceProp.voiceConf;
+
+ // FIX ME
+ const microphoneLockEnforced = false;
+
+ const userData = {
+ meetingId,
+ userId,
+ sessionToken,
+ username,
+ voiceBridge,
+ microphoneLockEnforced,
+ };
+
+ return AudioManager.init(userData, audioEventHandler);
+};
+
+const muteMicrophone = () => {
+ const user = VoiceUsers.findOne({
+ meetingId: Auth.meetingID, intId: Auth.userID,
+ }, { fields: { muted: 1 } });
+
+ if (!user.muted) {
+ logger.info({
+ logCode: 'audiomanager_mute_audio',
+ extraInfo: { logType: 'user_action' },
+ }, 'User wants to leave conference. Microphone muted');
+ AudioManager.setSenderTrackEnabled(false);
+ makeCall('toggleVoice');
+ }
+};
+
+const isVoiceUser = () => {
+ const voiceUser = VoiceUsers.findOne({ intId: Auth.userID },
+ { fields: { joined: 1 } });
+ return voiceUser ? voiceUser.joined : false;
+};
+
+const toggleMuteMicrophone = throttle(() => {
+ const user = VoiceUsers.findOne({
+ meetingId: Auth.meetingID, intId: Auth.userID,
+ }, { fields: { muted: 1 } });
+
+ Storage.setItem(MUTED_KEY, !user.muted);
+
+ if (user.muted) {
+ logger.info({
+ logCode: 'audiomanager_unmute_audio',
+ extraInfo: { logType: 'user_action' },
+ }, 'microphone unmuted by user');
+ makeCall('toggleVoice');
+ } else {
+ logger.info({
+ logCode: 'audiomanager_mute_audio',
+ extraInfo: { logType: 'user_action' },
+ }, 'microphone muted by user');
+ makeCall('toggleVoice');
+ }
+}, TOGGLE_MUTE_THROTTLE_TIME);
+
+export default {
+ init,
+ exitAudio: () => AudioManager.exitAudio(),
+ forceExitAudio: () => AudioManager.forceExitAudio(),
+ transferCall: () => AudioManager.transferCall(),
+ joinListenOnly: () => AudioManager.joinListenOnly(),
+ joinMicrophone: () => AudioManager.joinMicrophone(),
+ joinEchoTest: () => AudioManager.joinEchoTest(),
+ toggleMuteMicrophone: debounce(toggleMuteMicrophone, 500, { leading: true, trailing: false }),
+ changeInputDevice: (inputDeviceId) => AudioManager.changeInputDevice(inputDeviceId),
+ changeInputStream: (newInputStream) => { AudioManager.inputStream = newInputStream; },
+ liveChangeInputDevice: (inputDeviceId) => AudioManager.liveChangeInputDevice(inputDeviceId),
+ changeOutputDevice: (outputDeviceId, isLive) => AudioManager.changeOutputDevice(outputDeviceId, isLive),
+ isConnectedToBreakout: () => {
+ const transferStatus = AudioManager.getBreakoutAudioTransferStatus();
+ if (transferStatus.status
+ === AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.CONNECTED) return true;
+ return false;
+ },
+ isConnected: () => {
+ const transferStatus = AudioManager.getBreakoutAudioTransferStatus();
+ if (!!transferStatus.breakoutMeetingId
+ && transferStatus.breakoutMeetingId !== Auth.meetingID) return false;
+ return AudioManager.isConnected;
+ },
+ isTalking: () => AudioManager.isTalking,
+ isHangingUp: () => AudioManager.isHangingUp,
+ isUsingAudio: () => AudioManager.isUsingAudio(),
+ isWaitingPermissions: () => AudioManager.isWaitingPermissions,
+ isMuted: () => AudioManager.isMuted,
+ isConnecting: () => AudioManager.isConnecting,
+ isListenOnly: () => AudioManager.isListenOnly,
+ inputDeviceId: () => AudioManager.inputDeviceId,
+ outputDeviceId: () => AudioManager.outputDeviceId,
+ isEchoTest: () => AudioManager.isEchoTest,
+ error: () => AudioManager.error,
+ isUserModerator: () => Users.findOne({ userId: Auth.userID },
+ { fields: { role: 1 } })?.role === ROLE_MODERATOR,
+ isVoiceUser,
+ autoplayBlocked: () => AudioManager.autoplayBlocked,
+ handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(),
+ playAlertSound: (url) => AudioManager.playAlertSound(url),
+ updateAudioConstraints:
+ (constraints) => AudioManager.updateAudioConstraints(constraints),
+ recoverMicState,
+ muteMicrophone: () => muteMicrophone(),
+ isReconnecting: () => AudioManager.isReconnecting,
+ setBreakoutAudioTransferStatus: (status) => AudioManager
+ .setBreakoutAudioTransferStatus(status),
+ getBreakoutAudioTransferStatus: () => AudioManager
+ .getBreakoutAudioTransferStatus(),
+ getStats: () => AudioManager.getStats(),
+ localEchoEnabled: LOCAL_ECHO_TEST_ENABLED,
+ localEchoInitHearingState: LOCAL_ECHO_INIT_HEARING_STATE,
+ showVolumeMeter: SHOW_VOLUME_METER,
+ notify: (message, error, icon) => { AudioManager.notify(message, error, icon); },
+};
diff --git a/src/2.7.12/imports/ui/components/authenticated-handler/component.jsx b/src/2.7.12/imports/ui/components/authenticated-handler/component.jsx
new file mode 100644
index 00000000..deed223a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/authenticated-handler/component.jsx
@@ -0,0 +1,100 @@
+import React, { Component } from 'react';
+import { Session } from 'meteor/session';
+import logger from '/imports/startup/client/logger';
+import Auth from '/imports/ui/services/auth';
+import LoadingScreen from '/imports/ui/components/common/loading-screen/component';
+
+const STATUS_CONNECTING = 'connecting';
+
+class AuthenticatedHandler extends Component {
+ static setError({ description, error }) {
+ if (error) Session.set('codeError', error);
+ Session.set('errorMessageDescription', description);
+ }
+
+ static shouldAuthenticate(status, lastStatus) {
+ return lastStatus != null && lastStatus === STATUS_CONNECTING && status.connected;
+ }
+
+ static updateStatus(status, lastStatus) {
+ return lastStatus !== STATUS_CONNECTING ? status.status : lastStatus;
+ }
+
+ static addReconnectObservable() {
+ let lastStatus = null;
+
+ Tracker.autorun(() => {
+ lastStatus = AuthenticatedHandler.updateStatus(Meteor.status(), lastStatus);
+
+ if (AuthenticatedHandler.shouldAuthenticate(Meteor.status(), lastStatus)) {
+ Session.set('userWillAuth', true);
+ Auth.authenticate(true);
+ lastStatus = Meteor.status().status;
+ }
+ });
+ }
+
+ static async authenticatedRouteHandler(callback) {
+ if (Auth.loggedIn) {
+ callback();
+ }
+
+ AuthenticatedHandler.addReconnectObservable();
+
+ const setReason = (reason) => {
+ const log = reason.error === 403 ? 'warn' : 'error';
+
+ logger[log]({
+ logCode: 'authenticatedhandlercomponent_setreason',
+ extraInfo: { reason },
+ }, 'Encountered error while trying to authenticate');
+
+ AuthenticatedHandler.setError(reason);
+ callback();
+ };
+
+ try {
+ const getAuthenticate = await Auth.authenticate();
+ callback(getAuthenticate);
+ } catch (error) {
+ setReason(error);
+ }
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ authenticated: false,
+ };
+ }
+
+ componentDidMount() {
+ if (Session.get('codeError')) {
+ this.setState({ authenticated: true });
+ }
+ AuthenticatedHandler.authenticatedRouteHandler((value, error) => {
+ if (error) AuthenticatedHandler.setError(error);
+ this.setState({ authenticated: true });
+ });
+ }
+
+ render() {
+ const {
+ children,
+ } = this.props;
+ const {
+ authenticated,
+ } = this.state;
+
+ Session.set('isMeetingEnded', false);
+ Session.set('isPollOpen', false);
+ // TODO: breakoutRoomIsOpen doesn't seem used
+ Session.set('breakoutRoomIsOpen', false);
+
+ return authenticated
+ ? children
+ : ( );
+ }
+}
+
+export default AuthenticatedHandler;
diff --git a/src/2.7.12/imports/ui/components/banner-bar/component.jsx b/src/2.7.12/imports/ui/components/banner-bar/component.jsx
new file mode 100644
index 00000000..bd3d6e4f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/banner-bar/component.jsx
@@ -0,0 +1,37 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+import NotificationsBar from '/imports/ui/components/notifications-bar/component';
+import Styled from './styles';
+import { ACTIONS } from '../layout/enums';
+
+const BannerBar = ({
+ text, color, hasBanner: propsHasBanner, layoutContextDispatch,
+}) => {
+ useEffect(() => {
+ const localHasBanner = !!text;
+
+ if (localHasBanner !== propsHasBanner) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_HAS_BANNER_BAR,
+ value: localHasBanner,
+ });
+ }
+ }, [text, propsHasBanner]);
+
+ if (!text) return null;
+
+ return (
+
+
+ {text}
+
+
+ );
+};
+
+BannerBar.propTypes = {
+ text: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+};
+
+export default BannerBar;
diff --git a/src/2.7.12/imports/ui/components/banner-bar/container.jsx b/src/2.7.12/imports/ui/components/banner-bar/container.jsx
new file mode 100644
index 00000000..c9c9754b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/banner-bar/container.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { Session } from 'meteor/session';
+import { withTracker } from 'meteor/react-meteor-data';
+import BannerComponent from './component';
+import { layoutSelectInput, layoutDispatch } from '../layout/context';
+
+const BannerContainer = (props) => {
+ const bannerBar = layoutSelectInput((i) => i.bannerBar);
+ const { hasBanner } = bannerBar;
+ const layoutContextDispatch = layoutDispatch();
+
+ return ;
+};
+
+export default withTracker(() => ({
+ color: Session.get('bannerColor') || '#0F70D7',
+ text: Session.get('bannerText') || '',
+}))(BannerContainer);
diff --git a/src/2.7.12/imports/ui/components/banner-bar/styles.js b/src/2.7.12/imports/ui/components/banner-bar/styles.js
new file mode 100644
index 00000000..ff796628
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/banner-bar/styles.js
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+import { TextElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+
+const BannerTextColor = styled(TextElipsis)`
+ color: ${colorWhite};
+`;
+
+export default {
+ BannerTextColor,
+};
diff --git a/src/2.7.12/imports/ui/components/breakout-join-confirmation/component.jsx b/src/2.7.12/imports/ui/components/breakout-join-confirmation/component.jsx
new file mode 100644
index 00000000..2e94ab05
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-join-confirmation/component.jsx
@@ -0,0 +1,236 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component';
+import logger from '/imports/startup/client/logger';
+import PropTypes from 'prop-types';
+import AudioService from '../audio/service';
+import VideoService from '../video-provider/service';
+import { screenshareHasEnded } from '/imports/ui/components/screenshare/service';
+import Styled from './styles';
+import { Session } from 'meteor/session';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.breakoutJoinConfirmation.title',
+ description: 'Join breakout room title',
+ },
+ message: {
+ id: 'app.breakoutJoinConfirmation.message',
+ description: 'Join breakout confirm message',
+ },
+ freeJoinMessage: {
+ id: 'app.breakoutJoinConfirmation.freeJoinMessage',
+ description: 'Join breakout confirm message',
+ },
+ confirmLabel: {
+ id: 'app.createBreakoutRoom.join',
+ description: 'Join confirmation button label',
+ },
+ confirmDesc: {
+ id: 'app.breakoutJoinConfirmation.confirmDesc',
+ description: 'adds context to confirm option',
+ },
+ dismissLabel: {
+ id: 'app.breakoutJoinConfirmation.dismissLabel',
+ description: 'Cancel button label',
+ },
+ dismissDesc: {
+ id: 'app.breakoutJoinConfirmation.dismissDesc',
+ description: 'adds context to dismiss option',
+ },
+ generatingURL: {
+ id: 'app.createBreakoutRoom.generatingURLMessage',
+ description: 'label for generating breakout room url',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ breakout: PropTypes.objectOf(Object).isRequired,
+ getURL: PropTypes.func.isRequired,
+ breakoutURL: PropTypes.string.isRequired,
+ isFreeJoin: PropTypes.bool.isRequired,
+ voiceUserJoined: PropTypes.bool.isRequired,
+ requestJoinURL: PropTypes.func.isRequired,
+ breakouts: PropTypes.arrayOf(Object).isRequired,
+ breakoutName: PropTypes.string.isRequired,
+};
+
+let interval = null;
+
+class BreakoutJoinConfirmation extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ selectValue: props.breakout.breakoutId,
+ waiting: true,
+ };
+
+ this.handleJoinBreakoutConfirmation = this.handleJoinBreakoutConfirmation.bind(this);
+ this.renderSelectMeeting = this.renderSelectMeeting.bind(this);
+ this.handleSelectChange = this.handleSelectChange.bind(this);
+ }
+
+ componentDidMount() {
+ const {
+ isFreeJoin,
+ } = this.props;
+
+ const {
+ selectValue,
+ } = this.state;
+
+ if (isFreeJoin) {
+ this.fetchJoinURL(selectValue);
+ } else {
+ this.setState({ waiting: false });
+ }
+ }
+
+ componentWillUnmount() {
+ if (interval) clearInterval(interval);
+ }
+
+ handleJoinBreakoutConfirmation() {
+ const {
+ getURL,
+ setIsOpen,
+ breakoutURL,
+ isFreeJoin,
+ voiceUserJoined,
+ requestJoinURL,
+ amIPresenter,
+ } = this.props;
+
+ const { selectValue } = this.state;
+ if (!getURL(selectValue)) {
+ requestJoinURL(selectValue);
+ }
+ const urlFromSelectedRoom = getURL(selectValue);
+ const url = isFreeJoin ? urlFromSelectedRoom : breakoutURL;
+
+ // leave main room's audio, and stops video and screenshare when joining a breakout room
+ if (voiceUserJoined) {
+ AudioService.exitAudio();
+ logger.info({
+ logCode: 'breakoutjoinconfirmation_ended_audio',
+ extraInfo: { logType: 'user_action' },
+ }, 'joining breakout room closed audio in the main room');
+ }
+
+ VideoService.storeDeviceIds();
+ VideoService.exitVideo();
+ if (amIPresenter) screenshareHasEnded();
+ if (url === '') {
+ logger.error({
+ logCode: 'breakoutjoinconfirmation_redirecting_to_url',
+ extraInfo: { breakoutURL, isFreeJoin },
+ }, 'joining breakout room but redirected to about://blank');
+ }
+
+ Session.set('lastBreakoutIdOpened', selectValue);
+ window.open(url);
+ setIsOpen(false);
+ }
+
+ async fetchJoinURL(selectValue) {
+ const {
+ requestJoinURL,
+ getURL,
+ } = this.props;
+
+ this.setState({ selectValue });
+
+ if (!getURL(selectValue)) {
+ requestJoinURL(selectValue);
+
+ this.setState({ waiting: true });
+
+ await new Promise((resolve) => {
+
+ interval = setInterval(() => {
+ const url = getURL(selectValue);
+
+ if (url !== "") {
+ resolve();
+ clearInterval(interval);
+ this.setState({ waiting: false });
+ }
+ }, 1000)
+ })
+ } else {
+ this.setState({ waiting: false });
+ }
+ }
+
+ handleSelectChange(e) {
+ const { value } = e.target;
+
+ this.fetchJoinURL(value);
+ }
+
+ renderSelectMeeting() {
+ const { breakouts, intl } = this.props;
+ const { selectValue, waiting, } = this.state;
+ return (
+
+ {`${intl.formatMessage(intlMessages.freeJoinMessage)}`}
+
+ {
+ breakouts.map(({ name, breakoutId }) => (
+
+ {name}
+
+ ))
+ }
+
+ { waiting ? {intl.formatMessage(intlMessages.generatingURL)} : null}
+
+ );
+ }
+
+ render() {
+ const { intl, breakoutName, isFreeJoin, setIsOpen,
+ isOpen, priority,
+ } = this.props;
+ const { waiting } = this.state;
+
+ return (
+ setIsOpen(false),
+ label: intl.formatMessage(intlMessages.dismissLabel),
+ description: intl.formatMessage(intlMessages.dismissDesc),
+ }}
+ {...{
+ setIsOpen,
+ isOpen,
+ priority,
+ }}
+ >
+ { isFreeJoin ? this.renderSelectMeeting() : `${intl.formatMessage(intlMessages.message)} ${breakoutName}?`}
+
+ );
+ }
+}
+
+export default injectIntl(BreakoutJoinConfirmation);
+
+BreakoutJoinConfirmation.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/breakout-join-confirmation/container.jsx b/src/2.7.12/imports/ui/components/breakout-join-confirmation/container.jsx
new file mode 100644
index 00000000..b1f52a01
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-join-confirmation/container.jsx
@@ -0,0 +1,50 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Breakouts from '/imports/api/breakouts';
+import Auth from '/imports/ui/services/auth';
+import { makeCall } from '/imports/ui/services/api';
+import breakoutService from '/imports/ui/components/breakout-room/service';
+import AudioManager from '/imports/ui/services/audio-manager';
+import BreakoutJoinConfirmationComponent from './component';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+
+const BreakoutJoinConfirmationContrainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
+
+ return
+};
+
+const getURL = (breakoutId) => {
+ const currentUserId = Auth.userID;
+ const breakout = Breakouts.findOne({ breakoutId }, { fields: { [`url_${currentUserId}`]: 1 } });
+ const breakoutUrlData = (breakout && breakout[`url_${currentUserId}`]) ? breakout[`url_${currentUserId}`] : null;
+ if (breakoutUrlData) return breakoutUrlData.redirectToHtml5JoinURL;
+ return '';
+};
+
+const requestJoinURL = (breakoutId) => {
+ makeCall('requestJoinURL', {
+ breakoutId,
+ });
+};
+
+export default withTracker(({ breakout, breakoutName }) => {
+ const isFreeJoin = breakout.freeJoin;
+ const { breakoutId } = breakout;
+ const url = getURL(breakoutId);
+
+ return {
+ isFreeJoin,
+ breakoutName,
+ breakoutURL: url,
+ breakouts: breakoutService.getBreakouts(),
+ requestJoinURL,
+ getURL,
+ voiceUserJoined: AudioManager.isUsingAudio(),
+ };
+})(BreakoutJoinConfirmationContrainer);
diff --git a/src/2.7.12/imports/ui/components/breakout-join-confirmation/styles.js b/src/2.7.12/imports/ui/components/breakout-join-confirmation/styles.js
new file mode 100644
index 00000000..a30b4665
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-join-confirmation/styles.js
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+import { colorWhite, colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
+
+const SelectParent = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const Select = styled.select`
+ background-color: ${colorWhite};
+ width: 50%;
+ margin: 1rem;
+ border-color: ${colorGrayLighter};
+`;
+
+export default {
+ SelectParent,
+ Select,
+};
diff --git a/src/2.7.12/imports/ui/components/breakout-room/breakout-dropdown/component.jsx b/src/2.7.12/imports/ui/components/breakout-room/breakout-dropdown/component.jsx
new file mode 100644
index 00000000..59772cbb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/breakout-dropdown/component.jsx
@@ -0,0 +1,137 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import BBBMenu from "/imports/ui/components/common/menu/component";
+import CreateBreakoutRoomContainer from '/imports/ui/components/actions-bar/create-breakout-room/container';
+import Trigger from "/imports/ui/components/common/control-header/right/component";
+
+const intlMessages = defineMessages({
+ options: {
+ id: 'app.breakout.dropdown.options',
+ description: 'Breakout options label',
+ },
+ manageDuration: {
+ id: 'app.breakout.dropdown.manageDuration',
+ description: 'Manage duration label',
+ },
+ manageUsers: {
+ id: 'app.breakout.dropdown.manageUsers',
+ description: 'Manage users label',
+ },
+ destroy: {
+ id: 'app.breakout.dropdown.destroyAll',
+ description: 'Destroy breakouts label',
+ },
+});
+
+class BreakoutDropdown extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isCreateBreakoutRoomModalOpen: false,
+ };
+ this.setCreateBreakoutRoomModalIsOpen = this.setCreateBreakoutRoomModalIsOpen.bind(this);
+ }
+
+ getAvailableActions() {
+ const {
+ intl,
+ openBreakoutTimeManager,
+ endAllBreakouts,
+ isMeteorConnected,
+ amIModerator,
+ } = this.props;
+
+ this.menuItems = [];
+
+ this.menuItems.push(
+ {
+ key: 'breakoutTimeManager',
+ dataTest: 'openBreakoutTimeManager',
+ label: intl.formatMessage(intlMessages.manageDuration),
+ onClick: () => {
+ openBreakoutTimeManager();
+ }
+ }
+ );
+
+ this.menuItems.push(
+ {
+ key: 'updateBreakoutUsers',
+ dataTest: 'openUpdateBreakoutUsersModal',
+ label: intl.formatMessage(intlMessages.manageUsers),
+ onClick: () => {
+ this.setCreateBreakoutRoomModalIsOpen(true);
+ }
+ }
+ );
+
+ if (amIModerator) {
+ this.menuItems.push(
+ {
+ key: 'endAllBreakouts',
+ dataTest: 'endAllBreakouts',
+ label: intl.formatMessage(intlMessages.destroy),
+ disabled: !isMeteorConnected,
+ onClick: () => {
+ endAllBreakouts();
+ }
+ }
+ );
+ }
+
+ return this.menuItems;
+ }
+
+ setCreateBreakoutRoomModalIsOpen(value) {
+ this.setState({
+ isCreateBreakoutRoomModalOpen: value,
+ });
+ }
+
+ render() {
+ const {
+ intl,
+ isRTL,
+ } = this.props;
+
+ const { isCreateBreakoutRoomModalOpen } = this.state;
+ return (
+ <>
+ null}
+ />
+ }
+ opts={{
+ id: "breakoutroom-dropdown-menu",
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: "true",
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ actions={this.getAvailableActions()}
+ />
+ {isCreateBreakoutRoomModalOpen ? this.setCreateBreakoutRoomModalIsOpen(false),
+ priority: "low",
+ setIsOpen: this.setCreateBreakoutRoomModalIsOpen,
+ isOpen: isCreateBreakoutRoomModalOpen
+ }}
+ /> : null}
+ >
+ );
+ }
+}
+
+export default injectIntl(BreakoutDropdown);
diff --git a/src/2.7.12/imports/ui/components/breakout-room/component.jsx b/src/2.7.12/imports/ui/components/breakout-room/component.jsx
new file mode 100644
index 00000000..15a7e3f0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/component.jsx
@@ -0,0 +1,602 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Session } from 'meteor/session';
+import logger from '/imports/startup/client/logger';
+import Styled from './styles';
+import Service from './service';
+import MeetingRemainingTime from '../notifications-bar/meeting-remaining-time/container';
+import MessageFormContainer from './message-form/container';
+import VideoService from '/imports/ui/components/video-provider/service';
+import { PANELS, ACTIONS } from '../layout/enums';
+import { screenshareHasEnded } from '/imports/ui/components/screenshare/service';
+import AudioManager from '/imports/ui/services/audio-manager';
+import Settings from '/imports/ui/services/settings';
+import BreakoutDropdown from '/imports/ui/components/breakout-room/breakout-dropdown/component';
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import Header from '/imports/ui/components/common/control-header/component';
+
+const intlMessages = defineMessages({
+ breakoutTitle: {
+ id: 'app.createBreakoutRoom.title',
+ description: 'breakout title',
+ },
+ breakoutAriaTitle: {
+ id: 'app.createBreakoutRoom.ariaTitle',
+ description: 'breakout aria title',
+ },
+ breakoutDuration: {
+ id: 'app.createBreakoutRoom.duration',
+ description: 'breakout duration time',
+ },
+ breakoutRoom: {
+ id: 'app.createBreakoutRoom.room',
+ description: 'breakout room',
+ },
+ breakoutJoin: {
+ id: 'app.createBreakoutRoom.join',
+ description: 'label for join breakout room',
+ },
+ breakoutJoinAudio: {
+ id: 'app.createBreakoutRoom.joinAudio',
+ description: 'label for option to transfer audio',
+ },
+ breakoutReturnAudio: {
+ id: 'app.createBreakoutRoom.returnAudio',
+ description: 'label for option to return audio',
+ },
+ askToJoin: {
+ id: 'app.createBreakoutRoom.askToJoin',
+ description: 'label for generate breakout room url',
+ },
+ generatingURL: {
+ id: 'app.createBreakoutRoom.generatingURL',
+ description: 'label for generating breakout room url',
+ },
+ endAllBreakouts: {
+ id: 'app.createBreakoutRoom.endAllBreakouts',
+ description: 'Button label to end all breakout rooms',
+ },
+ chatTitleMsgAllRooms: {
+ id: 'app.createBreakoutRoom.chatTitleMsgAllRooms',
+ description: 'chat title for send message to all rooms',
+ },
+ alreadyConnected: {
+ id: 'app.createBreakoutRoom.alreadyConnected',
+ description: 'label for the user that is already connected to breakout room',
+ },
+ setTimeInMinutes: {
+ id: 'app.createBreakoutRoom.setTimeInMinutes',
+ description: 'Label for input to set time (minutes)',
+ },
+ setTimeLabel: {
+ id: 'app.createBreakoutRoom.setTimeLabel',
+ description: 'Button label to set breakout rooms time',
+ },
+ setTimeCancel: {
+ id: 'app.createBreakoutRoom.setTimeCancel',
+ description: 'Button label to cancel set breakout rooms time',
+ },
+ setTimeHigherThanMeetingTimeError: {
+ id: 'app.createBreakoutRoom.setTimeHigherThanMeetingTimeError',
+ description: 'Label for error when new breakout rooms time would be higher than remaining time in parent meeting',
+ },
+});
+
+let prevBreakoutData = {};
+
+class BreakoutRoom extends PureComponent {
+ static sortById(a, b) {
+ if (a.userId > b.userId) {
+ return 1;
+ }
+
+ if (a.userId < b.userId) {
+ return -1;
+ }
+
+ return 0;
+ }
+
+ constructor(props) {
+ super(props);
+ this.renderBreakoutRooms = this.renderBreakoutRooms.bind(this);
+ this.getBreakoutURL = this.getBreakoutURL.bind(this);
+ this.hasBreakoutUrl = this.hasBreakoutUrl.bind(this);
+ this.getBreakoutLabel = this.getBreakoutLabel.bind(this);
+ this.renderDuration = this.renderDuration.bind(this);
+ this.transferUserToBreakoutRoom = this.transferUserToBreakoutRoom.bind(this);
+ this.changeSetTime = this.changeSetTime.bind(this);
+ this.showSetTimeForm = this.showSetTimeForm.bind(this);
+ this.resetSetTimeForm = this.resetSetTimeForm.bind(this);
+ this.renderUserActions = this.renderUserActions.bind(this);
+ this.returnBackToMeeeting = this.returnBackToMeeeting.bind(this);
+ this.closePanel = this.closePanel.bind(this);
+ this.handleClickOutsideDurationContainer = this.handleClickOutsideDurationContainer.bind(this);
+ this.state = {
+ requestedBreakoutId: '',
+ waiting: false,
+ generated: false,
+ joinedAudioOnly: false,
+ breakoutId: '',
+ visibleSetTimeForm: false,
+ visibleSetTimeHigherThanMeetingTimeError: false,
+ newTime: 15,
+ };
+ }
+
+ componentDidMount() {
+ if (this.panel) this.panel.firstChild.focus();
+ }
+
+ componentDidUpdate() {
+ const {
+ getBreakoutRoomUrl,
+ setBreakoutAudioTransferStatus,
+ isMicrophoneUser,
+ isReconnecting,
+ breakoutRooms,
+ } = this.props;
+
+ const {
+ waiting,
+ requestedBreakoutId,
+ joinedAudioOnly,
+ generated,
+ } = this.state;
+
+ if (breakoutRooms.length === 0) {
+ return this.closePanel();
+ }
+
+ if (waiting && !generated) {
+ const breakoutUrlData = getBreakoutRoomUrl(requestedBreakoutId);
+
+ if (!breakoutUrlData) return false;
+ if (breakoutUrlData.redirectToHtml5JoinURL !== ''
+ && breakoutUrlData.redirectToHtml5JoinURL !== prevBreakoutData.redirectToHtml5JoinURL) {
+ prevBreakoutData = breakoutUrlData;
+
+ Session.set('lastBreakoutIdOpened', requestedBreakoutId);
+ window.open(breakoutUrlData.redirectToHtml5JoinURL, '_blank');
+ setTimeout(() => {
+ this.setState({ generated: true, waiting: false });
+ }, 1000);
+ }
+ }
+
+ if (joinedAudioOnly && (!isMicrophoneUser || isReconnecting)) {
+ this.clearJoinedAudioOnly();
+ setBreakoutAudioTransferStatus({
+ breakoutMeetingId: '',
+ status: AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ });
+ }
+ return true;
+ }
+
+ getBreakoutURL(breakoutId) {
+ const { requestJoinURL, getBreakoutRoomUrl } = this.props;
+ const { waiting } = this.state;
+ const breakoutRoomUrlData = getBreakoutRoomUrl(breakoutId);
+ if (!breakoutRoomUrlData && !waiting) {
+ this.setState(
+ {
+ waiting: true,
+ generated: false,
+ requestedBreakoutId: breakoutId,
+ },
+ () => requestJoinURL(breakoutId),
+ );
+ }
+
+ if (breakoutRoomUrlData) {
+
+ Session.set('lastBreakoutIdOpened', breakoutId);
+ window.open(breakoutRoomUrlData.redirectToHtml5JoinURL, '_blank');
+ this.setState({ waiting: false, generated: false });
+ }
+ return null;
+ }
+
+ hasBreakoutUrl(breakoutId) {
+ const { getBreakoutRoomUrl } = this.props;
+ const { requestedBreakoutId, generated } = this.state;
+
+ const breakoutRoomUrlData = getBreakoutRoomUrl(breakoutId);
+
+ if ((generated && requestedBreakoutId === breakoutId) || breakoutRoomUrlData) {
+ return true;
+ }
+
+ return false;
+ }
+
+ getBreakoutLabel(breakoutId) {
+ const { intl } = this.props;
+ const hasBreakoutUrl = this.hasBreakoutUrl(breakoutId)
+
+ if (hasBreakoutUrl) {
+ return intl.formatMessage(intlMessages.breakoutJoin);
+ }
+
+ return intl.formatMessage(intlMessages.askToJoin);
+ }
+
+ clearJoinedAudioOnly() {
+ this.setState({ joinedAudioOnly: false });
+ }
+
+ changeSetTime(event) {
+ const newSetTime = Number.parseInt(event.target.value, 10) || 0;
+ this.setState({ newTime: newSetTime >= 0 ? newSetTime : 0 });
+ }
+
+ handleClickOutsideDurationContainer(e) {
+ if (this.durationContainerRef) {
+ const { x, right, y, bottom } = this.durationContainerRef.getBoundingClientRect();
+
+ if (e.clientX < x || e.clientX > right || e.clientY < y || e.clientY > bottom) {
+ this.resetSetTimeForm();
+ }
+ }
+ }
+
+ showSetTimeForm() {
+ this.setState({ visibleSetTimeForm: true });
+ window.addEventListener('click', this.handleClickOutsideDurationContainer);
+ }
+
+ showSetTimeHigherThanMeetingTimeError(show) {
+ this.setState({ visibleSetTimeHigherThanMeetingTimeError: show });
+ }
+
+ resetSetTimeForm() {
+ this.setState({ visibleSetTimeForm: false, newTime: 5 });
+ window.removeEventListener('click', this.handleClickOutsideDurationContainer);
+ }
+
+ transferUserToBreakoutRoom(breakoutId) {
+ const { transferToBreakout } = this.props;
+ transferToBreakout(breakoutId);
+ this.setState({ joinedAudioOnly: true, breakoutId });
+ }
+
+ returnBackToMeeeting(breakoutId) {
+ const { transferUserToMeeting, meetingId } = this.props;
+ transferUserToMeeting(breakoutId, meetingId);
+ this.setState({ joinedAudioOnly: false, breakoutId });
+ }
+
+ closePanel() {
+ const { layoutContextDispatch } = this.props;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ }
+
+ renderUserActions(breakoutId, joinedUsers, shortName) {
+ const {
+ isMicrophoneUser,
+ amIModerator,
+ amIPresenter,
+ intl,
+ isUserInBreakoutRoom,
+ forceExitAudio,
+ rejoinAudio,
+ setBreakoutAudioTransferStatus,
+ getBreakoutAudioTransferStatus,
+ } = this.props;
+
+ const {
+ joinedAudioOnly,
+ breakoutId: _stateBreakoutId,
+ requestedBreakoutId,
+ waiting,
+ } = this.state;
+
+ const {
+ breakoutMeetingId: currentAudioTransferBreakoutId,
+ status,
+ } = getBreakoutAudioTransferStatus();
+
+ const isInBreakoutAudioTransfer = status
+ === AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.CONNECTED;
+
+ const stateBreakoutId = _stateBreakoutId || currentAudioTransferBreakoutId;
+ const moderatorJoinedAudio = isMicrophoneUser && amIModerator;
+ const disable = waiting && requestedBreakoutId !== breakoutId;
+ const hasBreakoutUrl = this.hasBreakoutUrl(breakoutId);
+ const dataTest = `${hasBreakoutUrl ? 'join' : 'askToJoin'}${shortName.replace(' ', '')}`;
+
+ const audioAction = joinedAudioOnly || isInBreakoutAudioTransfer
+ ? () => {
+ setBreakoutAudioTransferStatus({
+ breakoutMeetingId: breakoutId,
+ status: AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.RETURNING,
+ });
+ this.returnBackToMeeeting(breakoutId);
+ return logger.info({
+ logCode: 'breakoutroom_return_main_audio',
+ extraInfo: { logType: 'user_action' },
+ }, 'Returning to main audio (breakout room audio closed)');
+ }
+ : () => {
+ setBreakoutAudioTransferStatus({
+ breakoutMeetingId: breakoutId,
+ status: AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.CONNECTED,
+ });
+ this.transferUserToBreakoutRoom(breakoutId);
+ return logger.info({
+ logCode: 'breakoutroom_join_audio_from_main_room',
+ extraInfo: { logType: 'user_action' },
+ }, 'joining breakout room audio (main room audio closed)');
+ };
+ return (
+
+ {
+ isUserInBreakoutRoom(joinedUsers)
+ ? (
+
+ {intl.formatMessage(intlMessages.alreadyConnected)}
+
+ )
+ : (
+ {
+ this.getBreakoutURL(breakoutId);
+ // leave main room's audio,
+ // and stops video and screenshare when joining a breakout room
+ forceExitAudio();
+ logger.info({
+ logCode: 'breakoutroom_join',
+ extraInfo: { logType: 'user_action' },
+ }, 'joining breakout room closed audio in the main room');
+ VideoService.storeDeviceIds();
+ VideoService.exitVideo();
+ if (amIPresenter) screenshareHasEnded();
+
+ Tracker.autorun((c) => {
+ const selector = {
+ meetingId: breakoutId,
+ };
+
+ const query = Users.find(selector, {
+ fields: {
+ loggedOut: 1,
+ extId: 1,
+ },
+ });
+
+ const observeLogOut = (user) => {
+ if (user?.loggedOut && user?.extId?.startsWith(Auth.userID)) {
+ rejoinAudio();
+ c.stop();
+ }
+ }
+
+ query.observe({
+ added: observeLogOut,
+ changed: observeLogOut,
+ });
+ });
+ }}
+ disabled={disable}
+ />
+ )
+ }
+ {
+ moderatorJoinedAudio
+ ? [
+ ('|'),
+ (
+
+ ),
+ ]
+ : null
+ }
+
+ );
+ }
+
+ renderBreakoutRooms() {
+ const {
+ breakoutRooms,
+ intl,
+ } = this.props;
+
+ const {
+ waiting,
+ requestedBreakoutId,
+ } = this.state;
+
+ const { animations } = Settings.application;
+ const roomItems = breakoutRooms.map((breakout) => (
+
+
+
+ {breakout.isDefaultName
+ ? intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence })
+ : breakout.shortName}
+
+ (
+ {breakout.joinedUsers.length}
+ )
+
+
+ {waiting && requestedBreakoutId === breakout.breakoutId ? (
+
+ {intl.formatMessage(intlMessages.generatingURL)}
+
+
+ ) : this.renderUserActions(
+ breakout.breakoutId,
+ breakout.joinedUsers,
+ breakout.shortName,
+ )}
+
+
+ {breakout.joinedUsers
+ .sort(BreakoutRoom.sortById)
+ .filter((value, idx, arr) => !(value.userId === (arr[idx + 1] || {}).userId))
+ .sort(Service.sortUsersByName)
+ .map((u) => u.name)
+ .join(', ')}
+
+
+ ));
+
+ return (
+
+
+ {roomItems}
+
+
+ );
+ }
+
+ renderDuration() {
+ const {
+ intl,
+ breakoutRooms,
+ amIModerator,
+ isMeteorConnected,
+ setBreakoutsTime,
+ isNewTimeHigherThanMeetingRemaining,
+ } = this.props;
+ const {
+ newTime,
+ visibleSetTimeForm,
+ visibleSetTimeHigherThanMeetingTimeError,
+ } = this.state;
+ return (
+ this.durationContainerRef = ref}
+ >
+
+
+
+ {amIModerator && visibleSetTimeForm ? (
+
+
+ {intl.formatMessage(intlMessages.setTimeInMinutes)}
+
+
+
+
+
+
+ {
+ this.showSetTimeHigherThanMeetingTimeError(false);
+
+ if (isNewTimeHigherThanMeetingRemaining(newTime)) {
+ this.showSetTimeHigherThanMeetingTimeError(true);
+ } else if (setBreakoutsTime(newTime)) {
+ this.resetSetTimeForm();
+ }
+ }}
+ />
+
+ {visibleSetTimeHigherThanMeetingTimeError ? (
+
+ {intl.formatMessage(intlMessages.setTimeHigherThanMeetingTimeError)}
+
+ ) : null}
+
+ ) : null}
+
+ );
+ }
+
+ render() {
+ const {
+ isMeteorConnected,
+ intl,
+ endAllBreakouts,
+ amIModerator,
+ isRTL,
+ } = this.props;
+ return (
+ this.panel = n} onCopy={(e) => { e.stopPropagation(); }}>
+ {
+ this.closePanel();
+ },
+ }}
+ customRightButton={amIModerator && (
+ {
+ this.closePanel();
+ endAllBreakouts();
+ }}
+ isMeteorConnected={isMeteorConnected}
+ amIModerator={amIModerator}
+ isRTL={isRTL}
+ />
+ )}
+ />
+ {this.renderDuration()}
+ {amIModerator
+ ? (
+
+ ) : null }
+ {amIModerator ? : null }
+ {this.renderBreakoutRooms()}
+
+ );
+ }
+}
+
+export default injectIntl(BreakoutRoom);
diff --git a/src/2.7.12/imports/ui/components/breakout-room/container.jsx b/src/2.7.12/imports/ui/components/breakout-room/container.jsx
new file mode 100644
index 00000000..bf042e62
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/container.jsx
@@ -0,0 +1,101 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import AudioService from '/imports/ui/components/audio/service';
+import AudioManager from '/imports/ui/services/audio-manager';
+import BreakoutComponent from './component';
+import Service from './service';
+import { layoutDispatch, layoutSelect } from '../layout/context';
+import Auth from '/imports/ui/services/auth';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import {
+ didUserSelectedMicrophone,
+ didUserSelectedListenOnly,
+} from '/imports/ui/components/audio/audio-modal/service';
+import { makeCall } from '/imports/ui/services/api';
+
+const BreakoutContainer = (props) => {
+ const layoutContextDispatch = layoutDispatch();
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return ;
+};
+
+export default withTracker((props) => {
+ const {
+ endAllBreakouts,
+ requestJoinURL,
+ setBreakoutsTime,
+ sendMessageToAllBreakouts,
+ isNewTimeHigherThanMeetingRemaining,
+ findBreakouts,
+ getBreakoutRoomUrl,
+ transferUserToMeeting,
+ transferToBreakout,
+ meetingId,
+ amIModerator,
+ isUserInBreakoutRoom,
+ } = Service;
+
+ const breakoutRooms = findBreakouts();
+ const isMicrophoneUser = (AudioService.isConnectedToBreakout() || AudioService.isConnected())
+ && !AudioService.isListenOnly();
+ const isMeteorConnected = Meteor.status().connected;
+ const isReconnecting = AudioService.isReconnecting();
+ const {
+ setBreakoutAudioTransferStatus,
+ getBreakoutAudioTransferStatus,
+ } = AudioService;
+
+ const logUserCouldNotRejoinAudio = () => {
+ logger.warn({
+ logCode: 'mainroom_audio_rejoin',
+ extraInfo: { logType: 'user_action' },
+ }, 'leaving breakout room couldn\'t rejoin audio in the main room');
+ };
+
+ const rejoinAudio = () => {
+ if (didUserSelectedMicrophone()) {
+ AudioManager.joinMicrophone().then(() => {
+ makeCall('toggleVoice', null, true).catch(() => {
+ AudioManager.forceExitAudio();
+ logUserCouldNotRejoinAudio();
+ });
+ }).catch(() => {
+ logUserCouldNotRejoinAudio();
+ });
+ } else if (didUserSelectedListenOnly()) {
+ AudioManager.joinListenOnly().catch(() => {
+ logUserCouldNotRejoinAudio();
+ });
+ }
+ };
+
+ return {
+ ...props,
+ breakoutRooms,
+ endAllBreakouts,
+ requestJoinURL,
+ setBreakoutsTime,
+ sendMessageToAllBreakouts,
+ isNewTimeHigherThanMeetingRemaining,
+ getBreakoutRoomUrl,
+ transferUserToMeeting,
+ transferToBreakout,
+ isMicrophoneUser,
+ meetingId: meetingId(),
+ amIModerator: amIModerator(),
+ isMeteorConnected,
+ isUserInBreakoutRoom,
+ forceExitAudio: () => AudioManager.forceExitAudio(),
+ rejoinAudio,
+ isReconnecting,
+ setBreakoutAudioTransferStatus,
+ getBreakoutAudioTransferStatus,
+ };
+})(BreakoutContainer);
diff --git a/src/2.7.12/imports/ui/components/breakout-room/invitation/component.jsx b/src/2.7.12/imports/ui/components/breakout-room/invitation/component.jsx
new file mode 100644
index 00000000..6757e13a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/invitation/component.jsx
@@ -0,0 +1,138 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { Session } from 'meteor/session';
+import BreakoutJoinConfirmationContainer from '/imports/ui/components/breakout-join-confirmation/container';
+import BreakoutService from '../service';
+
+const BREAKOUT_MODAL_DELAY = 200;
+
+const propTypes = {
+ lastBreakoutReceived: PropTypes.shape({
+ breakoutUrlData: PropTypes.object.isRequired,
+ }),
+ breakoutRoomsUserIsIn: PropTypes.shape({
+ sequence: PropTypes.number.isRequired,
+ }),
+ breakouts: PropTypes.arrayOf(PropTypes.shape({
+ freeJoin: PropTypes.bool.isRequired,
+ })),
+};
+
+const defaultProps = {
+ lastBreakoutReceived: undefined,
+ breakoutRoomsUserIsIn: undefined,
+ breakouts: [],
+};
+
+class BreakoutRoomInvitation extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ didSendBreakoutInvite: false,
+ isBreakoutJoinConfirmationModalOpen: false,
+ breakout: null,
+ breakoutName: null,
+ };
+ this.setBreakoutJoinConfirmationModalIsOpen = this.setBreakoutJoinConfirmationModalIsOpen.bind(this);
+ }
+
+ componentDidMount() {
+ // use dummy old data on mount so it works if no data changes
+ this.checkBreakouts({ breakouts: [] });
+ }
+
+ componentDidUpdate(oldProps) {
+ this.checkBreakouts(oldProps);
+ }
+
+ checkBreakouts(oldProps) {
+ const {
+ breakouts,
+ lastBreakoutReceived,
+ breakoutRoomsUserIsIn,
+ } = this.props;
+
+ const {
+ didSendBreakoutInvite,
+ } = this.state;
+
+ const hasBreakoutsAvailable = breakouts.length > 0;
+ const isModerator = BreakoutService.amIModerator();
+
+ if (hasBreakoutsAvailable
+ && !breakoutRoomsUserIsIn) {
+ const freeJoinRooms = breakouts.filter((breakout) => (breakout.freeJoin));
+
+ if (lastBreakoutReceived) {
+ const lastBreakoutIdOpened = Session.get('lastBreakoutIdOpened');
+ const oldLastBktReceivedInsertedTime = (typeof oldProps.lastBreakoutReceived === 'object') ? oldProps.lastBreakoutReceived.breakoutUrlData.insertedTime : 0;
+
+ // check if user has a new invitation
+ if (lastBreakoutReceived.breakoutUrlData.insertedTime !== oldLastBktReceivedInsertedTime
+ // or check if user just left a room and was invited to another room in last 15 secs
+ || (typeof oldProps.breakoutRoomsUserIsIn === 'object'
+ && !breakoutRoomsUserIsIn
+ && lastBreakoutReceived.breakoutId !== lastBreakoutIdOpened
+ && lastBreakoutReceived.breakoutUrlData.insertedTime > (new Date().getTime()) - 15000)
+ ) {
+ if (!isModerator || lastBreakoutReceived.sendInviteToModerators) this.inviteUserToBreakout(lastBreakoutReceived);
+ }
+ } else if (freeJoinRooms.length > 0 && !didSendBreakoutInvite) {
+ const maxSeq = Math.max(...freeJoinRooms.map(((room) => room.sequence)));
+ // Check if received all rooms and Pick a room randomly
+ if (maxSeq === freeJoinRooms.length) {
+ const randomRoom = freeJoinRooms[Math.floor(Math.random() * freeJoinRooms.length)];
+ if (!isModerator || randomRoom.sendInviteToModerators) this.inviteUserToBreakout(randomRoom);
+ this.setState({ didSendBreakoutInvite: true });
+ }
+ }
+ }
+
+ if (!hasBreakoutsAvailable && didSendBreakoutInvite) {
+ this.setState({ didSendBreakoutInvite: false });
+ }
+ }
+
+ inviteUserToBreakout(breakout) {
+ Session.set('lastBreakoutIdInvited', breakout.breakoutId);
+ // There's a race condition on page load with modals. Only one modal can be shown at a
+ // time and new ones overwrite old ones. We delay the opening of the breakout modal
+ // because it should always be on top if breakouts are running.
+ setTimeout(() => {
+ this.setState({
+ breakout: breakout,
+ breakoutName: breakout.name,
+ })
+ this.setBreakoutJoinConfirmationModalIsOpen(true);
+ }, BREAKOUT_MODAL_DELAY);
+ }
+
+ setBreakoutJoinConfirmationModalIsOpen(value) {
+ this.setState({
+ isBreakoutJoinConfirmationModalOpen: value,
+ });
+ }
+
+ render() {
+ const { isBreakoutJoinConfirmationModalOpen, breakout, breakoutName } = this.state;
+ return (<>
+ {isBreakoutJoinConfirmationModalOpen ? this.setBreakoutJoinConfirmationModalIsOpen(false),
+ priority: "medium",
+ setIsOpen: this.setBreakoutJoinConfirmationModalIsOpen,
+ isOpen: isBreakoutJoinConfirmationModalOpen
+ }}
+ /> : null}
+ >
+ );
+ }
+}
+
+BreakoutRoomInvitation.propTypes = propTypes;
+BreakoutRoomInvitation.defaultProps = defaultProps;
+
+export default BreakoutRoomInvitation;
diff --git a/src/2.7.12/imports/ui/components/breakout-room/invitation/container.jsx b/src/2.7.12/imports/ui/components/breakout-room/invitation/container.jsx
new file mode 100644
index 00000000..5612bf89
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/invitation/container.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import BreakoutRoomInvitation from './component';
+import BreakoutService from '../service';
+import Auth from '/imports/ui/services/auth';
+import AppService from '/imports/ui/components/app/service';
+
+const BreakoutRoomInvitationContainer = ({ isMeetingBreakout, ...props }) => {
+ if (isMeetingBreakout) return null;
+ return (
+
+ );
+};
+
+export default withTracker(() => ({
+ isMeetingBreakout: AppService.meetingIsBreakout(),
+ breakouts: BreakoutService.getBreakoutsNoTime(),
+ lastBreakoutReceived: BreakoutService.getLastBreakoutByUserId(Auth.userID),
+ breakoutRoomsUserIsIn: BreakoutService.getBreakoutUserIsIn(Auth.userID),
+}))(BreakoutRoomInvitationContainer);
diff --git a/src/2.7.12/imports/ui/components/breakout-room/message-form/component.jsx b/src/2.7.12/imports/ui/components/breakout-room/message-form/component.jsx
new file mode 100644
index 00000000..60407e7a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/message-form/component.jsx
@@ -0,0 +1,255 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import deviceInfo from '/imports/utils/deviceInfo';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import { escapeHtml } from '/imports/utils/string-utils';
+import { isChatEnabled } from '/imports/ui/services/features';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ chatId: PropTypes.string.isRequired,
+ disabled: PropTypes.bool.isRequired,
+ minMessageLength: PropTypes.number.isRequired,
+ maxMessageLength: PropTypes.number.isRequired,
+ chatTitle: PropTypes.string.isRequired,
+ handleSendMessage: PropTypes.func.isRequired,
+ UnsentMessagesCollection: PropTypes.objectOf(Object).isRequired,
+ connected: PropTypes.bool.isRequired,
+ locked: PropTypes.bool.isRequired,
+};
+
+const messages = defineMessages({
+ submitLabel: {
+ id: 'app.chat.submitLabel',
+ description: 'Chat submit button label',
+ },
+ inputLabel: {
+ id: 'app.chat.inputLabel',
+ description: 'Chat message input label',
+ },
+ inputPlaceholder: {
+ id: 'app.chat.inputPlaceholder',
+ description: 'Chat message input placeholder',
+ },
+ errorMaxMessageLength: {
+ id: 'app.chat.errorMaxMessageLength',
+ },
+ errorServerDisconnected: {
+ id: 'app.chat.disconnected',
+ },
+ errorChatLocked: {
+ id: 'app.chat.locked',
+ },
+});
+
+class MessageForm extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ message: '',
+ error: null,
+ hasErrors: false,
+ };
+
+ this.handleMessageChange = this.handleMessageChange.bind(this);
+ this.handleMessageKeyDown = this.handleMessageKeyDown.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.setMessageHint = this.setMessageHint.bind(this);
+ }
+
+ componentDidMount() {
+ const { isMobile } = deviceInfo;
+ this.setMessageState();
+ this.setMessageHint();
+
+ if (!isMobile) {
+ if (this.textarea) this.textarea.focus();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ connected,
+ locked,
+ } = this.props;
+ const { message } = this.state;
+
+ this.updateUnsentMessagesCollection(prevProps.chatId, message);
+
+ if (
+ connected !== prevProps.connected
+ || locked !== prevProps.locked
+ ) {
+ this.setMessageHint();
+ }
+ }
+
+ componentWillUnmount() {
+ const { chatId } = this.props;
+ const { message } = this.state;
+ this.updateUnsentMessagesCollection(chatId, message);
+ this.setMessageState();
+ }
+
+ handleMessageKeyDown(e) {
+ // TODO Prevent send message pressing enter on mobile and/or virtual keyboard
+ if (e.keyCode === 13 && !e.shiftKey) {
+ e.preventDefault();
+
+ const event = new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ });
+
+ this.form.dispatchEvent(event);
+ }
+ }
+
+ handleMessageChange(e) {
+ const {
+ intl,
+ maxMessageLength,
+ } = this.props;
+
+ let message = e.target.value;
+ let error = null;
+
+ if (message.length > maxMessageLength) {
+ error = intl.formatMessage(
+ messages.errorMaxMessageLength,
+ { 0: maxMessageLength },
+ );
+ message = message.substring(0, maxMessageLength);
+ }
+
+ this.setState({
+ message,
+ error,
+ });
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ const {
+ disabled,
+ minMessageLength,
+ maxMessageLength,
+ handleSendMessage,
+ } = this.props;
+ const { message } = this.state;
+ const msg = message.trim();
+
+ if (msg.length < minMessageLength) return;
+
+ if (disabled
+ || msg.length > maxMessageLength) {
+ this.setState({ hasErrors: true });
+ return;
+ }
+
+ handleSendMessage(escapeHtml(msg));
+ this.setState({ message: '', error: '', hasErrors: false });
+ }
+
+ setMessageHint() {
+ const {
+ connected,
+ disabled,
+ intl,
+ locked,
+ } = this.props;
+
+ let chatDisabledHint = null;
+
+ if (disabled) {
+ if (connected) {
+ if (locked) {
+ chatDisabledHint = messages.errorChatLocked;
+ }
+ } else {
+ chatDisabledHint = messages.errorServerDisconnected;
+ }
+ }
+
+ this.setState({
+ hasErrors: disabled,
+ error: chatDisabledHint ? intl.formatMessage(chatDisabledHint) : null,
+ });
+ }
+
+ setMessageState() {
+ const { chatId, UnsentMessagesCollection } = this.props;
+ const unsentMessageByChat = UnsentMessagesCollection.findOne({ chatId },
+ { fields: { message: 1 } });
+ this.setState({ message: unsentMessageByChat ? unsentMessageByChat.message : '' });
+ }
+
+ updateUnsentMessagesCollection(chatId, message) {
+ const { UnsentMessagesCollection } = this.props;
+ UnsentMessagesCollection.upsert(
+ { chatId },
+ { $set: { message } },
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ chatTitle,
+ title,
+ disabled,
+ } = this.props;
+
+ const { hasErrors, error, message } = this.state;
+
+ return isChatEnabled() ? (
+ { this.form = ref; }}
+ onSubmit={this.handleSubmit}
+ >
+
+ { this.textarea = ref; return this.textarea; }}
+ placeholder={intl.formatMessage(messages.inputPlaceholder, { 0: title })}
+ aria-label={intl.formatMessage(messages.inputLabel, { 0: chatTitle })}
+ aria-invalid={hasErrors ? 'true' : 'false'}
+ autoCorrect="off"
+ autoComplete="off"
+ spellCheck="true"
+ disabled={disabled}
+ value={message}
+ onChange={this.handleMessageChange}
+ onKeyDown={this.handleMessageKeyDown}
+ async
+ onPaste={(e) => { e.stopPropagation(); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ />
+ { }}
+ data-test="sendMessageButton"
+ />
+
+ { error ? {error} : null }
+
+ ) : null;
+ }
+}
+
+MessageForm.propTypes = propTypes;
+
+export default injectIntl(MessageForm);
diff --git a/src/2.7.12/imports/ui/components/breakout-room/message-form/container.jsx b/src/2.7.12/imports/ui/components/breakout-room/message-form/container.jsx
new file mode 100644
index 00000000..88803620
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/message-form/container.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import MessageForm from './component';
+import BreakoutService from '/imports/ui/components/breakout-room/service';
+import ChatService from '/imports/ui/components/chat/service';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const MessageFormContainer = (props) => {
+ const handleSendMessage = (message) => BreakoutService.sendMessageToAllBreakouts(message);
+
+ return (
+
+ );
+};
+
+export default MessageFormContainer;
diff --git a/src/2.7.12/imports/ui/components/breakout-room/message-form/styles.js b/src/2.7.12/imports/ui/components/breakout-room/message-form/styles.js
new file mode 100644
index 00000000..bfb77a44
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/message-form/styles.js
@@ -0,0 +1,103 @@
+import styled from 'styled-components';
+import {
+ colorBlueLight,
+ colorText,
+ colorGrayLighter,
+ colorPrimary,
+ colorDanger,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ smPaddingX,
+ smPaddingY,
+ borderRadius,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import TextareaAutosize from 'react-autosize-textarea';
+import Button from '/imports/ui/components/common/button/component';
+
+const Form = styled.form`
+ flex-grow: 0;
+ flex-shrink: 0;
+ align-self: flex-end;
+ width: 100%;
+ position: relative;
+ margin-bottom: calc(-1 * ${smPaddingX});
+ margin-top: .2rem;
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+const Input = styled(TextareaAutosize)`
+ flex: 1;
+ background: #fff;
+ background-clip: padding-box;
+ margin: 0;
+ color: ${colorText};
+ -webkit-appearance: none;
+ padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25);
+ resize: none;
+ transition: none;
+ border-radius: ${borderRadius};
+ font-size: ${fontSizeBase};
+ line-height: 1;
+ min-height: 2.5rem;
+ max-height: 10rem;
+ border: 1px solid ${colorGrayLighter};
+
+ &:disabled,
+ &[disabled] {
+ cursor: not-allowed;
+ opacity: .75;
+ background-color: rgba(167,179,189,0.25);
+ }
+
+ &:focus {
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+`;
+
+const SendButton = styled(Button)`
+ margin:0 0 0 ${smPaddingX};
+ align-self: center;
+ font-size: 0.9rem;
+
+ [dir="rtl"] & {
+ margin: 0 ${smPaddingX} 0 0;
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+`;
+
+const ErrorMessage = styled.div`
+ color: ${colorDanger};
+ font-size: calc(${fontSizeBase} * .75);
+ text-align: left;
+ padding: ${borderSize} 0;
+ position: relative;
+ height: .93rem;
+ max-height: .93rem;
+`;
+
+export default {
+ Form,
+ Wrapper,
+ Input,
+ SendButton,
+ ErrorMessage,
+};
diff --git a/src/2.7.12/imports/ui/components/breakout-room/service.js b/src/2.7.12/imports/ui/components/breakout-room/service.js
new file mode 100644
index 00000000..2c47c472
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/service.js
@@ -0,0 +1,246 @@
+import Breakouts from '/imports/api/breakouts';
+import Meetings, { MeetingTimeRemaining } from '/imports/api/meetings';
+import { makeCall } from '/imports/ui/services/api';
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users';
+import UserListService from '/imports/ui/components/user-list/service';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import { UploadingPresentations } from '/imports/api/presentations';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const findBreakouts = () => {
+ const BreakoutRooms = Breakouts.find(
+ {
+ parentMeetingId: Auth.meetingID,
+ },
+ {
+ sort: {
+ sequence: 1,
+ },
+ }
+ ).fetch();
+
+ return BreakoutRooms;
+};
+
+const getBreakoutRoomUrl = (breakoutId) => {
+ const breakoutRooms = findBreakouts();
+ const breakoutRoom = breakoutRooms
+ .filter((breakout) => breakout.breakoutId === breakoutId)
+ .shift();
+ const breakoutUrlData =
+ breakoutRoom && breakoutRoom[`url_${Auth.userID}`]
+ ? breakoutRoom[`url_${Auth.userID}`]
+ : null;
+ return breakoutUrlData;
+};
+
+const upsertCapturedContent = (filename, temporaryPresentationId) => {
+ UploadingPresentations.upsert({
+ temporaryPresentationId,
+ }, {
+ $set: {
+ id: temporaryPresentationId,
+ temporaryPresentationId,
+ progress: 0,
+ filename,
+ lastModifiedUploader: false,
+ upload: {
+ done: false,
+ error: false,
+ },
+ uploadTimestamp: new Date(),
+ renderedInToast: false,
+ },
+ });
+};
+
+const setCapturedContentUploading = () => {
+ const breakoutRooms = findBreakouts();
+ breakoutRooms.forEach((breakout) => {
+ const filename = breakout.shortName;
+ const temporaryPresentationId = breakout.breakoutId;
+
+ if (breakout.captureNotes) {
+ upsertCapturedContent(filename, `${temporaryPresentationId}-notes`);
+ }
+
+ if (breakout.captureSlides) {
+ upsertCapturedContent(filename, `${temporaryPresentationId}-slides`);
+ }
+ });
+};
+
+const endAllBreakouts = () => {
+ setCapturedContentUploading();
+ makeCall('endAllBreakouts');
+};
+
+const requestJoinURL = (breakoutId) => {
+ makeCall('requestJoinURL', {
+ breakoutId,
+ });
+};
+
+const isNewTimeHigherThanMeetingRemaining = (newTimeInMinutes) => {
+ const meetingId = Auth.meetingID;
+ const meetingTimeRemaining = MeetingTimeRemaining.findOne({ meetingId });
+
+ if (meetingTimeRemaining) {
+ const { timeRemaining } = meetingTimeRemaining;
+
+ if (timeRemaining) {
+ const newBreakoutRoomsRemainingTime = newTimeInMinutes * 60;
+ // Keep margin of 5 seconds for breakout rooms end before parent meeting
+ const meetingTimeRemainingWithMargin = timeRemaining - 5;
+
+ if (newBreakoutRoomsRemainingTime > meetingTimeRemainingWithMargin) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+};
+
+const setBreakoutsTime = (timeInMinutes) => {
+ if (timeInMinutes <= 0) return false;
+
+ makeCall('setBreakoutsTime', {
+ timeInMinutes,
+ });
+
+ return true;
+};
+
+const sendMessageToAllBreakouts = (msg) => {
+ makeCall('sendMessageToAllBreakouts', {
+ msg,
+ });
+
+ return true;
+};
+
+const transferUserToMeeting = (fromMeetingId, toMeetingId) =>
+ makeCall('transferUser', fromMeetingId, toMeetingId);
+
+const transferToBreakout = (breakoutId) => {
+ const breakoutRooms = findBreakouts();
+ const breakoutRoom = breakoutRooms
+ .filter((breakout) => breakout.breakoutId === breakoutId)
+ .shift();
+ const breakoutMeeting = Meetings.findOne(
+ {
+ $and: [
+ { 'breakoutProps.sequence': breakoutRoom.sequence },
+ { 'breakoutProps.parentId': breakoutRoom.parentMeetingId },
+ { 'meetingProp.isBreakout': true },
+ ],
+ },
+ { fields: { meetingId: 1 } }
+ );
+ transferUserToMeeting(Auth.meetingID, breakoutMeeting.meetingId);
+};
+
+const amIModerator = () => {
+ const User = Users.findOne({ intId: Auth.userID }, { fields: { role: 1 } });
+ return User.role === ROLE_MODERATOR;
+};
+
+const getBreakoutByUserId = (userId) =>
+ Breakouts.find(
+ { [`url_${userId}`]: { $exists: true } },
+ { fields: { timeRemaining: 0 } },
+ ).fetch();
+
+const getWithBreakoutUrlData = (userId) => (breakoutsArray) => breakoutsArray
+ .map((breakout) => {
+ if (typeof breakout[`url_${userId}`] === 'object') {
+ return Object.assign(breakout, { breakoutUrlData: breakout[`url_${userId}`] });
+ }
+ return Object.assign(breakout, { breakoutUrlData: { insertedTime: 0 } });
+ })
+ .reduce((acc, urlDataArray) => acc.concat(urlDataArray), []);
+
+const getLastBreakoutInserted = (breakoutURLArray) => breakoutURLArray.sort((a, b) => {
+ return a.breakoutUrlData.insertedTime - b.breakoutUrlData.insertedTime;
+}).pop();
+
+const getLastBreakoutByUserId = (userId) => {
+ const breakout = getBreakoutByUserId(userId);
+ const url = getWithBreakoutUrlData(userId)(breakout);
+ return getLastBreakoutInserted(url);
+}
+
+const getBreakouts = () =>
+ Breakouts.find({}, { sort: { sequence: 1 } }).fetch();
+const getBreakoutsNoTime = () =>
+ Breakouts.find(
+ {},
+ {
+ sort: { sequence: 1 },
+ fields: { timeRemaining: 0 },
+ }
+ ).fetch();
+
+const getBreakoutUserIsIn = (userId) =>
+ Breakouts.findOne(
+ { 'joinedUsers.userId': new RegExp(`^${userId}`) },
+ { fields: { sequence: 1 } }
+ );
+
+const getBreakoutUserWasIn = (userId, extId) => {
+ const selector = {
+ meetingId: Auth.meetingID,
+ lastBreakoutRoom: { $exists: 1 },
+ };
+
+ if (extId !== null) {
+ selector.extId = extId;
+ } else {
+ selector.userId = userId;
+ }
+
+ const users = UsersPersistentData.find(
+ selector,
+ { fields: { userId: 1, lastBreakoutRoom: 1 } },
+ ).fetch();
+
+ if (users.length > 0) {
+ const hasCurrUserId = users.filter((user) => user.userId === userId);
+ if (hasCurrUserId.length > 0) return hasCurrUserId.pop().lastBreakoutRoom;
+
+ return users.pop().lastBreakoutRoom;
+ }
+
+ return null;
+};
+
+const isUserInBreakoutRoom = (joinedUsers) => {
+ const userId = Auth.userID;
+
+ return !!joinedUsers.find((user) => user.userId.startsWith(userId));
+};
+
+export default {
+ findBreakouts,
+ endAllBreakouts,
+ setBreakoutsTime,
+ sendMessageToAllBreakouts,
+ isNewTimeHigherThanMeetingRemaining,
+ requestJoinURL,
+ getBreakoutRoomUrl,
+ transferUserToMeeting,
+ transferToBreakout,
+ meetingId: () => Auth.meetingID,
+ amIModerator,
+ getLastBreakoutByUserId,
+ getBreakouts,
+ getBreakoutsNoTime,
+ getBreakoutUserIsIn,
+ getBreakoutUserWasIn,
+ sortUsersByName: UserListService.sortUsersByName,
+ isUserInBreakoutRoom,
+ setCapturedContentUploading,
+};
diff --git a/src/2.7.12/imports/ui/components/breakout-room/styles.js b/src/2.7.12/imports/ui/components/breakout-room/styles.js
new file mode 100644
index 00000000..fa8c8aa4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/breakout-room/styles.js
@@ -0,0 +1,271 @@
+import styled, { css, keyframes } from 'styled-components';
+import {
+ mdPaddingX,
+ borderSize,
+ listItemBgHover, borderSizeSmall,
+ borderRadius,
+ jumboPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorPrimary,
+ colorGray,
+ colorDanger,
+ userListBg,
+ colorWhite,
+ colorGrayLighter,
+ colorGrayLightest,
+ colorBlueLight
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ headingsFontWeight,
+ fontSizeSmall,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+import Button from '/imports/ui/components/common/button/component';
+
+const BreakoutActions = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ font-weight: ${headingsFontWeight};
+ color: ${colorPrimary};
+
+ & > button {
+ padding: 0 0 0 .5rem;
+ }
+`;
+
+const AlreadyConnected = styled.span`
+ padding: 0 .5rem 0 0;
+ display: inline-block;
+ vertical-align: middle;
+ white-space: nowrap;
+`;
+
+const JoinButton = styled(Button)`
+ flex: 0 1 48%;
+ color: ${colorPrimary};
+ margin: 0;
+ font-weight: inherit;
+ padding: 0 .5rem 0 .5rem !important;
+`;
+
+const AudioButton = styled(Button)`
+ flex: 0 1 48%;
+ color: ${colorPrimary};
+ margin: 0;
+ font-weight: inherit;
+`;
+
+const BreakoutItems = styled.div`
+ margin-bottom: 1rem;
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ font-size: ${fontSizeSmall};
+ font-weight: bold;
+ padding: ${borderSize} ${borderSize} ${borderSize} 0;
+
+ [dir="rtl"] & {
+ padding: ${borderSize} 0 ${borderSize} ${borderSize};
+ }
+`;
+
+const BreakoutRoomListNameLabel = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const UsersAssignedNumberLabel = styled.span`
+ margin: 0 0 0 .25rem;
+
+ [dir="rtl"] & {
+ margin: 0 .25em 0 0;
+ }
+`;
+
+const ellipsis = keyframes`
+ to {
+ width: 1.5em;
+ }
+`
+
+const ConnectingAnimation = styled.span`
+ &:after {
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: bottom;
+ content: "\\2026"; /* ascii code for the ellipsis character */
+ width: 0;
+ margin: 0 1.25em 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1.25em;
+ }
+
+ ${({ animations }) => animations && css`
+ animation: ${ellipsis} steps(4, end) 900ms infinite;
+ `}
+ }
+`;
+
+const JoinedUserNames = styled.div`
+ overflow-wrap: break-word;
+ white-space: pre-line;
+ margin-left: 1rem;
+ font-size: ${fontSizeSmall};
+`;
+
+const BreakoutColumn = styled.div`
+ display: flex;
+ flex-flow: column;
+ min-height: 0;
+ flex-grow: 1;
+`;
+
+const BreakoutScrollableList = styled(ScrollboxVertical)`
+ background: linear-gradient(${userListBg} 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), ${userListBg} 70%) 0 100%,
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${listItemBgHover}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:focus-within,
+ &:focus {
+ outline-style: solid;
+ }
+
+ &:active {
+ box-shadow: none;
+ border-radius: none;
+ }
+
+ overflow-x: hidden;
+ outline-width: 1px !important;
+ outline-color: transparent !important;
+ background: none;
+`;
+
+const DurationContainer = styled.div`
+ ${({ centeredText }) => centeredText && `
+ text-align: center;
+ `}
+
+ border-radius: ${borderRadius};
+ margin-bottom: ${jumboPaddingY};
+ padding: 10px;
+ box-shadow: 0 0 1px 1px ${colorGrayLightest};
+`;
+
+const SetTimeContainer = styled.div`
+ margin: .5rem 0 0 0;
+`;
+
+const SetDurationInput = styled.input`
+ flex: 1;
+ border: 1px solid ${colorGrayLighter};
+ width: 50%;
+ text-align: center;
+ padding: .25rem;
+ border-radius: ${borderRadius};
+ background-clip: padding-box;
+ outline: none;
+
+ &::placeholder {
+ color: ${colorGray};
+ opacity: 1;
+ }
+
+ &:focus {
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:disabled,
+ &[disabled] {
+ cursor: not-allowed;
+ opacity: .75;
+ background-color: rgba(167,179,189,0.25);
+ }
+`;
+
+const WithError = styled.span`
+ color: ${colorDanger};
+`;
+
+const EndButton = styled(Button)`
+ padding: .5rem;
+ font-weight: ${headingsFontWeight} !important;
+ border-radius: .2rem;
+ font-size: ${fontSizeSmall};
+`;
+
+const Duration = styled.span`
+ display: inline-block;
+ align-self: center;
+`;
+
+const Panel = styled(ScrollboxVertical)`
+ background: linear-gradient(${colorWhite} 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), ${colorWhite} 70%) 0 100%,
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ background-color: #fff;
+ padding: ${mdPaddingX};
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+`;
+
+const Separator = styled.div`
+ position: relative;
+ width: 100%;
+ height: 10px;
+ height: ${borderSizeSmall};
+ background-color: ${colorGrayLighter};
+ margin: 30px 0px;
+`;
+
+const FlexRow = styled.div`
+ display: flex;
+ flex-wrap: nowrap;
+`;
+
+export default {
+ BreakoutActions,
+ AlreadyConnected,
+ JoinButton,
+ AudioButton,
+ BreakoutItems,
+ Content,
+ BreakoutRoomListNameLabel,
+ UsersAssignedNumberLabel,
+ ConnectingAnimation,
+ JoinedUserNames,
+ BreakoutColumn,
+ BreakoutScrollableList,
+ DurationContainer,
+ SetTimeContainer,
+ SetDurationInput,
+ WithError,
+ EndButton,
+ Duration,
+ Panel,
+ Separator,
+ FlexRow,
+};
diff --git a/src/2.7.12/imports/ui/components/captions/button/component.jsx b/src/2.7.12/imports/ui/components/captions/button/component.jsx
new file mode 100644
index 00000000..4017608c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/button/component.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isActive: PropTypes.bool.isRequired,
+ handleOnClick: PropTypes.func.isRequired,
+};
+
+const intlMessages = defineMessages({
+ start: {
+ id: 'app.actionsBar.captions.start',
+ description: 'Start closed captions option',
+ },
+ stop: {
+ id: 'app.actionsBar.captions.stop',
+ description: 'Stop closed captions option',
+ },
+});
+
+const CaptionsButton = ({ intl, isActive, handleOnClick }) => (
+
+);
+
+CaptionsButton.propTypes = propTypes;
+export default injectIntl(CaptionsButton);
diff --git a/src/2.7.12/imports/ui/components/captions/button/container.jsx b/src/2.7.12/imports/ui/components/captions/button/container.jsx
new file mode 100644
index 00000000..623f7529
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/button/container.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/captions/service';
+import CaptionButton from './component';
+
+const Container = (props) => ;
+
+export default withTracker(({ setIsOpen }) => ({
+ isActive: Service.isCaptionsActive(),
+ handleOnClick: () => (Service.isCaptionsActive()
+ ? Service.deactivateCaptions()
+ : setIsOpen(true)),
+}))(Container);
diff --git a/src/2.7.12/imports/ui/components/captions/button/styles.js b/src/2.7.12/imports/ui/components/captions/button/styles.js
new file mode 100644
index 00000000..d8efa333
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/button/styles.js
@@ -0,0 +1,17 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+
+const CaptionsButton = styled(Button)`
+ ${({ ghost }) => ghost && `
+ span {
+ box-shadow: none;
+ background-color: transparent !important;
+ border-color: ${colorWhite} !important;
+ }
+ `}
+`;
+
+export default {
+ CaptionsButton,
+};
diff --git a/src/2.7.12/imports/ui/components/captions/component.jsx b/src/2.7.12/imports/ui/components/captions/component.jsx
new file mode 100644
index 00000000..b7ab1286
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/component.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import Button from '/imports/ui/components/common/button/component';
+import PadContainer from '/imports/ui/components/pads/container';
+import Service from '/imports/ui/components/captions/service';
+import Styled from './styles';
+import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums';
+import browserInfo from '/imports/utils/browserInfo';
+import Header from '/imports/ui/components/common/control-header/component';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.captions.title',
+ description: 'Title for the pad header',
+ },
+ hide: {
+ id: 'app.captions.hide',
+ description: 'Label for hiding closed captions',
+ },
+ takeOwnership: {
+ id: 'app.captions.ownership',
+ description: 'Label for taking ownership of closed captions',
+ },
+ takeOwnershipTooltip: {
+ id: 'app.captions.ownershipTooltip',
+ description: 'Text for button for taking ownership of closed captions',
+ },
+ dictationStart: {
+ id: 'app.captions.dictationStart',
+ description: 'Label for starting speech recognition',
+ },
+ dictationStop: {
+ id: 'app.captions.dictationStop',
+ description: 'Label for stoping speech recognition',
+ },
+ dictationOnDesc: {
+ id: 'app.captions.dictationOnDesc',
+ description: 'Aria description for button that turns on speech recognition',
+ },
+ dictationOffDesc: {
+ id: 'app.captions.dictationOffDesc',
+ description: 'Aria description for button that turns off speech recognition',
+ },
+});
+
+const propTypes = {
+ locale: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ ownerId: PropTypes.string.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ dictation: PropTypes.bool.isRequired,
+ dictating: PropTypes.bool.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+ hasPermission: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ isResizing: PropTypes.bool.isRequired,
+};
+
+const Captions = ({
+ locale,
+ intl,
+ ownerId,
+ name,
+ dictation,
+ dictating,
+ isRTL,
+ hasPermission,
+ layoutContextDispatch,
+ isResizing,
+ autoTranscription,
+}) => {
+ const { isChrome } = browserInfo;
+
+ return (
+
+ {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ },
+ 'aria-label': intl.formatMessage(intlMessages.hide),
+ label: autoTranscription ? intl.formatMessage(intlMessages.title) : name,
+ }}
+ customRightButton={dictation ? (
+ Service.amICaptionsOwner(ownerId) ? (
+
+ Service.stopDictation(locale)
+ : () => Service.startDictation(locale)}
+ label={dictating
+ ? intl.formatMessage(intlMessages.dictationStop)
+ : intl.formatMessage(intlMessages.dictationStart)}
+ aria-describedby="dictationBtnDesc"
+ color={dictating ? 'danger' : 'primary'}
+ />
+
+ {dictating
+ ? intl.formatMessage(intlMessages.dictationOffDesc)
+ : intl.formatMessage(intlMessages.dictationOnDesc)}
+
+
+ ) : (
+ Service.updateCaptionsOwner(locale, name)}
+ aria-label={intl.formatMessage(intlMessages.takeOwnership)}
+ label={intl.formatMessage(intlMessages.takeOwnership)}
+ />
+ )) : null}
+ />
+
+
+ );
+};
+
+Captions.propTypes = propTypes;
+
+export default injectWbResizeEvent(injectIntl(Captions));
diff --git a/src/2.7.12/imports/ui/components/captions/container.jsx b/src/2.7.12/imports/ui/components/captions/container.jsx
new file mode 100644
index 00000000..1ba5b2f1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/container.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/captions/service';
+import Captions from './component';
+import Auth from '/imports/ui/services/auth';
+import { layoutSelectInput, layoutDispatch } from '../layout/context';
+import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+
+const Container = (props) => {
+ const cameraDock = layoutSelectInput((i) => i.cameraDock);
+ const { isResizing } = cameraDock;
+ const layoutContextDispatch = layoutDispatch();
+
+ return ;
+};
+
+export default withTracker(() => {
+ const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
+ const {
+ locale,
+ name,
+ ownerId,
+ dictating,
+ } = Service.getCaptions();
+
+ return {
+ locale,
+ name,
+ ownerId,
+ dictation: Service.canIDictateThisPad(ownerId),
+ dictating,
+ currentUserId: Auth.userID,
+ isRTL,
+ hasPermission: Service.hasPermission(),
+ amIModerator: Service.amIModerator(),
+ autoTranscription: SpeechService.isEnabled(),
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/captions/live/component.jsx b/src/2.7.12/imports/ui/components/captions/live/component.jsx
new file mode 100644
index 00000000..ba58ca17
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/live/component.jsx
@@ -0,0 +1,89 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import Service from '/imports/ui/components/captions/service';
+
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+
+class LiveCaptions extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = { clear: true };
+ this.timer = null;
+ this.settings = Service.getCaptionsSettings();
+ }
+
+ componentDidUpdate(prevProps) {
+ const { clear } = this.state;
+
+ if (clear) {
+ const { data } = this.props;
+ if (prevProps.data !== data) {
+ // eslint-disable-next-line react/no-did-update-set-state
+ this.setState({ clear: false });
+ }
+ } else {
+ this.resetTimer();
+ this.timer = setTimeout(() => this.setState({ clear: true }), CAPTIONS_CONFIG.time);
+ }
+ }
+
+ componentWillUnmount() {
+ this.resetTimer();
+ }
+
+ resetTimer() {
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+
+ render() {
+ const { data } = this.props;
+ const { clear } = this.state;
+ const {
+ fontFamily,
+ fontSize,
+ fontColor,
+ backgroundColor,
+ } = this.settings;
+
+ const captionStyles = {
+ whiteSpace: 'pre-wrap',
+ wordWrap: 'break-word',
+ fontFamily,
+ fontSize,
+ background: backgroundColor,
+ color: fontColor,
+ };
+
+ const visuallyHidden = {
+ position: 'absolute',
+ overflow: 'hidden',
+ clip: 'rect(0 0 0 0)',
+ height: '1px',
+ width: '1px',
+ margin: '-1px',
+ padding: '0',
+ border: '0',
+ };
+
+ return (
+
+
+ {clear ? '' : data}
+
+
+ {clear ? '' : data}
+
+
+ );
+ }
+}
+
+LiveCaptions.propTypes = {
+ data: PropTypes.string.isRequired,
+};
+
+export default LiveCaptions;
diff --git a/src/2.7.12/imports/ui/components/captions/live/container.jsx b/src/2.7.12/imports/ui/components/captions/live/container.jsx
new file mode 100644
index 00000000..18da93d7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/live/container.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/captions/service';
+import LiveCaptions from './component';
+
+const Container = (props) => ;
+
+export default withTracker(() => ({
+ data: Service.getCaptionsData(),
+}))(Container);
diff --git a/src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/component.jsx b/src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/component.jsx
new file mode 100644
index 00000000..0643de0e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/component.jsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { HexColorPicker } from "react-colorful";
+import Styled from './styles';
+
+const ColorPicker = ({ color, onChange, presetColors, colorNames }) => {
+ return (
+
+
+
+
+
+ {presetColors.map((presetColor) => (
+ onChange(presetColor)}
+ title={colorNames[presetColor]}
+ />
+ ))}
+
+
+ );
+};
+
+export default ColorPicker;
diff --git a/src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/styles.js b/src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/styles.js
new file mode 100644
index 00000000..f75b5f83
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/reader-menu/color-picker/styles.js
@@ -0,0 +1,38 @@
+import styled, { createGlobalStyle } from 'styled-components';
+
+const ColorPickerGlobalStyle = createGlobalStyle`
+ .react-colorful {
+ display: none !important;
+ }
+`;
+
+const Picker = styled.div`
+ border: 1px solid rgba(0, 0, 0, 0.2);
+ width: 140px;
+ box-shadow: rgba(0, 0, 0, 0.15) 0px 3px 12px;
+ border-radius: 4px;
+ padding: 5px;
+ display: flex;
+ flex-wrap: wrap;
+ background: #fff;
+ position: relative;
+`;
+
+const PickerSwatch = styled.button`
+ width: 25px;
+ height: 25px;
+ border: none;
+
+ &:hover {
+ cursor: pointer;
+ border: 1px solid #fff;
+ border-style: inset;
+ }
+`;
+
+export default {
+ ColorPickerGlobalStyle,
+ Picker,
+ PickerSwatch,
+ };
+
\ No newline at end of file
diff --git a/src/2.7.12/imports/ui/components/captions/reader-menu/component.jsx b/src/2.7.12/imports/ui/components/captions/reader-menu/component.jsx
new file mode 100644
index 00000000..bd4de344
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/reader-menu/component.jsx
@@ -0,0 +1,416 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import Button from '/imports/ui/components/common/button/component';
+import ColorPicker from "./color-picker/component";
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const DEFAULT_VALUE = 'select';
+const DEFAULT_KEY = -1;
+const DEFAULT_INDEX = 0;
+const FONT_FAMILIES = ['Arial', 'Calibri', 'Times New Roman', 'Sans-serif'];
+const FONT_SIZES = ['12px', '14px', '18px', '24px', '32px', '42px'];
+
+const COLORS = [
+ "#000000", "#7a7a7a",
+ "#ff0000", "#ff8800",
+ "#88ff00", "#ffffff",
+ "#00ffff", "#0000ff",
+ "#8800ff", "#ff00ff"
+];
+
+// Used to convert hex values to color names for screen reader aria labels
+const HEX_COLOR_NAMES = {
+ '#000000': 'Black',
+ '#7a7a7a': 'Grey',
+ '#ff0000': 'Red',
+ '#ff8800': 'Orange',
+ '#88ff00': 'Green',
+ '#ffffff': 'White',
+ '#00ffff': 'Cyan',
+ '#0000ff': 'Blue',
+ '#8800ff': 'Dark violet',
+ '#ff00ff': 'Magenta',
+};
+
+const intlMessages = defineMessages({
+ closeLabel: {
+ id: 'app.captions.menu.closeLabel',
+ description: 'Label for closing captions menu',
+ },
+ title: {
+ id: 'app.captions.menu.title',
+ description: 'Title for the closed captions menu',
+ },
+ start: {
+ id: 'app.captions.menu.start',
+ description: 'Write closed captions',
+ },
+ select: {
+ id: 'app.captions.menu.select',
+ description: 'Select closed captions available language',
+ },
+ backgroundColor: {
+ id: 'app.captions.menu.backgroundColor',
+ description: 'Select closed captions background color',
+ },
+ fontColor: {
+ id: 'app.captions.menu.fontColor',
+ description: 'Select closed captions font color',
+ },
+ fontFamily: {
+ id: 'app.captions.menu.fontFamily',
+ description: 'Select closed captions font family',
+ },
+ fontSize: {
+ id: 'app.captions.menu.fontSize',
+ description: 'Select closed captions font size',
+ },
+ cancelLabel: {
+ id: 'app.captions.menu.cancelLabel',
+ description: 'Cancel button label',
+ },
+ preview: {
+ id: 'app.captions.menu.previewLabel',
+ description: 'Preview area label',
+ },
+ ariaSelectLang: {
+ id: 'app.captions.menu.ariaSelect',
+ description: 'Captions language select aria label',
+ },
+ captionsLabel: {
+ id: 'app.captions.label',
+ description: 'Used in font / size aria labels',
+ },
+ current: {
+ id: 'app.submenu.application.currentSize',
+ description: 'Used in text / background color aria labels',
+ },
+});
+
+const propTypes = {
+ activateCaptions: PropTypes.func.isRequired,
+ getCaptionsSettings: PropTypes.func.isRequired,
+ closeModal: PropTypes.func.isRequired,
+ ownedLocales: PropTypes.arrayOf(PropTypes.object).isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+class ReaderMenu extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ const {
+ backgroundColor,
+ fontColor,
+ fontFamily,
+ fontSize,
+ } = props.getCaptionsSettings();
+
+ const { ownedLocales } = this.props;
+
+ this.state = {
+ locale: (ownedLocales && ownedLocales[0]) ? ownedLocales[0].locale : null,
+ backgroundColor,
+ fontColor,
+ fontFamily,
+ fontSize,
+ displayBackgroundColorPicker: false,
+ displayFontColorPicker: false,
+ };
+
+ this.handleSelectChange = this.handleSelectChange.bind(this);
+ this.handleColorPickerClick = this.handleColorPickerClick.bind(this);
+ this.handleCloseColorPicker = this.handleCloseColorPicker.bind(this);
+ this.handleFontColorChange = this.handleFontColorChange.bind(this);
+ this.handleBackgroundColorChange = this.handleBackgroundColorChange.bind(this);
+ this.handleLocaleChange = this.handleLocaleChange.bind(this);
+ this.handleStart = this.handleStart.bind(this);
+ this.getPreviewStyle = this.getPreviewStyle.bind(this);
+ }
+
+ handleColorPickerClick(fieldname) {
+ const obj = {};
+ // eslint-disable-next-line react/destructuring-assignment
+ obj[fieldname] = !this.state[fieldname];
+ this.setState(obj);
+ }
+
+ handleCloseColorPicker() {
+ this.setState({
+ displayBackgroundColorPicker: false,
+ displayFontColorPicker: false,
+ });
+ }
+
+ handleFontColorChange(color) {
+ this.setState({ fontColor: color });
+ this.handleCloseColorPicker();
+ }
+
+ handleBackgroundColorChange(color) {
+ this.setState({ backgroundColor: color });
+ this.handleCloseColorPicker();
+ }
+
+ handleLocaleChange(event) {
+ this.setState({ locale: event.target.value });
+ }
+
+
+ handleSelectChange(fieldname, options, event) {
+ const obj = {};
+ obj[fieldname] = options[event.target.value];
+ this.setState(obj);
+ }
+
+ handleStart() {
+ const { closeModal, activateCaptions } = this.props;
+ const {
+ locale,
+ backgroundColor,
+ fontColor,
+ fontFamily,
+ fontSize,
+ } = this.state;
+ const settings = {
+ backgroundColor,
+ fontColor,
+ fontFamily,
+ fontSize,
+ };
+ activateCaptions(locale, settings);
+ closeModal();
+ }
+
+ getPreviewStyle() {
+ const {
+ backgroundColor,
+ fontColor,
+ fontFamily,
+ fontSize,
+ } = this.state;
+
+ return {
+ fontFamily,
+ fontSize,
+ color: fontColor,
+ background: backgroundColor,
+ };
+ }
+
+ render() {
+ const {
+ intl,
+ ownedLocales,
+ closeModal,
+ isOpen,
+ priority,
+ } = this.props;
+
+ const {
+ backgroundColor,
+ displayBackgroundColorPicker,
+ displayFontColorPicker,
+ fontColor,
+ fontFamily,
+ fontSize,
+ locale,
+ } = this.state;
+
+ const defaultLocale = locale || DEFAULT_VALUE;
+
+ const ariaTextColor = `${intl.formatMessage(intlMessages.fontColor)} ${intl.formatMessage(intlMessages.current, { 0: HEX_COLOR_NAMES[fontColor.toLowerCase()] })}`;
+ const ariaBackgroundColor = `${intl.formatMessage(intlMessages.backgroundColor)} ${intl.formatMessage(intlMessages.current, { 0: HEX_COLOR_NAMES[backgroundColor.toLowerCase()] })}`;
+ const ariaFont = `${intl.formatMessage(intlMessages.captionsLabel)} ${intl.formatMessage(intlMessages.fontFamily)}`;
+ const ariaSize = `${intl.formatMessage(intlMessages.captionsLabel)} ${intl.formatMessage(intlMessages.fontSize)}`;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+ {!locale ? null : (
+
+
+
+
+ {intl.formatMessage(intlMessages.ariaSelectLang)}
+
+
+
+ {intl.formatMessage(intlMessages.select)}
+
+ {ownedLocales.map((loc) => (
+
+ {loc.name}
+
+ ))}
+
+
+
+
+
+ {intl.formatMessage(intlMessages.fontColor)}
+
+ { }}
+ role="button"
+ >
+
+
+ {
+ displayFontColorPicker
+ ? (
+
+ { }}
+ role="button"
+ tabIndex={0}
+ aria-label={ariaTextColor}
+ />
+
+
+ )
+ : null
+ }
+
+
+
+
+ {intl.formatMessage(intlMessages.backgroundColor)}
+
+ { }}
+ >
+
+
+ {
+ displayBackgroundColorPicker
+ ? (
+
+ { }}
+ />
+
+
+ )
+ : null
+ }
+
+
+
+
+ {intl.formatMessage(intlMessages.fontFamily)}
+
+
+ {FONT_FAMILIES.map((family, index) => (
+
+ {family}
+
+ ))}
+
+
+
+
+
+ {intl.formatMessage(intlMessages.fontSize)}
+
+
+ {FONT_SIZES.map((size, index) => (
+
+ {size}
+
+ ))}
+
+
+
+
+ {intl.formatMessage(intlMessages.preview)}
+ AaBbCc
+
+
+
+ )}
+
+
+
+ this.handleStart()}
+ disabled={locale == null}
+ data-test="startViewingClosedCaptions"
+ />
+
+
+
+ );
+ }
+}
+
+ReaderMenu.propTypes = propTypes;
+
+export default injectIntl(ReaderMenu);
diff --git a/src/2.7.12/imports/ui/components/captions/reader-menu/container.jsx b/src/2.7.12/imports/ui/components/captions/reader-menu/container.jsx
new file mode 100644
index 00000000..c3cc19dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/reader-menu/container.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ReaderMenu from './component';
+import CaptionsService from '/imports/ui/components/captions/service';
+
+const ReaderMenuContainer = (props) => ;
+
+export default withTracker(({ setIsOpen }) => ({
+ closeModal: () => setIsOpen(false),
+ activateCaptions: (locale, settings) => CaptionsService.activateCaptions(locale, settings),
+ getCaptionsSettings: () => CaptionsService.getCaptionsSettings(),
+ ownedLocales: CaptionsService.getOwnedLocales(),
+}))(ReaderMenuContainer);
diff --git a/src/2.7.12/imports/ui/components/captions/reader-menu/styles.js b/src/2.7.12/imports/ui/components/captions/reader-menu/styles.js
new file mode 100644
index 00000000..09bb7886
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/reader-menu/styles.js
@@ -0,0 +1,133 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import {
+ colorGrayDark,
+ colorWhite,
+ colorGrayLabel,
+ colorGrayLight,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize, borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general';
+
+const ReaderMenuModal = styled(ModalSimple)`
+ padding: 1rem;
+`;
+
+const Title = styled.header`
+ display: block;
+ color: ${colorGrayDark};
+ font-size: 1.4rem;
+ text-align: center;
+`;
+
+const Col = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ margin: 0 1.5rem 0 0;
+ justify-content: center;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1.5rem;
+ }
+
+ @media ${smallOnly} {
+ width: 100%;
+ height: unset;
+ }
+`;
+
+const Row = styled.div`
+ display: flex;
+ justify-content: space-between;
+ padding: .2rem 0 .2rem 0;
+`;
+
+const Label = styled.div`
+ flex: 1 0 0;
+`;
+
+const Select = styled.select`
+ background-color: ${colorWhite};
+ border-radius: 0.3rem;
+ color: ${colorGrayLabel};
+ height: 1.6rem;
+ margin-top: 0.4rem;
+ width: 50%;
+`;
+
+const Swatch = styled.div`
+ flex: 1 0 0;
+ border-radius: ${borderSize};
+ border: ${borderSize} solid ${colorGrayLight};
+ display: inline-block;
+ vertical-align: middle;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary};
+ border-radius: ${borderSize};
+ }
+`;
+
+const SwatchInner = styled.div`
+ width: auto;
+ height: 1.1rem;
+ border-radius: ${borderSize};
+`;
+
+const ColorPickerPopover = styled.div`
+ position: absolute;
+ z-index: 1001;
+`;
+
+const ColorPickerOverlay = styled.div`
+ position: fixed;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+`;
+
+const Footer = styled.div`
+ display: flex;
+`;
+
+const Actions = styled.div`
+ margin-left: auto;
+ margin-right: 3px;
+
+ [dir="rtl"] & {
+ margin-right: auto;
+ margin-left: 3px;
+ }
+
+ > * {
+ &:first-child {
+ margin-right: 3px;
+ margin-left: inherit;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: 3px;
+ }
+ }
+ }
+`;
+
+export default {
+ ReaderMenuModal,
+ Title,
+ Col,
+ Row,
+ Label,
+ Select,
+ Swatch,
+ SwatchInner,
+ ColorPickerPopover,
+ ColorPickerOverlay,
+ Footer,
+ Actions,
+};
diff --git a/src/2.7.12/imports/ui/components/captions/service.js b/src/2.7.12/imports/ui/components/captions/service.js
new file mode 100644
index 00000000..098fb63d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/service.js
@@ -0,0 +1,261 @@
+import Captions from '/imports/api/captions';
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import PadsService from '/imports/ui/components/pads/service';
+import SpeechService from '/imports/ui/components/captions/speech/service';
+import { makeCall } from '/imports/ui/services/api';
+import { Meteor } from 'meteor/meteor';
+import { Session } from 'meteor/session';
+import { isCaptionsEnabled } from '/imports/ui/services/features';
+
+const CAPTIONS_CONFIG = Meteor.settings.public.captions;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const LINE_BREAK = '\n';
+
+const getAvailableLocales = () => {
+ const availableLocales = Captions.find(
+ { meetingId: Auth.meetingID, ownerId: '' },
+ { sort: { locale: 1 } },
+ { fields: { ownerId: 1, locale: 1, name: 1 } },
+ ).fetch();
+
+ return availableLocales;
+};
+
+const getOwnedLocales = () => {
+ const ownedLocales = Captions.find(
+ { meetingId: Auth.meetingID, ownerId: { $not: '' } },
+ { fields: { ownerId: 1, locale: 1, name: 1 } },
+ ).fetch();
+
+ return ownedLocales;
+};
+
+const updateCaptionsOwner = (locale, name) => makeCall('updateCaptionsOwner', locale, name);
+
+const startDictation = (locale) => makeCall('startDictation', locale);
+
+const stopDictation = (locale) => makeCall('stopDictation', locale);
+
+const getCaptionsSettings = () => {
+ const settings = Session.get('captionsSettings');
+ if (settings) return settings;
+
+ const {
+ background,
+ font,
+ } = CAPTIONS_CONFIG;
+
+ return {
+ backgroundColor: background,
+ fontColor: font.color,
+ fontFamily: font.family,
+ fontSize: font.size,
+ };
+};
+
+const setCaptionsSettings = (settings) => Session.set('captionsSettings', settings);
+
+const getCaptionsLocale = () => Session.get('captionsLocale') || '';
+
+const getCaptions = () => {
+ const locale = getCaptionsLocale();
+ if (locale) {
+ const {
+ name,
+ ownerId,
+ dictating,
+ } = Captions.findOne({
+ meetingId: Auth.meetingID,
+ locale,
+ });
+
+ return {
+ locale,
+ name,
+ ownerId,
+ dictating,
+ };
+ }
+
+ return {
+ locale,
+ name: '',
+ ownerId: '',
+ dictating: false,
+ };
+};
+
+const setCaptionsLocale = (locale) => Session.set('captionsLocale', locale);
+
+const getCaptionsActive = () => Session.get('captionsActive') || '';
+
+const formatCaptionsText = (text) => {
+ const splitText = text.split(LINE_BREAK);
+ const filteredText = splitText.filter((line, index) => {
+ const lastLine = index === (splitText.length - 1);
+ const emptyLine = line.length === 0;
+
+ return (!emptyLine || lastLine);
+ });
+
+ while (filteredText.length > CAPTIONS_CONFIG.lines) filteredText.shift();
+
+ return filteredText.join(LINE_BREAK);
+};
+
+const getCaptionsData = () => {
+ const locale = getCaptionsActive();
+ if (locale) {
+ const captions = Captions.findOne({
+ meetingId: Auth.meetingID,
+ locale,
+ dictating: true,
+ });
+
+ let data = '';
+ if (captions) {
+ data = captions.transcript;
+ } else {
+ data = PadsService.getPadTail(locale);
+ }
+
+ return formatCaptionsText(data);
+ }
+
+ return '';
+};
+
+const setCaptionsActive = (locale) => Session.set('captionsActive', locale);
+
+const amICaptionsOwner = (ownerId) => ownerId === Auth.userID;
+
+const isCaptionsAvailable = () => {
+ if (!CAPTIONS_CONFIG.showButton) {
+ return false;
+ }
+
+ if (isCaptionsEnabled()) {
+ const ownedLocales = getOwnedLocales();
+
+ return (ownedLocales.length > 0);
+ }
+
+ return false;
+};
+
+const isCaptionsActive = () => {
+ const enabled = isCaptionsEnabled();
+ const activated = getCaptionsActive() !== '';
+
+ return (enabled && activated);
+};
+
+const deactivateCaptions = () => setCaptionsActive('');
+
+const activateCaptions = (locale, settings) => {
+ setCaptionsSettings(settings);
+ setCaptionsActive(locale);
+};
+
+const amIModerator = () => {
+ const user = Users.findOne(
+ { userId: Auth.userID },
+ { fields: { role: 1 } },
+ );
+
+ return user && user.role === ROLE_MODERATOR;
+};
+
+const getName = (locale) => {
+ const captions = Captions.findOne({
+ meetingId: Auth.meetingID,
+ locale,
+ });
+
+ return captions?.name;
+};
+
+const createCaptions = (locale) => {
+ const name = getName(locale);
+ PadsService.createGroup(locale, CAPTIONS_CONFIG.id, name);
+ updateCaptionsOwner(locale, name);
+ setCaptionsLocale(locale);
+};
+
+const hasPermission = () => {
+ if (amIModerator()) {
+ const { ownerId } = getCaptions();
+
+ return Auth.userID === ownerId;
+ }
+
+ return false;
+};
+
+const getDictationStatus = () => {
+ if (!CAPTIONS_CONFIG.dictation || !amIModerator()) {
+ return {
+ locale: '',
+ dictating: false,
+ };
+ }
+
+ const captions = Captions.findOne({
+ meetingId: Auth.meetingID,
+ ownerId: Auth.userID,
+ }, {
+ fields: {
+ locale: 1,
+ dictating: 1,
+ },
+ });
+
+ if (captions) {
+ return {
+ locale: captions.locale,
+ dictating: captions.dictating,
+ };
+ }
+
+ return {
+ locale: '',
+ dictating: false,
+ };
+};
+
+const canIDictateThisPad = (ownerId) => {
+ if (!CAPTIONS_CONFIG.dictation) return false;
+
+ if (ownerId !== Auth.userID) return false;
+
+ if (!SpeechService.hasSpeechRecognitionSupport()) return false;
+
+ return true;
+};
+
+export default {
+ ID: CAPTIONS_CONFIG.id,
+ getAvailableLocales,
+ getOwnedLocales,
+ updateCaptionsOwner,
+ startDictation,
+ stopDictation,
+ getCaptionsSettings,
+ getCaptionsData,
+ getCaptions,
+ hasPermission,
+ amICaptionsOwner,
+ isCaptionsEnabled,
+ isCaptionsAvailable,
+ isCaptionsActive,
+ deactivateCaptions,
+ activateCaptions,
+ formatCaptionsText,
+ amIModerator,
+ createCaptions,
+ getCaptionsLocale,
+ setCaptionsLocale,
+ getDictationStatus,
+ canIDictateThisPad,
+};
diff --git a/src/2.7.12/imports/ui/components/captions/speech/component.jsx b/src/2.7.12/imports/ui/components/captions/speech/component.jsx
new file mode 100644
index 00000000..c3147235
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/speech/component.jsx
@@ -0,0 +1,143 @@
+import { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { notify } from '/imports/ui/services/notification';
+import logger from '/imports/startup/client/logger';
+import CaptionsService from '/imports/ui/components/captions/service';
+import Service from './service';
+
+const intlMessages = defineMessages({
+ start: {
+ id: 'app.captions.speech.start',
+ description: 'Notification on speech recognition start',
+ },
+ stop: {
+ id: 'app.captions.speech.stop',
+ description: 'Notification on speech recognition stop',
+ },
+ error: {
+ id: 'app.captions.speech.error',
+ description: 'Notification on speech recognition error',
+ },
+});
+
+class Speech extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onStop = this.onStop.bind(this);
+ this.onError = this.onError.bind(this);
+ this.onResult = this.onResult.bind(this);
+
+ this.result = {
+ transcript: '',
+ isFinal: true,
+ };
+
+ this.speechRecognition = Service.initSpeechRecognition();
+
+ if (this.speechRecognition) {
+ this.speechRecognition.onstart = () => notify(props.intl.formatMessage(intlMessages.start), 'info', 'closed_caption');
+ this.speechRecognition.onend = () => notify(props.intl.formatMessage(intlMessages.stop), 'info', 'closed_caption');
+ this.speechRecognition.onerror = (event) => this.onError(event);
+ this.speechRecognition.onresult = (event) => this.onResult(event);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ locale,
+ dictating,
+ } = this.props;
+
+ // Start dictating
+ if (!prevProps.dictating && dictating) {
+ if (this.speechRecognition) {
+ this.speechRecognition.lang = locale;
+ try {
+ this.speechRecognition.start();
+ } catch (event) {
+ this.onError(event.error);
+ }
+ }
+ }
+
+ // Stop dictating
+ if (prevProps.dictating && !dictating) {
+ this.onStop();
+ }
+ }
+
+ componentWillUnmount() {
+ this.onStop();
+ }
+
+ onError(error) {
+ this.onStop();
+
+ const {
+ intl,
+ locale,
+ } = this.props;
+
+ notify(intl.formatMessage(intlMessages.error), 'error', 'warning');
+ CaptionsService.stopDictation(locale);
+ logger.error({
+ logCode: 'captions_speech_recognition',
+ extraInfo: { error },
+ }, 'Captions speech recognition error');
+ }
+
+ onStop() {
+ const { locale } = this.props;
+
+ if (this.speechRecognition) {
+ const {
+ isFinal,
+ transcript,
+ } = this.result;
+
+ if (!isFinal) {
+ Service.pushFinalTranscript(locale, transcript);
+ this.speechRecognition.abort();
+ } else {
+ this.speechRecognition.stop();
+ }
+ }
+ }
+
+ onResult(event) {
+ const { locale } = this.props;
+
+ const {
+ resultIndex,
+ results,
+ } = event;
+
+ const { transcript } = results[resultIndex][0];
+ const { isFinal } = results[resultIndex];
+
+ this.result.transcript = transcript;
+ this.result.isFinal = isFinal;
+
+ if (isFinal) {
+ Service.pushFinalTranscript(locale, transcript);
+ } else {
+ Service.pushInterimTranscript(locale, transcript);
+ }
+ }
+
+ render() {
+ return null;
+ }
+}
+
+Speech.propTypes = {
+ locale: PropTypes.string.isRequired,
+ dictating: PropTypes.bool.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+export default injectIntl(Speech);
diff --git a/src/2.7.12/imports/ui/components/captions/speech/container.jsx b/src/2.7.12/imports/ui/components/captions/speech/container.jsx
new file mode 100644
index 00000000..2ffa6460
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/speech/container.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import CaptionsService from '/imports/ui/components/captions/service';
+import Speech from './component';
+
+const Container = (props) => ;
+
+export default withTracker(() => {
+ const {
+ locale,
+ dictating,
+ } = CaptionsService.getDictationStatus();
+
+ return {
+ locale,
+ dictating,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/captions/speech/service.js b/src/2.7.12/imports/ui/components/captions/speech/service.js
new file mode 100644
index 00000000..01d936f9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/speech/service.js
@@ -0,0 +1,43 @@
+import { throttle } from '/imports/utils/throttle';
+import { makeCall } from '/imports/ui/services/api';
+
+const DEFAULT_LANGUAGE = 'en-US';
+const THROTTLE_TIMEOUT = 2000;
+
+const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition;
+
+const hasSpeechRecognitionSupport = () => typeof SpeechRecognitionAPI !== 'undefined';
+
+const initSpeechRecognition = (locale = DEFAULT_LANGUAGE) => {
+ if (hasSpeechRecognitionSupport()) {
+ const speechRecognition = new SpeechRecognitionAPI();
+ speechRecognition.continuous = true;
+ speechRecognition.interimResults = true;
+ speechRecognition.lang = locale;
+
+ return speechRecognition;
+ }
+
+ return null;
+};
+
+const pushSpeechTranscript = (locale, transcript, type) => makeCall('pushSpeechTranscript', locale, transcript, type);
+
+const throttledTranscriptPush = throttle(pushSpeechTranscript, THROTTLE_TIMEOUT, {
+ leading: false,
+ trailing: true,
+});
+
+const pushInterimTranscript = (locale, transcript) => throttledTranscriptPush(locale, transcript, 'interim');
+
+const pushFinalTranscript = (locale, transcript) => {
+ throttledTranscriptPush.cancel();
+ pushSpeechTranscript(locale, transcript, 'final');
+};
+
+export default {
+ hasSpeechRecognitionSupport,
+ initSpeechRecognition,
+ pushInterimTranscript,
+ pushFinalTranscript,
+};
diff --git a/src/2.7.12/imports/ui/components/captions/styles.js b/src/2.7.12/imports/ui/components/captions/styles.js
new file mode 100644
index 00000000..e3578c33
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/styles.js
@@ -0,0 +1,27 @@
+import styled from 'styled-components';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ mdPaddingX,
+ mdPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const Captions = styled.div`
+ background-color: ${colorWhite};
+ padding: ${mdPaddingY} ${mdPaddingY} ${mdPaddingX} ${mdPaddingX};
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+
+ ${({ isChrome }) => isChrome && `
+ transform: translateZ(0);
+ `}
+
+ @media ${smallOnly} {
+ transform: none !important;
+ }
+`;
+
+export default { Captions };
diff --git a/src/2.7.12/imports/ui/components/captions/writer-menu/component.jsx b/src/2.7.12/imports/ui/components/captions/writer-menu/component.jsx
new file mode 100644
index 00000000..77da4699
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/writer-menu/component.jsx
@@ -0,0 +1,161 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Service from '/imports/ui/components/captions/service';
+import LocalesDropdown from '/imports/ui/components/common/locales-dropdown/component';
+import Styled from './styles';
+import { PANELS, ACTIONS } from '../../layout/enums';
+
+const intlMessages = defineMessages({
+ closeLabel: {
+ id: 'app.captions.menu.closeLabel',
+ description: 'Label for closing captions menu',
+ },
+ title: {
+ id: 'app.captions.menu.title',
+ description: 'Title for the closed captions menu',
+ },
+ subtitle: {
+ id: 'app.captions.menu.subtitle',
+ description: 'Subtitle for the closed captions writer menu',
+ },
+ start: {
+ id: 'app.captions.menu.start',
+ description: 'Write closed captions',
+ },
+ ariaStart: {
+ id: 'app.captions.menu.ariaStart',
+ description: 'aria label for start captions button',
+ },
+ ariaStartDesc: {
+ id: 'app.captions.menu.ariaStartDesc',
+ description: 'aria description for start captions button',
+ },
+ select: {
+ id: 'app.captions.menu.select',
+ description: 'Select closed captions available language',
+ },
+ ariaSelect: {
+ id: 'app.captions.menu.ariaSelect',
+ description: 'Aria label for captions language selector',
+ },
+});
+
+const propTypes = {
+ availableLocales: PropTypes.arrayOf(PropTypes.object).isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+class WriterMenu extends PureComponent {
+ constructor(props) {
+ super(props);
+ const { availableLocales, intl } = this.props;
+
+ const candidate = availableLocales.filter(
+ (l) => l.locale.substring(0, 2) === intl.locale.substring(0, 2),
+ );
+
+ this.state = {
+ locale: candidate && candidate[0] ? candidate[0].locale : null,
+ };
+
+ this.handleChange = this.handleChange.bind(this);
+ this.handleStart = this.handleStart.bind(this);
+ }
+
+ componentWillUnmount() {
+ const { setIsOpen } = this.props;
+
+ setIsOpen(false);
+ }
+
+ handleChange(event) {
+ this.setState({ locale: event.target.value });
+ }
+
+ handleStart() {
+ const {
+ setIsOpen,
+ layoutContextDispatch,
+ } = this.props;
+
+ const { locale } = this.state;
+ Service.createCaptions(locale);
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CAPTIONS,
+ });
+
+ setIsOpen(false);
+ }
+
+ render() {
+ const {
+ intl,
+ availableLocales,
+ isOpen,
+ onRequestClose,
+ priority,
+ setIsOpen
+
+ } = this.props;
+
+ const { locale } = this.state;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.subtitle)}
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.ariaStartDesc)}
+
+
+ );
+ }
+}
+
+WriterMenu.propTypes = propTypes;
+
+export default injectIntl(WriterMenu);
diff --git a/src/2.7.12/imports/ui/components/captions/writer-menu/container.jsx b/src/2.7.12/imports/ui/components/captions/writer-menu/container.jsx
new file mode 100644
index 00000000..5a4610cc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/writer-menu/container.jsx
@@ -0,0 +1,25 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from '/imports/ui/components/captions/service';
+import WriterMenu from './component';
+import { layoutDispatch } from '../../layout/context';
+import Auth from '/imports/ui/services/auth';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const WriterMenuContainer = (props) => {
+ const layoutContextDispatch = layoutDispatch();
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+
+ return ;
+};
+
+export default withTracker(({ setIsOpen }) => ({
+ closeModal: () => setIsOpen(false),
+ availableLocales: Service.getAvailableLocales(),
+}))(WriterMenuContainer);
diff --git a/src/2.7.12/imports/ui/components/captions/writer-menu/styles.js b/src/2.7.12/imports/ui/components/captions/writer-menu/styles.js
new file mode 100644
index 00000000..9ca2c9ed
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/captions/writer-menu/styles.js
@@ -0,0 +1,81 @@
+import styled from 'styled-components';
+import {
+ borderSize,
+ borderSizeLarge,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorWhite,
+ colorLink,
+ colorGrayLighter,
+ colorGrayLabel,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import Button from '/imports/ui/components/common/button/component';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+
+const WriterMenuModal = styled(ModalSimple)`
+ min-height: 20rem;
+`;
+
+const Content = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ padding: .3rem 0 0.5rem 0;
+`;
+
+const StartBtn = styled(Button)`
+ align-self: center;
+ margin: 0;
+ display: block;
+ position: absolute;
+ bottom: ${mdPaddingX};
+ color: ${colorWhite} !important;
+ background-color: ${colorLink} !important;
+
+ &:focus {
+ outline: none !important;
+ }
+
+ & > i {
+ color: #3c5764;
+ }
+`;
+
+const WriterMenuSelect = styled.div`
+ width: 40%;
+
+ & > select {
+ background-color: ${colorWhite};
+ border: ${borderSize} solid ${colorWhite};
+ border-radius: ${borderSize};
+ border-bottom: 0.1rem solid ${colorGrayLighter};
+ color: ${colorGrayLabel};
+ width: 100%;
+ height: 1.75rem;
+ padding: 1px;
+
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary};
+ border-radius: ${borderSize};
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ }
+ }
+`;
+
+export default {
+ WriterMenuModal,
+ Content,
+ StartBtn,
+ WriterMenuSelect,
+};
diff --git a/src/2.7.12/imports/ui/components/chat/alert/component.jsx b/src/2.7.12/imports/ui/components/chat/alert/component.jsx
new file mode 100644
index 00000000..b5beb691
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/alert/component.jsx
@@ -0,0 +1,269 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { Meteor } from 'meteor/meteor';
+import { defineMessages, injectIntl } from 'react-intl';
+import injectNotify from '/imports/ui/components/common/toast/inject-notify/component';
+import AudioService from '/imports/ui/components/audio/service';
+import ChatPushAlert from './push-alert/component';
+import { stripTags, unescapeHtml, uniqueId } from '/imports/utils/string-utils';
+import Service from '../service';
+import Styled from './styles';
+import { usePreviousValue } from '/imports/ui/components/utils/hooks';
+import { Session } from 'meteor/session';
+import { isEqual } from 'radash';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_CLEAR = CHAT_CONFIG.chat_clear;
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+const POLL_RESULT_KEY = CHAT_CONFIG.system_messages_keys.chat_poll_result;
+const EXPORTED_PRESENTATION_KEY = CHAT_CONFIG.system_messages_keys.chat_exported_presentation;
+
+const propTypes = {
+ pushAlertEnabled: PropTypes.bool.isRequired,
+ audioAlertEnabled: PropTypes.bool.isRequired,
+ unreadMessagesCountByChat: PropTypes.arrayOf(PropTypes.object),
+ unreadMessagesByChat: PropTypes.arrayOf(PropTypes.array),
+ idChatOpen: PropTypes.string.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+const defaultProps = {
+ unreadMessagesCountByChat: null,
+ unreadMessagesByChat: null,
+};
+
+const intlMessages = defineMessages({
+ appToastChatPublic: {
+ id: 'app.toast.chat.public',
+ description: 'when entry various message',
+ },
+ appToastChatPrivate: {
+ id: 'app.toast.chat.private',
+ description: 'when entry various message',
+ },
+ appToastChatSystem: {
+ id: 'app.toast.chat.system',
+ description: 'system for use',
+ },
+ publicChatClear: {
+ id: 'app.chat.clearPublicChatMessage',
+ description: 'message of when clear the public chat',
+ },
+ publicChatMsg: {
+ id: 'app.toast.chat.public',
+ description: 'public chat toast message title',
+ },
+ privateChatMsg: {
+ id: 'app.toast.chat.private',
+ description: 'private chat toast message title',
+ },
+ pollResults: {
+ id: 'app.toast.chat.poll',
+ description: 'chat toast message for polls',
+ },
+ pollResultsClick: {
+ id: 'app.toast.chat.pollClick',
+ description: 'chat toast click message for polls',
+ },
+ exportedPresentation: {
+ id: 'app.toast.chat.exportedPresentation',
+ description: 'chat toast message for exportedPresentation',
+ }
+});
+
+const ALERT_INTERVAL = 5000; // 5 seconds
+const ALERT_DURATION = 4000; // 4 seconds
+
+const ChatAlert = (props) => {
+ const {
+ audioAlertEnabled,
+ pushAlertEnabled,
+ idChatOpen,
+ unreadMessagesCountByChat,
+ unreadMessagesByChat,
+ intl,
+ layoutContextDispatch,
+ } = props;
+
+ const [unreadMessagesCount, setUnreadMessagesCount] = useState(0);
+ const [unreadMessages, setUnreadMessages] = useState([]);
+ const [lastAlertTimestampByChat, setLastAlertTimestampByChat] = useState({});
+ const [alertEnabledTimestamp, setAlertEnabledTimestamp] = useState(null);
+ const prevUnreadMessages = usePreviousValue(unreadMessages);
+
+ // audio alerts
+ useEffect(() => {
+ if (audioAlertEnabled) {
+ const unreadObject = unreadMessagesCountByChat;
+
+ const unreadCount = document.hidden
+ ? unreadObject.reduce((a, b) => a + b.unreadCounter, 0)
+ : unreadObject.filter((chat) => chat.chatId !== idChatOpen)
+ .reduce((a, b) => a + b.unreadCounter, 0);
+
+ if (unreadCount > unreadMessagesCount) {
+ AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId}`
+ + '/resources/sounds/notify.mp3');
+ }
+
+ setUnreadMessagesCount(unreadCount);
+ }
+ }, [unreadMessagesCountByChat]);
+
+ // push alerts
+ useEffect(() => {
+ if (pushAlertEnabled) {
+ setAlertEnabledTimestamp(new Date().getTime());
+ }
+ }, [pushAlertEnabled]);
+
+ useEffect(() => {
+ if (pushAlertEnabled) {
+ const alertsObject = unreadMessagesByChat;
+
+ let timewindowsToAlert = [];
+ let filteredTimewindows = [];
+
+ alertsObject.forEach((chat) => {
+ filteredTimewindows = filteredTimewindows.concat(
+ chat.filter((timeWindow) => timeWindow.timestamp > alertEnabledTimestamp),
+ );
+ });
+
+ filteredTimewindows.forEach((timeWindow) => {
+ const durationDiff = ALERT_DURATION - (new Date().getTime() - timeWindow.timestamp);
+
+ if ((timeWindow.lastTimestamp > timeWindow.timestamp && durationDiff > 0
+ && timeWindow.lastTimestamp > (lastAlertTimestampByChat[timeWindow.chatId] || 0))
+ || timeWindow.timestamp
+ > (lastAlertTimestampByChat[timeWindow.chatId] || 0) + ALERT_INTERVAL) {
+ timewindowsToAlert = timewindowsToAlert
+ .filter((item) => item.chatId !== timeWindow.chatId);
+ const newTimeWindow = { ...timeWindow };
+ newTimeWindow.durationDiff = durationDiff;
+ timewindowsToAlert.push(newTimeWindow);
+
+ const newLastAlertTimestampByChat = { ...lastAlertTimestampByChat };
+ if (timeWindow.timestamp > (lastAlertTimestampByChat[timeWindow.chatId] || 0)) {
+ newLastAlertTimestampByChat[timeWindow.chatId] = timeWindow.timestamp;
+ setLastAlertTimestampByChat(newLastAlertTimestampByChat);
+ }
+ }
+ });
+ setUnreadMessages(timewindowsToAlert);
+ }
+ }, [unreadMessagesByChat]);
+
+ const mapContentText = (message) => {
+ const contentMessage = message
+ .map((content) => {
+ if (content.text === PUBLIC_CHAT_CLEAR) {
+ return intl.formatMessage(intlMessages.publicChatClear);
+ }
+
+ return unescapeHtml(stripTags(content.text));
+ });
+
+ return contentMessage;
+ };
+
+ const createMessage = (name, message) => (
+
+ {name}
+
+ {
+ mapContentText(message)
+ .reduce((acc, text) => [...acc, ( ), text], [])
+ }
+
+
+ );
+
+ const createPollMessage = () => (
+
+
+ {intl.formatMessage(intlMessages.pollResults)}
+
+
+ {intl.formatMessage(intlMessages.pollResultsClick)}
+
+
+ );
+
+ const createExportedPresentationMessage = (filename) => (
+
+ {intl.formatMessage(intlMessages.exportedPresentation)}
+ {filename}
+
+ );
+
+ if (isEqual(prevUnreadMessages, unreadMessages)) {
+ return null;
+ }
+
+ return pushAlertEnabled
+ ? unreadMessages.map((timeWindow) => {
+ const mappedMessage = Service.mapGroupMessage(timeWindow);
+
+ let content = null;
+ let isPollResult = false;
+ if (mappedMessage) {
+ if (mappedMessage.id.includes(POLL_RESULT_KEY)) {
+ content = createPollMessage();
+ isPollResult = true;
+ } else if (mappedMessage.id.includes(EXPORTED_PRESENTATION_KEY)) {
+ content = createExportedPresentationMessage(mappedMessage.extra.filename);
+ } else {
+ content = createMessage(mappedMessage.sender.name, mappedMessage.content.slice(-5));
+ }
+ }
+
+ const messageChatId = mappedMessage.chatId === 'MAIN-PUBLIC-GROUP-CHAT' ? PUBLIC_CHAT_ID : mappedMessage.chatId;
+
+ const newUnreadMessages = unreadMessages
+ .filter((message) => message.key !== mappedMessage.key);
+
+ return content
+ ? (
+ {intl.formatMessage(intlMessages.appToastChatPublic)}
+ : {intl.formatMessage(intlMessages.appToastChatPrivate)}
+ }
+ onOpen={
+ () => {
+ if (isPollResult) {
+ Session.set('ignorePollNotifications', true);
+ }
+
+ setUnreadMessages(newUnreadMessages);
+ }
+ }
+ onClose={
+ () => {
+ if (isPollResult) {
+ Session.set('ignorePollNotifications', false);
+ }
+
+ setUnreadMessages(newUnreadMessages);
+ }
+ }
+ alertDuration={timeWindow.durationDiff}
+ layoutContextDispatch={layoutContextDispatch}
+ />
+ ) : null;
+ })
+ : null;
+};
+ChatAlert.propTypes = propTypes;
+ChatAlert.defaultProps = defaultProps;
+
+export default injectNotify(injectIntl(ChatAlert));
diff --git a/src/2.7.12/imports/ui/components/chat/alert/container.jsx b/src/2.7.12/imports/ui/components/chat/alert/container.jsx
new file mode 100644
index 00000000..3420d560
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/alert/container.jsx
@@ -0,0 +1,70 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import ChatAlert from './component';
+import { layoutSelect, layoutSelectInput, layoutDispatch } from '../../layout/context';
+import { PANELS } from '../../layout/enums';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import { ChatContext } from '/imports/ui/components/components-data/chat-context/context';
+import { GroupChatContext } from '/imports/ui/components/components-data/group-chat-context/context';
+import userListService from '/imports/ui/components/user-list/service';
+import UnreadMessages from '/imports/ui/services/unread-messages';
+
+const PUBLIC_CHAT_ID = Meteor.settings.public.chat.public_group_id;
+
+const propTypes = {
+ audioAlertEnabled: PropTypes.bool.isRequired,
+ pushAlertEnabled: PropTypes.bool.isRequired,
+};
+
+const ChatAlertContainer = (props) => {
+ const idChatOpen = layoutSelect((i) => i.idChatOpen);
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+
+ const { audioAlertEnabled, pushAlertEnabled } = props;
+
+ let idChat = idChatOpen;
+ if (sidebarContentPanel !== PANELS.CHAT) idChat = '';
+
+ const usingUsersContext = useContext(UsersContext);
+ const usingChatContext = useContext(ChatContext);
+ const usingGroupChatContext = useContext(GroupChatContext);
+
+ const { users } = usingUsersContext;
+ const { chats: groupChatsMessages } = usingChatContext;
+ const { groupChat: groupChats } = usingGroupChatContext;
+
+ const activeChats = userListService.getActiveChats({ groupChatsMessages, groupChats, users });
+
+ // audio alerts
+ const unreadMessagesCountByChat = audioAlertEnabled
+ ? activeChats.map((chat) => ({
+ chatId: chat.chatId, unreadCounter: chat.unreadCounter,
+ }))
+ : null;
+
+ // push alerts
+ const unreadMessagesByChat = pushAlertEnabled
+ ? activeChats.filter(
+ (chat) => chat.unreadCounter > 0 && chat.chatId !== idChat,
+ ).map((chat) => {
+ const chatId = (chat.chatId === 'public') ? PUBLIC_CHAT_ID : chat.chatId;
+ return UnreadMessages.getUnreadMessages(chatId, groupChatsMessages);
+ })
+ : null;
+
+ return (
+
+ );
+};
+
+ChatAlertContainer.propTypes = propTypes;
+
+export default ChatAlertContainer;
diff --git a/src/2.7.12/imports/ui/components/chat/alert/push-alert/component.jsx b/src/2.7.12/imports/ui/components/chat/alert/push-alert/component.jsx
new file mode 100644
index 00000000..4a6af093
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/alert/push-alert/component.jsx
@@ -0,0 +1,83 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import injectNotify from '/imports/ui/components/common/toast/inject-notify/component';
+import { PANELS, ACTIONS } from '../../../layout/enums';
+
+const propTypes = {
+ notify: PropTypes.func.isRequired,
+ onOpen: PropTypes.func.isRequired,
+ chatId: PropTypes.string.isRequired,
+ title: PropTypes.node.isRequired,
+ content: PropTypes.node.isRequired,
+ alertDuration: PropTypes.number.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+};
+
+class ChatPushAlert extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.showNotify = this.showNotify.bind(this);
+
+ this.componentDidMount = this.showNotify;
+ this.componentDidUpdate = this.showNotify;
+ this.link = this.link.bind(this);
+ }
+
+ link(title, chatId) {
+ const { layoutContextDispatch } = this.props;
+
+ return (
+ {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: chatId,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+ }}
+ onKeyPress={() => null}
+ >
+ {title}
+
+ );
+ }
+
+ showNotify() {
+ const {
+ notify,
+ onOpen,
+ onClose,
+ chatId,
+ title,
+ content,
+ alertDuration,
+ } = this.props;
+
+ return notify(
+ this.link(title, chatId),
+ 'info',
+ 'chat',
+ { onOpen, onClose, autoClose: alertDuration },
+ this.link(content, chatId),
+ true,
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+ChatPushAlert.propTypes = propTypes;
+
+export default injectNotify(ChatPushAlert);
diff --git a/src/2.7.12/imports/ui/components/chat/alert/styles.js b/src/2.7.12/imports/ui/components/chat/alert/styles.js
new file mode 100644
index 00000000..d4719591
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/alert/styles.js
@@ -0,0 +1,55 @@
+import styled from 'styled-components';
+import { colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
+import { borderRadius } from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+
+const PushMessageContent = styled.div`
+ margin-top: 1.4rem;
+ margin-bottom: .4rem;
+ margin-left: .4rem;
+ margin-right: .4rem;
+ background-color: inherit;
+ width: 98%;
+`;
+
+const UserNameMessage = styled.h3`
+ margin: 0;
+ font-size: 80%;
+ color: ${colorGrayDark};
+ font-weight: bold;
+ background-color: inherit;
+ position: relative;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1em;
+ max-height: 1em;
+`;
+
+const ContentMessage = styled.div`
+ margin-top: ${borderRadius};
+ font-size: 80%;
+ background-color: inherit;
+ position: relative;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: ${fontSizeSmall};
+ max-height: calc(${fontSizeSmall} * 10);
+`;
+
+const ContentMessagePoll = styled(ContentMessage)`
+ margin-top: ${fontSizeSmall};
+`;
+
+const ContentMessageExportedPresentation = styled(ContentMessage)`
+ margin-top: ${fontSizeSmall};
+`;
+
+export default {
+ PushMessageContent,
+ UserNameMessage,
+ ContentMessage,
+ ContentMessagePoll,
+ ContentMessageExportedPresentation,
+};
diff --git a/src/2.7.12/imports/ui/components/chat/chat-dropdown/component.jsx b/src/2.7.12/imports/ui/components/chat/chat-dropdown/component.jsx
new file mode 100644
index 00000000..d13c4efa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/chat-dropdown/component.jsx
@@ -0,0 +1,161 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import { getDateString, uniqueId } from '/imports/utils/string-utils';
+import Trigger from '/imports/ui/components/common/control-header/right/component';
+
+import ChatService from '../service';
+import { addNewAlert } from '../../screenreader-alert/service';
+
+const intlMessages = defineMessages({
+ clear: {
+ id: 'app.chat.dropdown.clear',
+ description: 'Clear button label',
+ },
+ save: {
+ id: 'app.chat.dropdown.save',
+ description: 'Clear button label',
+ },
+ copy: {
+ id: 'app.chat.dropdown.copy',
+ description: 'Copy button label',
+ },
+ copySuccess: {
+ id: 'app.chat.copySuccess',
+ description: 'aria success alert',
+ },
+ copyErr: {
+ id: 'app.chat.copyErr',
+ description: 'aria error alert',
+ },
+ options: {
+ id: 'app.chat.dropdown.options',
+ description: 'Chat Options',
+ },
+});
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const ENABLE_SAVE_AND_COPY_PUBLIC_CHAT = CHAT_CONFIG.enableSaveAndCopyPublicChat;
+
+class ChatDropdown extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.actionsKey = [
+ uniqueId('action-item-'),
+ uniqueId('action-item-'),
+ uniqueId('action-item-'),
+ ];
+ }
+
+ getAvailableActions() {
+ const {
+ intl,
+ isMeteorConnected,
+ amIModerator,
+ meetingIsBreakout,
+ meetingName,
+ timeWindowsValues,
+ } = this.props;
+
+ const clearIcon = 'delete';
+ const saveIcon = 'download';
+ const copyIcon = 'copy';
+
+ this.menuItems = [];
+
+ if (ENABLE_SAVE_AND_COPY_PUBLIC_CHAT) {
+ this.menuItems.push(
+ {
+ key: this.actionsKey[0],
+ icon: saveIcon,
+ dataTest: 'chatSave',
+ label: intl.formatMessage(intlMessages.save),
+ onClick: () => {
+ const link = document.createElement('a');
+ const mimeType = 'text/plain';
+ link.setAttribute('download', `bbb-${meetingName}[public-chat]_${getDateString()}.txt`);
+ link.setAttribute(
+ 'href',
+ `data: ${mimeType};charset=utf-8,`
+ + `${encodeURIComponent(ChatService.exportChat(timeWindowsValues, intl))}`,
+ );
+ link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
+ },
+ },
+ );
+ }
+
+ if (ENABLE_SAVE_AND_COPY_PUBLIC_CHAT) {
+ this.menuItems.push(
+ {
+ key: this.actionsKey[1],
+ icon: copyIcon,
+ id: 'clipboardButton',
+ dataTest: 'chatCopy',
+ label: intl.formatMessage(intlMessages.copy),
+ onClick: () => {
+ const chatHistory = ChatService.exportChat(timeWindowsValues, intl);
+ navigator.clipboard.writeText(chatHistory).then(() => {
+ addNewAlert(intl.formatMessage(intlMessages.copySuccess));
+ }).catch(() => {
+ addNewAlert(intl.formatMessage(intlMessages.copyErr));
+ });
+ },
+ },
+ );
+ }
+
+ if (!meetingIsBreakout && amIModerator && isMeteorConnected) {
+ this.menuItems.push(
+ {
+ key: this.actionsKey[2],
+ icon: clearIcon,
+ dataTest: 'chatClear',
+ label: intl.formatMessage(intlMessages.clear),
+ onClick: () => ChatService.clearPublicChatHistory(),
+ },
+ );
+ }
+
+ return this.menuItems;
+ }
+
+ render() {
+ const {
+ intl,
+ amIModerator,
+ isRTL,
+ } = this.props;
+
+ if (!amIModerator && !ENABLE_SAVE_AND_COPY_PUBLIC_CHAT) return null;
+ return (
+ <>
+ null}
+ />
+ }
+ opts={{
+ id: 'chat-options-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ actions={this.getAvailableActions()}
+ />
+ >
+ );
+ }
+}
+
+export default injectIntl(ChatDropdown);
diff --git a/src/2.7.12/imports/ui/components/chat/chat-dropdown/container.jsx b/src/2.7.12/imports/ui/components/chat/chat-dropdown/container.jsx
new file mode 100644
index 00000000..84e3433e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/chat-dropdown/container.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+import ChatDropdown from './component';
+import { layoutSelect } from '../../layout/context';
+
+const ChatDropdownContainer = ({ ...props }) => {
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return ;
+};
+
+export default withTracker(() => {
+ const getMeetingName = () => {
+ const m = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'meetingProp.name': 1 } });
+ return m.meetingProp.name;
+ };
+
+ return {
+ meetingName: getMeetingName(),
+ };
+})(ChatDropdownContainer);
diff --git a/src/2.7.12/imports/ui/components/chat/chat-logger/ChatLogger.js b/src/2.7.12/imports/ui/components/chat/chat-logger/ChatLogger.js
new file mode 100644
index 00000000..fc3ef2f6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/chat-logger/ChatLogger.js
@@ -0,0 +1,37 @@
+
+
+class ChatLogger {
+ constructor() {
+ this.logLevel = 'info';
+ this.levels = Object.freeze({
+ error: 1,
+ info: 2,
+ debug: 3,
+ trace: 4,
+ });
+ Object.keys(this.levels).forEach((i) => {
+ this[i] = this.logger.bind(this, i);
+ });
+ }
+
+ setLogLevel(level) {
+ if (this.levels[level]) {
+ this.logLevel = level;
+ } else {
+ throw new Error('This Level not exist');
+ }
+ }
+
+ getLogLevel() {
+ return this.logLevel;
+ }
+
+ logger(level, ...text) {
+ const logLevel = this.levels[level];
+ if (this.levels[this.logLevel] >= logLevel) {
+ console.log(`${level}:`, ...text);
+ }
+ }
+}
+
+export default new ChatLogger();
diff --git a/src/2.7.12/imports/ui/components/chat/component.jsx b/src/2.7.12/imports/ui/components/chat/component.jsx
new file mode 100644
index 00000000..0e55df2c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/component.jsx
@@ -0,0 +1,181 @@
+import React, { memo, useState } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import { Meteor } from 'meteor/meteor';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import Styled from './styles';
+import MessageFormContainer from './message-form/container';
+import TimeWindowList from './time-window-list/container';
+import ChatDropdownContainer from './chat-dropdown/container';
+import { PANELS, ACTIONS } from '../layout/enums';
+import { UserSentMessageCollection } from './service';
+import Auth from '/imports/ui/services/auth';
+import browserInfo from '/imports/utils/browserInfo';
+import Header from '/imports/ui/components/common/control-header/component';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+const ELEMENT_ID = 'chat-messages';
+
+const intlMessages = defineMessages({
+ closeChatLabel: {
+ id: 'app.chat.closeChatLabel',
+ description: 'aria-label for closing chat button',
+ },
+ hideChatLabel: {
+ id: 'app.chat.hideChatLabel',
+ description: 'aria-label for hiding chat button',
+ },
+});
+
+const Chat = (props) => {
+ const {
+ chatID,
+ title,
+ messages,
+ partnerIsLoggedOut,
+ isChatLocked,
+ actions,
+ intl,
+ shortcuts,
+ isMeteorConnected,
+ lastReadMessageTime,
+ hasUnreadMessages,
+ scrollPosition,
+ amIModerator,
+ meetingIsBreakout,
+ timeWindowsValues,
+ dispatch,
+ count,
+ layoutContextDispatch,
+ syncing,
+ syncedPercent,
+ lastTimeWindowValuesBuild,
+ width,
+ } = props;
+
+ const userSentMessage = UserSentMessageCollection.findOne({ userId: Auth.userID, sent: true });
+ const { isChrome } = browserInfo;
+
+ const HIDE_CHAT_AK = shortcuts.hideprivatechat;
+ const CLOSE_CHAT_AK = shortcuts.closeprivatechat;
+ const isPublicChat = chatID === PUBLIC_CHAT_ID;
+ ChatLogger.debug('ChatComponent::render', props);
+ return (
+
+ {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: '',
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ },
+ }}
+ rightButtonProps={{
+ accessKey: CLOSE_CHAT_AK,
+ 'aria-label': intl.formatMessage(intlMessages.closeChatLabel, { 0: title }),
+ 'data-test': "closePrivateChat",
+ icon: "close",
+ label: intl.formatMessage(intlMessages.closeChatLabel, { 0: title }),
+ onClick: () => {
+ actions.handleClosePrivateChat(chatID);
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: '',
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ },
+ }}
+ customRightButton={isPublicChat && (
+
+ )}
+ />
+
+
+
+ );
+};
+
+export default memo(withShortcutHelper(injectWbResizeEvent(injectIntl(Chat)), ['hidePrivateChat', 'closePrivateChat']));
+
+const propTypes = {
+ chatID: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ shortcuts: PropTypes.objectOf(PropTypes.string),
+ partnerIsLoggedOut: PropTypes.bool.isRequired,
+ isChatLocked: PropTypes.bool.isRequired,
+ isMeteorConnected: PropTypes.bool.isRequired,
+ actions: PropTypes.shape({
+ handleClosePrivateChat: PropTypes.func.isRequired,
+ }).isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+const defaultProps = {
+ shortcuts: [],
+};
+
+Chat.propTypes = propTypes;
+Chat.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/chat/container.jsx b/src/2.7.12/imports/ui/components/chat/container.jsx
new file mode 100644
index 00000000..e3374ea4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/container.jsx
@@ -0,0 +1,271 @@
+import React, { useEffect, useContext, useState } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { withTracker } from 'meteor/react-meteor-data';
+import { throttle } from '/imports/utils/throttle';
+import Auth from '/imports/ui/services/auth';
+import Storage from '/imports/ui/services/storage/session';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { ChatContext, getLoginTime } from '../components-data/chat-context/context';
+import { GroupChatContext } from '../components-data/group-chat-context/context';
+import { UsersContext } from '../components-data/users-context/context';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import Chat from '/imports/ui/components/chat/component';
+import ChatService from './service';
+import { layoutSelect, layoutDispatch } from '../layout/context';
+import { escapeHtml } from '/imports/utils/string-utils';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
+const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
+const CHAT_CLEAR = CHAT_CONFIG.system_messages_keys.chat_clear;
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const DEBOUNCE_TIME = 1000;
+
+const sysMessagesIds = {
+ welcomeId: `${SYSTEM_CHAT_TYPE}-welcome-msg`,
+ moderatorId: `${SYSTEM_CHAT_TYPE}-moderator-msg`,
+ syncId: `${SYSTEM_CHAT_TYPE}-sync-msg`,
+};
+
+const intlMessages = defineMessages({
+ [CHAT_CLEAR]: {
+ id: 'app.chat.clearPublicChatMessage',
+ description: 'message of when clear the public chat',
+ },
+ titlePublic: {
+ id: 'app.chat.titlePublic',
+ description: 'Public chat title',
+ },
+ titlePrivate: {
+ id: 'app.chat.titlePrivate',
+ description: 'Private chat title',
+ },
+ partnerDisconnected: {
+ id: 'app.chat.partnerDisconnected',
+ description: 'System chat message when the private chat partnet disconnect from the meeting',
+ },
+ loading: {
+ id: 'app.chat.loading',
+ description: 'loading message',
+ },
+});
+
+let previousChatId = null;
+let prevSync = false;
+let prevPartnerIsLoggedOut = false;
+
+let globalAppplyStateToProps = () => { };
+
+const throttledFunc = throttle(() => {
+ globalAppplyStateToProps();
+}, DEBOUNCE_TIME, { trailing: true, leading: true });
+
+const ChatContainer = (props) => {
+ const {
+ children,
+ loginTime,
+ intl,
+ userLocks,
+ lockSettings,
+ isChatLockedPublic,
+ isChatLockedPrivate,
+ users: propUsers,
+ ...restProps
+ } = props;
+
+ const idChatOpen = layoutSelect((i) => i.idChatOpen);
+ const layoutContextDispatch = layoutDispatch();
+
+ const isPublicChat = idChatOpen === PUBLIC_CHAT_KEY;
+
+ const chatID = idChatOpen;
+
+ if (!chatID) return null;
+
+ useEffect(() => {
+ ChatService.removeFromClosedChatsSession();
+ }, []);
+
+ const modOnlyMessage = Storage.getItem('ModeratorOnlyMessage');
+ const { welcomeProp } = ChatService.getWelcomeProp();
+
+ ChatLogger.debug('ChatContainer::render::props', props);
+
+ const systemMessages = {
+ [sysMessagesIds.welcomeId]: {
+ id: sysMessagesIds.welcomeId,
+ content: [{
+ id: sysMessagesIds.welcomeId,
+ text: welcomeProp.welcomeMsg,
+ time: loginTime,
+ }],
+ key: sysMessagesIds.welcomeId,
+ time: loginTime,
+ sender: null,
+ },
+ [sysMessagesIds.moderatorId]: {
+ id: sysMessagesIds.moderatorId,
+ content: [{
+ id: sysMessagesIds.moderatorId,
+ text: modOnlyMessage,
+ time: loginTime + 1,
+ }],
+ key: sysMessagesIds.moderatorId,
+ time: loginTime + 1,
+ sender: null,
+ },
+ };
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const amIModerator = currentUser.role === ROLE_MODERATOR;
+ const systemMessagesIds = [
+ sysMessagesIds.welcomeId,
+ amIModerator && modOnlyMessage && sysMessagesIds.moderatorId,
+ ].filter((i) => i);
+
+ const usingChatContext = useContext(ChatContext);
+ const usingGroupChatContext = useContext(GroupChatContext);
+ const [stateLastMsg, setLastMsg] = useState(null);
+
+ const [
+ stateTimeWindows, setTimeWindows,
+ ] = useState(isPublicChat ? [...systemMessagesIds.map((item) => systemMessages[item])] : []);
+ const [lastTimeWindowValuesBuild, setLastTimeWindowValuesBuild] = useState(0);
+
+ const { groupChat } = usingGroupChatContext;
+ const participants = groupChat[idChatOpen]?.participants;
+ const chatName = participants?.filter((user) => user.id !== Auth.userID)[0]?.name;
+ const title = chatName
+ ? intl.formatMessage(intlMessages.titlePrivate, { 0: chatName })
+ : intl.formatMessage(intlMessages.titlePublic);
+
+ let partnerIsLoggedOut = false;
+
+ let isChatLocked;
+ if (!isPublicChat) {
+ const idUser = participants?.filter((user) => user.id !== Auth.userID)[0]?.id;
+ partnerIsLoggedOut = !!(users[Auth.meetingID][idUser]?.loggedOut
+ || users[Auth.meetingID][idUser]?.ejected);
+ isChatLocked = isChatLockedPrivate && !(users[Auth.meetingID][idUser]?.role === ROLE_MODERATOR);
+ } else if (users[Auth.meetingID][Auth.userID]?.chatLocked === true) {
+ isChatLocked = true;
+ } else {
+ isChatLocked = isChatLockedPublic;
+ }
+
+ const contextChat = usingChatContext?.chats[isPublicChat ? PUBLIC_GROUP_CHAT_KEY : chatID];
+ const lastTimeWindow = contextChat?.lastTimewindow;
+ const lastMsg = contextChat && (isPublicChat
+ ? contextChat?.preJoinMessages[lastTimeWindow] || contextChat?.posJoinMessages[lastTimeWindow]
+ : contextChat?.messageGroups[lastTimeWindow]);
+ ChatLogger.debug('ChatContainer::render::chatData', contextChat);
+ const applyPropsToState = () => {
+ ChatLogger.debug('ChatContainer::applyPropsToState::chatData', lastMsg, stateLastMsg, contextChat?.syncing);
+ if (
+ (lastMsg?.lastTimestamp !== stateLastMsg?.lastTimestamp)
+ || (previousChatId !== idChatOpen)
+ || (prevSync !== contextChat?.syncing)
+ || (prevPartnerIsLoggedOut !== partnerIsLoggedOut)
+ ) {
+ prevSync = contextChat?.syncing;
+ prevPartnerIsLoggedOut = partnerIsLoggedOut;
+
+ const timeWindowsValues = isPublicChat
+ ? [
+ ...(
+ !contextChat?.syncing ? Object.values(contextChat?.preJoinMessages || {}) : [
+ {
+ id: sysMessagesIds.syncId,
+ content: [{
+ id: 'synced',
+ text: intl.formatMessage(intlMessages.loading, { 0: contextChat?.syncedPercent }),
+ time: loginTime + 1,
+ }],
+ key: sysMessagesIds.syncId,
+ time: loginTime + 1,
+ sender: null,
+ },
+ ]
+ ), ...systemMessagesIds.map((item) => systemMessages[item]),
+ ...Object.values(contextChat?.posJoinMessages || {})]
+ : [...Object.values(contextChat?.messageGroups || {})];
+ if (previousChatId !== idChatOpen) {
+ previousChatId = idChatOpen;
+ }
+
+ if (partnerIsLoggedOut) {
+ const time = Date.now();
+ const id = `partner-disconnected-${time}`;
+ const messagePartnerLoggedOut = {
+ id,
+ content: [{
+ id,
+ text: escapeHtml(intl.formatMessage(intlMessages.partnerDisconnected, { 0: chatName })),
+ time,
+ }],
+ time,
+ sender: null,
+ };
+
+ timeWindowsValues.push(messagePartnerLoggedOut);
+ }
+
+ setLastMsg(lastMsg ? { ...lastMsg } : lastMsg);
+ setTimeWindows(timeWindowsValues);
+ setLastTimeWindowValuesBuild(Date.now());
+ }
+ };
+ globalAppplyStateToProps = applyPropsToState;
+ throttledFunc();
+
+ ChatService.removePackagedClassAttribute(
+ ['ReactVirtualized__Grid', 'ReactVirtualized__Grid__innerScrollContainer'],
+ 'role',
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks }) => {
+ const isChatLockedPublic = userLocks.userPublicChat;
+ const isChatLockedPrivate = userLocks.userPrivateChat;
+
+ const { connected: isMeteorConnected } = Meteor.status();
+
+ return {
+ intl,
+ isChatLockedPublic,
+ isChatLockedPrivate,
+ isMeteorConnected,
+ meetingIsBreakout: meetingIsBreakout(),
+ loginTime: getLoginTime(),
+ actions: {
+ handleClosePrivateChat: ChatService.closePrivateChat,
+ },
+ };
+})(ChatContainer)));
diff --git a/src/2.7.12/imports/ui/components/chat/message-form/component.jsx b/src/2.7.12/imports/ui/components/chat/message-form/component.jsx
new file mode 100644
index 00000000..21da509d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/message-form/component.jsx
@@ -0,0 +1,392 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { checkText } from 'smile2emoji';
+import deviceInfo from '/imports/utils/deviceInfo';
+import PropTypes from 'prop-types';
+import { throttle } from '/imports/utils/throttle';
+import TypingIndicatorContainer from './typing-indicator/container';
+import ClickOutside from '/imports/ui/components/click-outside/component';
+import Styled from './styles';
+import { escapeHtml } from '/imports/utils/string-utils';
+import { isChatEnabled } from '/imports/ui/services/features';
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ chatId: PropTypes.string.isRequired,
+ disabled: PropTypes.bool.isRequired,
+ minMessageLength: PropTypes.number.isRequired,
+ maxMessageLength: PropTypes.number.isRequired,
+ chatTitle: PropTypes.string.isRequired,
+ chatAreaId: PropTypes.string.isRequired,
+ handleSendMessage: PropTypes.func.isRequired,
+ UnsentMessagesCollection: PropTypes.objectOf(Object).isRequired,
+ connected: PropTypes.bool.isRequired,
+ locked: PropTypes.bool.isRequired,
+ partnerIsLoggedOut: PropTypes.bool.isRequired,
+ stopUserTyping: PropTypes.func.isRequired,
+ startUserTyping: PropTypes.func.isRequired,
+};
+
+const messages = defineMessages({
+ submitLabel: {
+ id: 'app.chat.submitLabel',
+ description: 'Chat submit button label',
+ },
+ inputLabel: {
+ id: 'app.chat.inputLabel',
+ description: 'Chat message input label',
+ },
+ emojiButtonLabel: {
+ id: 'app.chat.emojiButtonLabel',
+ description: 'Chat message emoji picker button label',
+ },
+ inputPlaceholder: {
+ id: 'app.chat.inputPlaceholder',
+ description: 'Chat message input placeholder',
+ },
+ errorMaxMessageLength: {
+ id: 'app.chat.errorMaxMessageLength',
+ },
+ errorServerDisconnected: {
+ id: 'app.chat.disconnected',
+ },
+ errorChatLocked: {
+ id: 'app.chat.locked',
+ },
+ singularTyping: {
+ id: 'app.chat.singularTyping',
+ description: 'used to indicate when 1 user is typing',
+ },
+ pluralTyping: {
+ id: 'app.chat.pluralTyping',
+ description: 'used to indicate when multiple user are typing',
+ },
+ severalPeople: {
+ id: 'app.chat.severalPeople',
+ description: 'displayed when 4 or more users are typing',
+ },
+});
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const AUTO_CONVERT_EMOJI = Meteor.settings.public.chat.autoConvertEmoji;
+const ENABLE_EMOJI_PICKER = Meteor.settings.public.chat.emojiPicker.enable;
+
+class MessageForm extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ message: '',
+ error: null,
+ hasErrors: false,
+ showEmojiPicker: false,
+ };
+
+ this.handleMessageChange = this.handleMessageChange.bind(this);
+ this.handleMessageKeyDown = this.handleMessageKeyDown.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.setMessageHint = this.setMessageHint.bind(this);
+ this.handleUserTyping = throttle(this.handleUserTyping.bind(this), 2000, { trailing: false });
+ this.typingIndicator = CHAT_CONFIG.typingIndicator.enabled;
+ }
+
+ componentDidMount() {
+ const { isMobile } = deviceInfo;
+ this.setMessageState();
+ this.setMessageHint();
+
+ if (!isMobile) {
+ if (this.textarea) this.textarea.focus();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ chatId,
+ connected,
+ locked,
+ partnerIsLoggedOut,
+ } = this.props;
+ const { message } = this.state;
+ const { isMobile } = deviceInfo;
+
+ if (prevProps.chatId !== chatId && !isMobile) {
+ if (this.textarea) this.textarea.focus();
+ }
+
+ if (prevProps.chatId !== chatId) {
+ this.updateUnsentMessagesCollection(prevProps.chatId, message);
+ this.setState(
+ {
+ error: null,
+ hasErrors: false,
+ }, this.setMessageState(),
+ );
+ }
+
+ if (
+ connected !== prevProps.connected
+ || locked !== prevProps.locked
+ || partnerIsLoggedOut !== prevProps.partnerIsLoggedOut
+ ) {
+ this.setMessageHint();
+ }
+ }
+
+ componentWillUnmount() {
+ const { chatId } = this.props;
+ const { message } = this.state;
+ this.updateUnsentMessagesCollection(chatId, message);
+ this.setMessageState();
+ }
+
+ handleClickOutside() {
+ const { showEmojiPicker } = this.state;
+ if (showEmojiPicker) {
+ this.setState({ showEmojiPicker: false });
+ }
+ }
+
+ setMessageHint() {
+ const {
+ connected,
+ disabled,
+ intl,
+ locked,
+ partnerIsLoggedOut,
+ } = this.props;
+
+ let chatDisabledHint = null;
+
+ if (disabled && !partnerIsLoggedOut) {
+ if (connected) {
+ if (locked) {
+ chatDisabledHint = messages.errorChatLocked;
+ }
+ } else {
+ chatDisabledHint = messages.errorServerDisconnected;
+ }
+ }
+
+ this.setState({
+ hasErrors: disabled,
+ error: chatDisabledHint ? intl.formatMessage(chatDisabledHint) : null,
+ });
+ }
+
+ setMessageState() {
+ const { chatId, UnsentMessagesCollection } = this.props;
+ const unsentMessageByChat = UnsentMessagesCollection.findOne({ chatId },
+ { fields: { message: 1 } });
+ this.setState({ message: unsentMessageByChat ? unsentMessageByChat.message : '' });
+ }
+
+ updateUnsentMessagesCollection(chatId, message) {
+ const { UnsentMessagesCollection } = this.props;
+ UnsentMessagesCollection.upsert(
+ { chatId },
+ { $set: { message } },
+ );
+ }
+
+ handleMessageKeyDown(e) {
+ // TODO Prevent send message pressing enter on mobile and/or virtual keyboard
+ if (e.keyCode === 13 && !e.shiftKey) {
+ e.preventDefault();
+
+ const event = new Event('submit', {
+ bubbles: true,
+ cancelable: true,
+ });
+
+ this.handleSubmit(event);
+ }
+ }
+
+ handleUserTyping(error) {
+ const { startUserTyping, chatId } = this.props;
+ if (error || !this.typingIndicator) return;
+ startUserTyping(chatId);
+ }
+
+ handleMessageChange(e) {
+ const {
+ intl,
+ maxMessageLength,
+ } = this.props;
+
+ let message = null;
+ let error = null;
+
+ if (AUTO_CONVERT_EMOJI) {
+ message = checkText(e.target.value);
+ } else {
+ message = e.target.value;
+ }
+
+ if (message.length > maxMessageLength) {
+ error = intl.formatMessage(
+ messages.errorMaxMessageLength,
+ { 0: maxMessageLength },
+ );
+ message = message.substring(0, maxMessageLength);
+ }
+
+ this.setState({
+ message,
+ error,
+ }, this.handleUserTyping(error));
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ const {
+ disabled,
+ minMessageLength,
+ maxMessageLength,
+ handleSendMessage,
+ stopUserTyping,
+ } = this.props;
+ const { message } = this.state;
+ const msg = message.trim();
+
+ if (msg.length < minMessageLength) return;
+
+ if (disabled
+ || msg.length > maxMessageLength) {
+ this.setState({ hasErrors: true });
+ return;
+ }
+
+ const callback = this.typingIndicator ? stopUserTyping : null;
+
+ handleSendMessage(escapeHtml(msg));
+ this.setState({ message: '', error: '', hasErrors: false, showEmojiPicker: false }, callback);
+ }
+
+ handleEmojiSelect(emojiObject) {
+ const { message } = this.state;
+ const cursor = this.textarea.selectionStart;
+
+ this.setState(
+ {
+ message: message.slice(0, cursor)
+ + emojiObject.native
+ + message.slice(cursor),
+ },
+ );
+
+ const newCursor = cursor + emojiObject.native.length;
+ setTimeout(() => this.textarea.setSelectionRange(newCursor, newCursor), 10);
+ }
+
+ renderEmojiPicker() {
+ const { showEmojiPicker } = this.state;
+
+ if (showEmojiPicker) {
+ return (
+
+ this.handleEmojiSelect(emojiObject)}
+ />
+
+ );
+ }
+ return null;
+ }
+
+ renderEmojiButton() {
+ const { intl } = this.props;
+
+ return (
+ this.setState((prevState) => ({
+ showEmojiPicker: !prevState.showEmojiPicker,
+ }))}
+ icon="happy"
+ color="light"
+ ghost
+ type="button"
+ circle
+ hideLabel
+ label={intl.formatMessage(messages.emojiButtonLabel)}
+ data-test="emojiPickerButton"
+ />
+ );
+ }
+
+ renderForm() {
+ const {
+ intl,
+ chatTitle,
+ title,
+ disabled,
+ idChatOpen,
+ partnerIsLoggedOut,
+ } = this.props;
+
+ const {
+ hasErrors, error, message,
+ } = this.state;
+
+ return (
+ { this.form = ref; }}
+ onSubmit={this.handleSubmit}
+ >
+ {this.renderEmojiPicker()}
+
+ { this.textarea = ref; return this.textarea; }}
+ placeholder={intl.formatMessage(messages.inputPlaceholder, { 0: title })}
+ aria-label={intl.formatMessage(messages.inputLabel, { 0: chatTitle })}
+ aria-invalid={hasErrors ? 'true' : 'false'}
+ autoCorrect="off"
+ autoComplete="off"
+ spellCheck="true"
+ disabled={disabled || partnerIsLoggedOut}
+ value={message}
+ onChange={this.handleMessageChange}
+ onKeyDown={this.handleMessageKeyDown}
+ onPaste={(e) => { e.stopPropagation(); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ async
+ />
+ {ENABLE_EMOJI_PICKER && this.renderEmojiButton()}
+ { }}
+ data-test="sendMessageButton"
+ />
+
+
+
+ );
+ }
+
+ render() {
+ if (!isChatEnabled()) return null;
+
+ return ENABLE_EMOJI_PICKER ? (
+ this.handleClickOutside()}
+ >
+ {this.renderForm()}
+
+ ) : this.renderForm();
+ }
+}
+
+MessageForm.propTypes = propTypes;
+
+export default injectIntl(MessageForm);
diff --git a/src/2.7.12/imports/ui/components/chat/message-form/container.jsx b/src/2.7.12/imports/ui/components/chat/message-form/container.jsx
new file mode 100644
index 00000000..5a6339be
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/message-form/container.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { throttle } from '/imports/utils/throttle';
+import { makeCall } from '/imports/ui/services/api';
+import MessageForm from './component';
+import ChatService from '/imports/ui/components/chat/service';
+import { layoutSelect } from '../../layout/context';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const START_TYPING_THROTTLE_INTERVAL = 2000;
+
+const MessageFormContainer = (props) => {
+ const idChatOpen = layoutSelect((i) => i.idChatOpen);
+
+ const handleSendMessage = (message) => {
+ ChatService.setUserSentMessage(true);
+ return ChatService.sendGroupMessage(message, idChatOpen);
+ };
+ const startUserTyping = throttle(
+ (chatId) => makeCall('startUserTyping', chatId),
+ START_TYPING_THROTTLE_INTERVAL,
+ );
+ const stopUserTyping = () => makeCall('stopUserTyping');
+
+ return (
+
+ );
+};
+
+export default MessageFormContainer;
diff --git a/src/2.7.12/imports/ui/components/chat/message-form/styles.js b/src/2.7.12/imports/ui/components/chat/message-form/styles.js
new file mode 100644
index 00000000..d8d91d50
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/message-form/styles.js
@@ -0,0 +1,143 @@
+import styled, { css } from 'styled-components';
+import {
+ colorBlueLight,
+ colorText,
+ colorGrayLighter,
+ colorGrayLight,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ smPaddingX,
+ smPaddingY,
+ borderRadius,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import TextareaAutosize from 'react-autosize-textarea';
+import EmojiPickerComponent from '/imports/ui/components/emoji-picker/component';
+import Button from '/imports/ui/components/common/button/component';
+
+const Form = styled.form`
+ flex-grow: 0;
+ flex-shrink: 0;
+ align-self: flex-end;
+ width: 100%;
+ position: relative;
+ margin-bottom: calc(-1 * ${smPaddingX});
+ margin-top: .2rem;
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+const Input = styled(TextareaAutosize)`
+ flex: 1;
+ background: #fff;
+ background-clip: padding-box;
+ margin: 0;
+ color: ${colorText};
+ -webkit-appearance: none;
+ padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25);
+ resize: none;
+ transition: none;
+ border-radius: ${borderRadius};
+ font-size: ${fontSizeBase};
+ line-height: 1;
+ min-height: 2.5rem;
+ max-height: 10rem;
+ border: 1px solid ${colorGrayLighter};
+ box-shadow: 0 0 0 1px ${colorGrayLighter};
+
+ &:disabled,
+ &[disabled] {
+ cursor: not-allowed;
+ opacity: .75;
+ background-color: rgba(167,179,189,0.25);
+ }
+
+ &:focus {
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ ${({ emojiEnabled }) => emojiEnabled ?
+ css`
+ padding-left: calc(${smPaddingX} * 3);
+ `
+ : null
+ }
+`;
+
+const SendButton = styled(Button)`
+ margin:0 0 0 ${smPaddingX};
+ align-self: center;
+ font-size: 0.9rem;
+
+ [dir="rtl"] & {
+ margin: 0 ${smPaddingX} 0 0;
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+`;
+
+const EmojiButton = styled(Button)`
+ margin:0 0 0 ${smPaddingX};
+ align-self: center;
+ font-size: 0.5rem;
+
+ [dir="rtl"] & {
+ margin: 0 ${smPaddingX} 0 0;
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+`;
+
+const EmojiPickerWrapper = styled.div`
+ em-emoji-picker {
+ width: 100%;
+ max-height: 300px;
+ }
+ padding-bottom: 5px;
+`;
+
+const EmojiPicker = styled(EmojiPickerComponent)``;
+
+const EmojiButtonWrapper = styled.div`
+ color: ${colorGrayLight};
+ width: 2.5rem;
+ height: 2.5rem;
+ border: none;
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: ${fontSizeBase};
+ cursor: pointer;
+`;
+
+export default {
+ Form,
+ Wrapper,
+ Input,
+ SendButton,
+ EmojiButton,
+ EmojiButtonWrapper,
+ EmojiPicker,
+ EmojiPickerWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/component.jsx b/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/component.jsx
new file mode 100644
index 00000000..7d3db761
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/component.jsx
@@ -0,0 +1,139 @@
+import React, { PureComponent } from 'react';
+import {
+ defineMessages, injectIntl, FormattedMessage,
+} from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ typingUsers: PropTypes.arrayOf(Object).isRequired,
+};
+
+const messages = defineMessages({
+ severalPeople: {
+ id: 'app.chat.multi.typing',
+ description: 'displayed when 4 or more users are typing',
+ },
+ someoneTyping: {
+ id: 'app.chat.someone.typing',
+ description: 'label used when one user is typing with disabled name',
+ },
+});
+
+class TypingIndicator extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.renderTypingElement = this.renderTypingElement.bind(this);
+ }
+
+ renderTypingElement() {
+ const {
+ typingUsers, indicatorEnabled, indicatorShowNames, intl,
+ } = this.props;
+
+ if (!indicatorEnabled || !typingUsers) return null;
+
+ const { length } = typingUsers;
+
+ let element = null;
+
+ if (indicatorShowNames) {
+ const isSingleTyper = length === 1;
+ const isCoupleTyper = length === 2;
+ const isMultiTypers = length > 2;
+
+ if (isSingleTyper) {
+ const { name } = typingUsers[0];
+ element = (
+
+ {`${name}`}
+
+ ,
+ }}
+ />
+ );
+ }
+
+ if (isCoupleTyper) {
+ const {name} = typingUsers[0];
+ const {name: name2} = typingUsers[1];
+ element = (
+
+ {`${name}`}
+
+ ,
+ 1:
+
+ {`${name2}`}
+
+ ,
+ }}
+ />
+ );
+ }
+
+ if (isMultiTypers) {
+ element = (
+
+ {`${intl.formatMessage(messages.severalPeople)}`}
+
+ );
+ }
+ } else {
+ // Show no names in typing indicator
+ const isSingleTyper = length === 1;
+ const isMultiTypers = length > 1;
+
+ if (isSingleTyper) {
+ element = (
+
+ {`${intl.formatMessage(messages.someoneTyping)}`}
+
+ );
+ }
+
+ if (isMultiTypers) {
+ element = (
+
+ {`${intl.formatMessage(messages.severalPeople)}`}
+
+ );
+ }
+ }
+
+ return element;
+ }
+
+ render() {
+ const {
+ error,
+ indicatorEnabled,
+ } = this.props;
+
+ const typingElement = indicatorEnabled ? this.renderTypingElement() : null;
+
+ return (
+
+ {error || typingElement}
+
+ );
+ }
+}
+
+TypingIndicator.propTypes = propTypes;
+
+export default injectIntl(TypingIndicator);
diff --git a/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/container.jsx b/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/container.jsx
new file mode 100644
index 00000000..f407663a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/container.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import { UsersTyping } from '/imports/api/group-chat-msg';
+import Users from '/imports/api/users';
+import Meetings from '/imports/api/meetings';
+import TypingIndicator from './component';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const USER_CONFIG = Meteor.settings.public.user;
+const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
+const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled;
+const TYPING_SHOW_NAMES = CHAT_CONFIG.typingIndicator.showNames;
+
+const TypingIndicatorContainer = props => ;
+
+export default withTracker(({ idChatOpen }) => {
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, {
+ fields: {
+ 'lockSettingsProps.hideUserList': 1,
+ },
+ });
+
+ const selector = {
+ meetingId: Auth.meetingID,
+ isTypingTo: PUBLIC_CHAT_KEY,
+ userId: { $ne: Auth.userID },
+ };
+
+ if (idChatOpen !== PUBLIC_CHAT_KEY) {
+ selector.isTypingTo = idChatOpen;
+ }
+
+ const currentUser = Users.findOne({
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ }, {
+ fields: {
+ role: 1,
+ },
+ });
+
+ if (meeting.lockSettingsProps.hideUserList && currentUser?.role === USER_CONFIG.role_viewer) {
+ selector.role = { $ne: USER_CONFIG.role_viewer };
+ }
+
+ const typingUsers = UsersTyping.find(selector).fetch();
+
+ return {
+ typingUsers,
+ indicatorEnabled: TYPING_INDICATOR_ENABLED,
+ indicatorShowNames: TYPING_SHOW_NAMES,
+ };
+})(TypingIndicatorContainer);
diff --git a/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/styles.js b/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/styles.js
new file mode 100644
index 00000000..e650c0a5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/message-form/typing-indicator/styles.js
@@ -0,0 +1,73 @@
+import styled from 'styled-components';
+import { colorDanger, colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeSmaller, fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const SingleTyper = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: bold;
+ font-size: ${fontSizeSmaller};
+ max-width: 70%;
+`;
+
+const CoupleTyper = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: bold;
+ font-size: ${fontSizeSmaller};
+ max-width: 25%;
+`;
+
+const TypingIndicator = styled.span`
+ display: flex;
+ flex-direction: row;
+
+ > span {
+ display: block;
+ margin-right: 0.05rem;
+ margin-left: 0.05rem;
+ }
+
+ text-align: left;
+ [dir="rtl"] & {
+ text-align: right;
+ }
+`;
+
+const TypingIndicatorWrapper = styled.div`
+ ${({ error }) => error && `
+ color: ${colorDanger};
+ font-size: calc(${fontSizeBase} * .75);
+ color: ${colorGrayDark};
+ text-align: left;
+ padding: ${borderSize} 0;
+ position: relative;
+ height: .93rem;
+ max-height: .93rem;
+ `}
+
+ ${({ info }) => info && `
+ font-size: calc(${fontSizeBase} * .75);
+ color: ${colorGrayDark};
+ text-align: left;
+ padding: ${borderSize} 0;
+ position: relative;
+ height: .93rem;
+ max-height: .93rem;
+ `}
+
+ ${({ spacer }) => spacer && `
+ height: .93rem;
+ max-height: .93rem;
+ `}
+`;
+
+export default {
+ SingleTyper,
+ CoupleTyper,
+ TypingIndicator,
+ TypingIndicatorWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/chat/service.js b/src/2.7.12/imports/ui/components/chat/service.js
new file mode 100644
index 00000000..d667cfa0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/service.js
@@ -0,0 +1,388 @@
+import Users from '/imports/api/users';
+import Meetings from '/imports/api/meetings';
+import GroupChat from '/imports/api/group-chat';
+import Auth from '/imports/ui/services/auth';
+import UnreadMessages from '/imports/ui/services/unread-messages';
+import Storage from '/imports/ui/services/storage/session';
+import { makeCall } from '/imports/ui/services/api';
+import { stripTags, unescapeHtml } from '/imports/utils/string-utils';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { defineMessages } from 'react-intl';
+import PollService from '/imports/ui/components/poll/service';
+
+const APP = Meteor.settings.public.app;
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const GROUPING_MESSAGES_WINDOW = CHAT_CONFIG.grouping_messages_window;
+const CHAT_EMPHASIZE_TEXT = CHAT_CONFIG.moderatorChatEmphasized;
+
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+
+const PUBLIC_CHAT_CLEAR = CHAT_CONFIG.system_messages_keys.chat_clear;
+const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const ScrollCollection = new Mongo.Collection(null);
+
+const UnsentMessagesCollection = new Mongo.Collection(null);
+
+export const UserSentMessageCollection = new Mongo.Collection(null);
+
+// session for closed chat list
+const CLOSED_CHAT_LIST_KEY = 'closedChatList';
+
+const intlMessages = defineMessages({
+ publicChatClear: {
+ id: 'app.chat.clearPublicChatMessage',
+ description: 'message of when clear the public chat',
+ },
+ pollResult: {
+ id: 'app.chat.pollResult',
+ description: 'used in place of user name who published poll to chat',
+ },
+ download: {
+ id: 'app.presentation.downloadLabel',
+ description: 'used as label for presentation download link',
+ },
+ notAccessibleWarning: {
+ id: 'app.presentationUploader.export.notAccessibleWarning',
+ description: 'used for indicating that a link may be not accessible',
+ },
+ original: {
+ id: 'app.presentationUploader.export.originalLabel',
+ description: 'Label to identify original presentation exported',
+ },
+ withWhiteboardAnnotations: {
+ id: 'app.presentationUploader.export.withWhiteboardAnnotations',
+ description: 'Label to identify in current state presentation exported',
+ },
+});
+
+const setUserSentMessage = (bool) => {
+ UserSentMessageCollection.upsert(
+ { userId: Auth.userID },
+ { $set: { sent: bool } },
+ );
+};
+
+const getUser = (userId) => Users.findOne({ userId });
+
+const getPrivateChatByUsers = (userId) => GroupChat
+ .findOne({ users: { $all: [userId, Auth.userID] } });
+
+const getWelcomeProp = () => Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { welcomeProp: 1 } });
+
+const mapGroupMessage = (message) => {
+ const mappedMessage = {
+ id: message._id || message.id,
+ content: message.content,
+ time: message.timestamp || message.time,
+ sender: null,
+ key: message.key,
+ chatId: message.chatId,
+ extra: message.extra,
+ };
+
+ if (message.sender && message.sender !== SYSTEM_CHAT_TYPE) {
+ const sender = Users.findOne(
+ { userId: message.sender },
+ {
+ fields: { avatar: 1, role: 1, name: 1 },
+ },
+ );
+
+ const mappedSender = {
+ avatar: sender?.avatar,
+ color: message.color,
+ isModerator: message.senderRole === ROLE_MODERATOR,
+ name: message.senderName,
+ isOnline: !!sender,
+ };
+
+ mappedMessage.sender = mappedSender;
+ }
+
+ return mappedMessage;
+};
+
+const reduceGroupMessages = (previous, current) => {
+ const lastMessage = previous[previous.length - 1];
+ const currentMessage = current;
+ currentMessage.content = [{
+ id: current.id,
+ text: current.message,
+ time: current.timestamp,
+ color: current.color,
+ }];
+ if (!lastMessage) {
+ return previous.concat(currentMessage);
+ }
+ // Check if the last message is from the same user and time discrepancy
+ // between the two messages exceeds window and then group current
+ // message with the last one
+ const timeOfLastMessage = lastMessage.content[lastMessage.content.length - 1].time;
+ const isOrWasPoll = currentMessage.id.includes(CHAT_POLL_RESULTS_MESSAGE)
+ || lastMessage.id.includes(CHAT_POLL_RESULTS_MESSAGE);
+ const groupingWindow = isOrWasPoll ? 0 : GROUPING_MESSAGES_WINDOW;
+
+ if (lastMessage.sender.id === currentMessage.sender.id
+ && (currentMessage.timestamp - timeOfLastMessage) <= groupingWindow) {
+ lastMessage.content.push(currentMessage.content.pop());
+ return previous;
+ }
+
+ return previous.concat(currentMessage);
+};
+
+const reduceAndMapGroupMessages = (messages) => (messages
+ .reduce(reduceGroupMessages, []).map(mapGroupMessage));
+
+const reduceAndDontMapGroupMessages = (messages) => (messages
+ .reduce(reduceGroupMessages, []));
+
+const isChatLocked = (receiverID) => {
+ const isPublic = receiverID === PUBLIC_CHAT_ID;
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'lockSettingsProps.disablePublicChat': 1, 'lockSettingsProps.disablePrivateChat': 1 } });
+ const user = Users.findOne({ meetingId: Auth.meetingID, userId: Auth.userID },
+ { fields: { locked: 1, role: 1 } });
+ const receiver = Users.findOne({ meetingId: Auth.meetingID, userId: receiverID },
+ { fields: { role: 1 } });
+ const isReceiverModerator = receiver && receiver.role === ROLE_MODERATOR;
+
+ // disable private chat in breakouts
+ if (meetingIsBreakout()) {
+ return !isPublic;
+ }
+
+ if (meeting.lockSettingsProps !== undefined) {
+ if (user.locked && user.role !== ROLE_MODERATOR) {
+ if (isPublic) {
+ return meeting.lockSettingsProps.disablePublicChat;
+ }
+ return !isReceiverModerator
+ && meeting.lockSettingsProps.disablePrivateChat;
+ }
+ }
+
+ if (user.chatLocked === true && user.role !== ROLE_MODERATOR) {
+ return true;
+ }
+
+ return false;
+};
+
+const isChatClosed = (chatId) => {
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
+ return !!currentClosedChats.find(closedChat => closedChat.chatId === chatId);
+};
+
+const lastReadMessageTime = (receiverID) => {
+ const isPublic = receiverID === PUBLIC_CHAT_ID;
+ const chatType = isPublic ? PUBLIC_GROUP_CHAT_ID : receiverID;
+
+ return UnreadMessages.get(chatType);
+};
+
+const sendGroupMessage = (message, idChatOpen) => {
+ const chatIdToSent = idChatOpen === PUBLIC_CHAT_ID ? PUBLIC_GROUP_CHAT_ID : idChatOpen;
+ const chat = GroupChat.findOne({ chatId: chatIdToSent },
+ { fields: { users: 1 } });
+ const chatID = idChatOpen === PUBLIC_CHAT_ID
+ ? PUBLIC_GROUP_CHAT_ID
+ : chat.users.filter((id) => id !== Auth.userID)[0];
+ const isPublicChat = chatID === PUBLIC_CHAT_ID;
+
+ let destinationChatId = PUBLIC_GROUP_CHAT_ID;
+
+ const { userID: senderUserId } = Auth;
+ const receiverId = { id: chatID };
+
+ if (!isPublicChat) {
+ const privateChat = GroupChat.findOne({ users: { $all: [chatID, senderUserId] } },
+ { fields: { chatId: 1 } });
+
+ if (privateChat) {
+ const { chatId: privateChatId } = privateChat;
+
+ destinationChatId = privateChatId;
+ }
+ }
+
+ const payload = {
+ correlationId: `${senderUserId}-${Date.now()}`,
+ sender: {
+ id: senderUserId,
+ name: '',
+ role: '',
+ },
+ chatEmphasizedText: CHAT_EMPHASIZE_TEXT,
+ message,
+ };
+
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY);
+
+ // Remove the chat that user send messages from the session.
+ if (isChatClosed(receiverId.id)) {
+ const closedChats = currentClosedChats.filter(closedChat => closedChat.chatId !== receiverId.id);
+ Storage.setItem(CLOSED_CHAT_LIST_KEY,closedChats);
+ }
+
+ return makeCall('sendGroupChatMsg', destinationChatId, payload);
+};
+
+const getScrollPosition = (receiverID) => {
+ const scroll = ScrollCollection.findOne({ receiver: receiverID },
+ { fields: { position: 1 } }) || { position: null };
+ return scroll.position;
+};
+
+const updateScrollPosition = (position, idChatOpen) => ScrollCollection.upsert(
+ { receiver: idChatOpen },
+ { $set: { position } },
+);
+
+const updateUnreadMessage = (timestamp, idChatOpen) => {
+ const chatID = idChatOpen;
+ const isPublic = chatID === PUBLIC_CHAT_ID;
+ const chatType = isPublic ? PUBLIC_GROUP_CHAT_ID : chatID;
+ return UnreadMessages.update(chatType, timestamp);
+};
+
+const clearPublicChatHistory = () => (makeCall('clearPublicChatHistory'));
+
+const closePrivateChat = (chatId) => {
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
+
+ if (!isChatClosed(chatId)) {
+ currentClosedChats.push({ chatId, timestamp: Date.now() });
+
+ Storage.setItem(CLOSED_CHAT_LIST_KEY, currentClosedChats);
+ }
+};
+
+// if this private chat has been added to the list of closed ones, remove it
+const removeFromClosedChatsSession = (idChatOpen) => {
+ const chatID = idChatOpen;
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY);
+
+ if (isChatClosed(chatID)) {
+ const closedChats = currentClosedChats.filter(closedChat => closedChat.chatId !== chatID);
+ Storage.setItem(CLOSED_CHAT_LIST_KEY,closedChats);
+ }
+};
+
+const htmlDecode = (input) => {
+ const replacedBRs = input.replaceAll(' ', '\n');
+ return unescapeHtml(stripTags(replacedBRs));
+};
+
+// Export the chat as [Hour:Min] user: message
+const exportChat = (timeWindowList, intl) => {
+ const messageList = timeWindowList.reduce((acc, timeWindow) => {
+ const msgs = timeWindow.content.map((message) => {
+ const date = new Date(message.time);
+ const hour = date.getHours().toString().padStart(2, 0);
+ const min = date.getMinutes().toString().padStart(2, 0);
+ const hourMin = `[${hour}:${min}]`;
+
+ // Skip the reduce aggregation for the sync messages because they aren't localized, causing an error in line 268
+ // Also they're temporary (preliminary) messages, so it doesn't make sense export them
+ if (['SYSTEM_MESSAGE-sync-msg', 'synced'].includes(message.id)) return acc;
+
+ let userName = message.id.startsWith(SYSTEM_CHAT_TYPE)
+ ? ''
+ : `${timeWindow.senderName}: `;
+ let messageText = '';
+ if (message.text === PUBLIC_CHAT_CLEAR) {
+ messageText = intl.formatMessage(intlMessages.publicChatClear);
+ } else if (message.id.includes(CHAT_POLL_RESULTS_MESSAGE)) {
+ userName = `${intl.formatMessage(intlMessages.pollResult)}:\n`;
+ const { pollResultData } = timeWindow.extra;
+ const pollText = htmlDecode(PollService.getPollResultString(pollResultData, intl).split(' ').join('\n'));
+ // remove last \n to avoid empty line
+ messageText = pollText.slice(0, -1);
+ } else {
+ messageText = message.text;
+ }
+ return `${hourMin} ${userName}${htmlDecode(messageText)}`;
+ });
+
+ return [...acc, ...msgs];
+ }, []);
+
+ return messageList.join('\n');
+};
+
+const getAllMessages = (chatID, messages) => {
+ if (!messages[chatID]) {
+ return [];
+ }
+
+ return (chatID === PUBLIC_GROUP_CHAT_ID)
+ ? Object.values(messages[chatID].posJoinMessages)
+ : Object.values(messages[chatID].messageGroups);
+};
+
+const maxTimestampReducer = (max, el) => ((el.timestamp > max) ? el.timestamp : max);
+
+const maxNumberReducer = (max, el) => ((el > max) ? el : max);
+
+const getLastMessageTimestampFromChatList = (activeChats, messages) => activeChats
+ .map((chat) => ((chat.userId === 'public') ? 'MAIN-PUBLIC-GROUP-CHAT' : chat.chatId))
+ .map((chatId) => getAllMessages(chatId, messages).reduce(maxTimestampReducer, 0))
+ .reduce(maxNumberReducer, 0);
+
+const removePackagedClassAttribute = (classnames, attribute) => {
+ classnames.forEach((c) => {
+ const elements = document.getElementsByClassName(c);
+ if (elements) {
+ // eslint-disable-next-line
+ for (const [, v] of Object.entries(elements)) {
+ v.removeAttribute(attribute);
+ }
+ }
+ });
+};
+
+const getExportedPresentationString = (fileURI, filename, intl, fileStateType) => {
+ const sanitizedFilename = stripTags(filename);
+ const intlFileStateType = fileStateType === 'Original' ? intlMessages.original : intlMessages.withWhiteboardAnnotations;
+ const href = `${APP.bbbWebBase}/${fileURI}`;
+ const warningIcon = ' ';
+ const label = `${intl.formatMessage(intlMessages.download)} `;
+ const notAccessibleWarning = `${warningIcon} `;
+ const link = `${label} ${notAccessibleWarning} `;
+ const name = `${sanitizedFilename} (${intl.formatMessage(intlFileStateType)}) `;
+ return `${name}${link}`;
+};
+
+export default {
+ setUserSentMessage,
+ mapGroupMessage,
+ reduceAndMapGroupMessages,
+ reduceAndDontMapGroupMessages,
+ getUser,
+ getPrivateChatByUsers,
+ getWelcomeProp,
+ getScrollPosition,
+ lastReadMessageTime,
+ isChatLocked,
+ isChatClosed,
+ updateScrollPosition,
+ updateUnreadMessage,
+ sendGroupMessage,
+ closePrivateChat,
+ removeFromClosedChatsSession,
+ exportChat,
+ clearPublicChatHistory,
+ maxTimestampReducer,
+ getLastMessageTimestampFromChatList,
+ UnsentMessagesCollection,
+ removePackagedClassAttribute,
+ getExportedPresentationString,
+};
diff --git a/src/2.7.12/imports/ui/components/chat/styles.js b/src/2.7.12/imports/ui/components/chat/styles.js
new file mode 100644
index 00000000..fc3f5dad
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/styles.js
@@ -0,0 +1,61 @@
+import styled from 'styled-components';
+import {
+ colorWhite,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { mdPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+
+const Chat = styled.div`
+ background-color: ${colorWhite};
+ padding: ${mdPaddingX};
+
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ justify-content: space-around;
+ overflow: hidden;
+ height: 100%;
+
+ a {
+ color: ${colorPrimary};
+ text-decoration: none;
+
+ &:focus {
+ color: ${colorPrimary};
+ text-decoration: underline;
+ }
+ &:hover {
+ filter: brightness(90%);
+ text-decoration: underline;
+ }
+ &:active {
+ filter: brightness(85%);
+ text-decoration: underline;
+ }
+ &:hover:focus{
+ filter: brightness(90%);
+ text-decoration: underline;
+ }
+ &:focus:active {
+ filter: brightness(85%);
+ text-decoration: underline;
+ }
+ }
+ u {
+ text-decoration-line: none;
+ }
+
+ ${({ isChrome }) => isChrome && `
+ transform: translateZ(0);
+ `}
+
+ @media ${smallOnly} {
+ transform: none !important;
+ &.no-padding {
+ padding: 0;
+ }
+ }
+`;
+
+export default { Chat };
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/component.jsx b/src/2.7.12/imports/ui/components/chat/time-window-list/component.jsx
new file mode 100644
index 00000000..44a2b07a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/component.jsx
@@ -0,0 +1,386 @@
+import React, { PureComponent } from 'react';
+import { findDOMNode } from 'react-dom';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { debounce } from '/imports/utils/debounce';
+import { AutoSizer,CellMeasurer, CellMeasurerCache } from 'react-virtualized';
+import Styled from './styles';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import TimeWindowChatItem from './time-window-chat-item/container';
+import { convertRemToPixels } from '/imports/utils/dom-utils';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+const CHAT_CLEAR_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_clear;
+
+const propTypes = {
+ scrollPosition: PropTypes.number,
+ chatId: PropTypes.string.isRequired,
+ handleScrollUpdate: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ id: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+ scrollPosition: null,
+};
+
+const intlMessages = defineMessages({
+ moreMessages: {
+ id: 'app.chat.moreMessages',
+ description: 'Chat message when the user has unread messages below the scroll',
+ },
+ emptyLogLabel: {
+ id: 'app.chat.emptyLogLabel',
+ description: 'aria-label used when chat log is empty',
+ },
+});
+
+const updateChatSemantics = () => {
+ setTimeout(() => {
+ const msgListItem = document.querySelector('span[data-test="msgListItem"]');
+ if (msgListItem) {
+ const virtualizedGridInnerScrollContainer = msgListItem.parentElement;
+ const virtualizedGrid = virtualizedGridInnerScrollContainer.parentElement;
+ virtualizedGridInnerScrollContainer.setAttribute('role', 'list');
+ virtualizedGridInnerScrollContainer.setAttribute('tabIndex', 0);
+ virtualizedGrid.removeAttribute('tabIndex');
+ virtualizedGrid.removeAttribute('aria-label');
+ virtualizedGrid.removeAttribute('aria-readonly');
+ }
+ }, 300);
+}
+
+class TimeWindowList extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.cache = new CellMeasurerCache({
+ fixedWidth: true,
+ minHeight: 18,
+ keyMapper: (rowIndex) => {
+ const { timeWindowsValues } = this.props;
+ const timewindow = timeWindowsValues[rowIndex];
+
+ const key = timewindow?.key;
+ const contentCount = timewindow?.content?.length;
+ return `${key}-${contentCount}`;
+ },
+ });
+ this.userScrolledBack = false;
+ this.handleScrollUpdate = debounce(this.handleScrollUpdate.bind(this), 150);
+ this.rowRender = this.rowRender.bind(this);
+ this.forceCacheUpdate = this.forceCacheUpdate.bind(this);
+ this.systemMessagesResized = {};
+
+ this.state = {
+ scrollArea: null,
+ shouldScrollToPosition: false,
+ scrollPosition: 0,
+ userScrolledBack: false,
+ lastMessage: {},
+ fontsLoaded: false,
+ };
+ this.systemMessageIndexes = [];
+
+ this.listRef = null;
+ this.virualRef = null;
+
+ this.lastWidth = 0;
+
+ document.fonts.onloadingdone = () => this.setState({fontsLoaded: true});
+ }
+
+ componentDidMount() {
+ const { scrollPosition: scrollProps } = this.props;
+
+ this.setState({
+ scrollPosition: scrollProps,
+ });
+
+ updateChatSemantics();
+ }
+
+ componentDidUpdate(prevProps) {
+ ChatLogger.debug('TimeWindowList::componentDidUpdate', { ...this.props }, { ...prevProps });
+ if (this.virualRef) {
+ if (this.virualRef.style.direction !== document.documentElement.dir) {
+ this.virualRef.style.direction = document.documentElement.dir;
+ }
+ }
+
+ const {
+ userSentMessage,
+ setUserSentMessage,
+ timeWindowsValues,
+ chatId,
+ syncing,
+ syncedPercent,
+ lastTimeWindowValuesBuild,
+ scrollPosition: scrollProps,
+ count,
+ } = this.props;
+
+ const { userScrolledBack } = this.state;
+
+ if((count > 0 && !userScrolledBack) || userSentMessage || !scrollProps) {
+ const lastItemIndex = timeWindowsValues.length - 1;
+
+ this.setState({
+ scrollPosition: lastItemIndex,
+ }, ()=> this.handleScrollUpdate(lastItemIndex));
+ }
+
+ const {
+ timeWindowsValues: prevTimeWindowsValues,
+ chatId: prevChatId,
+ syncing: prevSyncing,
+ syncedPercent: prevSyncedPercent
+ } = prevProps;
+
+ if (prevChatId !== chatId) {
+ this.setState({
+ scrollPosition: scrollProps,
+ });
+ }
+
+ const prevTimeWindowsLength = prevTimeWindowsValues.length;
+ const timeWindowsValuesLength = timeWindowsValues.length;
+ const prevLastTimeWindow = prevTimeWindowsValues[prevTimeWindowsLength - 1];
+ const lastTimeWindow = timeWindowsValues[prevTimeWindowsLength - 1];
+
+ if ((lastTimeWindow
+ && (prevLastTimeWindow?.content.length !== lastTimeWindow?.content.length))) {
+ if (this.listRef) {
+ this.cache.clear(timeWindowsValuesLength - 1);
+ this.listRef.recomputeRowHeights(timeWindowsValuesLength - 1);
+ }
+ }
+
+ if (userSentMessage && !prevProps.userSentMessage) {
+ this.setState({
+ userScrolledBack: false,
+ }, () => setUserSentMessage(false));
+ }
+
+ // this condition exist to the case where the chat has a single message and the chat is cleared
+ // The component List from react-virtualized doesn't have a reference to the list of messages
+ // so I need force the update to fix it
+ if (
+ (lastTimeWindow?.id === `${SYSTEM_CHAT_TYPE}-${CHAT_CLEAR_MESSAGE}`)
+ || (prevSyncing && !syncing)
+ || (syncedPercent !== prevSyncedPercent)
+ || (chatId !== prevChatId)
+ || (lastTimeWindowValuesBuild !== prevProps.lastTimeWindowValuesBuild)
+ ) {
+ this.listRef.forceUpdateGrid();
+ }
+
+ updateChatSemantics();
+ }
+
+ handleScrollUpdate(position, target) {
+ const {
+ handleScrollUpdate,
+ } = this.props;
+
+ if (position !== null && position + target?.offsetHeight === target?.scrollHeight) {
+ // I used one because the null value is used to notify that
+ // the user has sent a message and the message list should scroll to bottom
+ handleScrollUpdate(1);
+ return;
+ }
+
+ handleScrollUpdate(position || 1);
+ }
+
+ scrollTo(position = null) {
+ if (position) {
+ setTimeout(() => this.setState({
+ shouldScrollToPosition: true,
+ scrollPosition: position,
+ }), 200);
+ }
+ }
+
+ forceCacheUpdate(index) {
+ if (index >= 0) {
+ this.cache.clear(index);
+ this.listRef.recomputeRowHeights(index);
+ }
+ }
+
+ rowRender({
+ index,
+ parent,
+ style,
+ key,
+ }) {
+ const {
+ id,
+ timeWindowsValues,
+ dispatch,
+ chatId,
+ } = this.props;
+
+ const { scrollArea } = this.state;
+ const message = timeWindowsValues[index];
+
+ ChatLogger.debug('TimeWindowList::rowRender', this.props);
+ return (
+
+
+
+
+
+ );
+ }
+
+ renderUnreadNotification() {
+ const {
+ intl,
+ count,
+ timeWindowsValues,
+ } = this.props;
+ const { userScrolledBack } = this.state;
+
+ if (count && userScrolledBack) {
+ return (
+ {
+ const lastItemIndex = timeWindowsValues.length - 1;
+ this.handleScrollUpdate(lastItemIndex);
+
+ this.setState({
+ scrollPosition: lastItemIndex,
+ userScrolledBack: false,
+ });
+ }}
+ />
+ );
+ }
+
+ return null;
+ }
+
+ render() {
+ const {
+ timeWindowsValues,
+ width,
+ } = this.props;
+ const {
+ scrollArea,
+ scrollPosition,
+ userScrolledBack,
+ } = this.state;
+ ChatLogger.debug('TimeWindowList::render', {...this.props}, {...this.state}, new Date());
+
+ const shouldAutoScroll = !!(
+ scrollPosition
+ && timeWindowsValues.length >= scrollPosition
+ && !userScrolledBack
+ );
+
+ const paddingValue = convertRemToPixels(2);
+
+ return (
+ [
+ {
+ this.setState({
+ userScrolledBack: true,
+ });
+ }}
+ onWheel={(e) => {
+ if (e.deltaY < 0) {
+ this.setState({
+ userScrolledBack: true,
+ });
+ this.userScrolledBack = true
+ }
+ }}
+ key="chat-list"
+ data-test="chatMessages"
+ ref={node => this.messageListWrapper = node}
+ onCopy={(e) => { e.stopPropagation(); }}
+ >
+
+ {({ height }) => {
+ if (width !== this.lastWidth) {
+ this.lastWidth = width;
+ this.cache.clearAll();
+ }
+ return (
+ {
+ if (ref !== null) {
+ this.listRef = ref;
+
+ if (!scrollArea) {
+ this.setState({ scrollArea: findDOMNode(this.listRef) });
+ }
+ }
+ }}
+ isScrolling
+ rowHeight={this.cache.rowHeight}
+ rowRenderer={this.rowRender}
+ rowCount={timeWindowsValues.length}
+ height={height}
+ width={width - paddingValue}
+ overscanRowCount={0}
+ deferredMeasurementCache={this.cache}
+ scrollToIndex={shouldAutoScroll ? scrollPosition : undefined}
+ onRowsRendered={({ stopIndex }) => {
+ this.handleScrollUpdate(stopIndex);
+ }}
+ onScroll={({ clientHeight, scrollHeight, scrollTop }) => {
+ const scrollSize = scrollTop + clientHeight;
+ if (scrollSize >= scrollHeight) {
+ this.setState({
+ userScrolledBack: false,
+ });
+ }
+ }}
+ />
+ );
+ }}
+
+ ,
+ this.renderUnreadNotification(),
+ ]
+ );
+ }
+}
+
+TimeWindowList.propTypes = propTypes;
+TimeWindowList.defaultProps = defaultProps;
+
+export default injectIntl(TimeWindowList);
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/container.jsx b/src/2.7.12/imports/ui/components/chat/time-window-list/container.jsx
new file mode 100644
index 00000000..5d3752dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/container.jsx
@@ -0,0 +1,27 @@
+import React, { PureComponent } from 'react';
+import TimeWindowList from './component';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import ChatService from '../service';
+
+export default class TimeWindowListContainer extends PureComponent {
+ render() {
+ const { chatId, userSentMessage } = this.props;
+ const scrollPosition = ChatService.getScrollPosition(chatId);
+ const lastReadMessageTime = ChatService.lastReadMessageTime(chatId);
+ ChatLogger.debug('TimeWindowListContainer::render', { ...this.props }, new Date());
+ return (
+
+ );
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/styles.js b/src/2.7.12/imports/ui/components/chat/time-window-list/styles.js
new file mode 100644
index 00000000..1e5aab37
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/styles.js
@@ -0,0 +1,142 @@
+import styled, { css } from 'styled-components';
+import {
+ smPaddingX,
+ mdPaddingX,
+ mdPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { ButtonElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
+import { VirtualizedScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+import MessageChatItem from '/imports/ui/components/chat/time-window-list/time-window-chat-item/message-chat-item/component';
+
+const UnreadButton = styled(ButtonElipsis)`
+ flex-shrink: 0;
+ width: 100%;
+ text-transform: uppercase;
+ margin-bottom: .25rem;
+ z-index: 3;
+`;
+
+const MessageListWrapper = styled.div`
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+ flex-shrink: 1;
+ position: relative;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding-left: ${smPaddingX};
+ margin-left: calc(-1 * ${mdPaddingX});
+ padding-right: ${smPaddingX};
+ margin-right: calc(-1 * ${mdPaddingY});
+ padding-bottom: ${mdPaddingX};
+ z-index: 2;
+ [dir="rtl"] & {
+ padding-right: ${mdPaddingX};
+ margin-right: calc(-1 * ${mdPaddingX});
+ padding-left: ${mdPaddingY};
+ margin-left: calc(-1 * ${mdPaddingX});
+ }
+`;
+
+const MessageList = styled(VirtualizedScrollboxVertical)`
+ flex-flow: column;
+ flex-grow: 1;
+ flex-shrink: 1;
+ margin: 0 auto 0 0;
+ right: 0 ${mdPaddingX} 0 0;
+ padding-top: 0;
+ width: 100%;
+ outline-style: none;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 auto;
+ padding: 0 0 0 ${mdPaddingX};
+ }
+`;
+
+const Time = styled.time`
+`;
+
+const Wrapper = styled.div`
+`;
+
+const AvatarWrapper = styled.div`
+`;
+
+const Content = styled.div`
+`;
+
+const Meta = styled.div`
+`;
+
+const Name = styled.div`
+`;
+
+const Read = styled.div`
+`;
+
+const Messages = styled.div`
+`;
+
+const SystemMessageChatItem = styled(MessageChatItem)`
+ ${({ messageId }) => messageId ?
+ css`
+ `
+ :
+ css`
+ `
+ }
+`;
+
+const Item = styled.div`
+`;
+
+const OnlineIndicator = styled.div`
+ ${({ isOnline }) => isOnline ?
+ css`
+ color: red;
+ `
+ :
+ css`
+ color: blue;
+ `
+ }
+`;
+
+const ChatItem = styled.div`
+`;
+
+const Offline = styled.div`
+`;
+
+const PollIcon = styled.div`
+`;
+
+const PollMessageChatItem = styled.div`
+`;
+
+const StatusMessageChatItem = styled(MessageChatItem)`
+`;
+
+export default {
+ UnreadButton,
+ MessageListWrapper,
+ MessageList,
+ Time,
+ Content,
+ Meta,
+ Wrapper,
+ AvatarWrapper,
+ Name,
+ Read,
+ Messages,
+ SystemMessageChatItem,
+ Item,
+ ChatItem,
+ OnlineIndicator,
+ Offline,
+ PollIcon,
+ PollMessageChatItem,
+ StatusMessageChatItem,
+};
+
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/component.jsx b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/component.jsx
new file mode 100644
index 00000000..8ae20152
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/component.jsx
@@ -0,0 +1,525 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { FormattedTime, defineMessages, injectIntl } from 'react-intl';
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+import { Meteor } from 'meteor/meteor';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import PollService from '/imports/ui/components/poll/service';
+import Tooltip from '/imports/ui/components/common/tooltip/component';
+import Styled from './styles';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const CHAT_CLEAR_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_clear;
+const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
+const CHAT_USER_STATUS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_status_message;
+const CHAT_PUBLIC_ID = CHAT_CONFIG.public_id;
+const CHAT_EMPHASIZE_TEXT = CHAT_CONFIG.moderatorChatEmphasized;
+const CHAT_EXPORTED_PRESENTATION_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_exported_presentation;
+
+const propTypes = {
+ user: PropTypes.shape({
+ color: PropTypes.string,
+ messageFromModerator: PropTypes.bool,
+ isOnline: PropTypes.bool,
+ name: PropTypes.string,
+ }),
+ messages: PropTypes.arrayOf(Object).isRequired,
+ timestamp: PropTypes.number,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ scrollArea: PropTypes.instanceOf(Element),
+ chatAreaId: PropTypes.string.isRequired,
+ handleReadMessage: PropTypes.func.isRequired,
+ lastReadMessageTime: PropTypes.number,
+ lastReadByPartnerMessageTime: PropTypes.number,
+ isMessageReadFeedbackEnabled: PropTypes.bool,
+};
+
+const defaultProps = {
+ user: null,
+ scrollArea: null,
+ lastReadMessageTime: 0,
+ lastReadByPartnerMessageTime: 0,
+ isMessageReadFeedbackEnabled: false,
+ timestamp: 0,
+};
+
+const intlMessages = defineMessages({
+ offline: {
+ id: 'app.chat.offline',
+ description: 'Offline',
+ },
+ pollResult: {
+ id: 'app.chat.pollResult',
+ description: 'used in place of user name who published poll to chat',
+ },
+ [CHAT_CLEAR_MESSAGE]: {
+ id: 'app.chat.clearPublicChatMessage',
+ description: 'message of when clear the public chat',
+ },
+ breakoutDurationUpdated: {
+ id: 'app.chat.breakoutDurationUpdated',
+ description: 'used when the breakout duration is updated',
+ },
+ away: {
+ id: 'app.chat.away',
+ description: 'away label',
+ },
+ notAway: {
+ id: 'app.chat.notAway',
+ description: 'not away label',
+ },
+ messageReadLabel: {
+ id: 'app.chat.messageRead',
+ description: 'Message read tooltip label',
+ },
+});
+
+
+class TimeWindowChatItem extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ forcedUpdateCount: 0,
+ };
+ }
+
+ componentWillMount() {
+ ChatLogger.debug('TimeWindowChatItem::componentWillMount::props', { ...this.props });
+ ChatLogger.debug('TimeWindowChatItem::componentWillMount::state', { ...this.state });
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { height, forceCacheUpdate, index } = this.props;
+ const elementHeight = this.itemRef ? this.itemRef.clientHeight : null;
+
+ if (elementHeight && height !== 'auto' && elementHeight !== height && this.state.forcedUpdateCount < 10) {
+ // forceCacheUpdate() internally calls forceUpdate(), so we need a stop flag
+ // and cannot rely on shouldComponentUpdate() and other comparisons.
+ forceCacheUpdate(index);
+ this.setState(({ forcedUpdateCount }) => ({ forcedUpdateCount: forcedUpdateCount + 1 }));
+ }
+
+ ChatLogger.debug('TimeWindowChatItem::componentDidUpdate::props', { ...this.props }, { ...prevProps });
+ ChatLogger.debug('TimeWindowChatItem::componentDidUpdate::state', { ...this.state }, { ...prevState });
+ }
+
+ componentWillUnmount() {
+ ChatLogger.debug('TimeWindowChatItem::componentWillUnmount::props', { ...this.props });
+ ChatLogger.debug('TimeWindowChatItem::componentWillUnmount::state', { ...this.state });
+ }
+
+ getText(message, messageValues) {
+ const { intl } = this.props;
+
+ let { text } = message;
+
+ if (intlMessages[message.text]) {
+ text = intl.formatMessage(
+ intlMessages[message.text],
+ messageValues || {},
+ );
+ }
+
+ return text;
+ }
+
+ renderSystemMessage() {
+ const {
+ messages,
+ messageValues,
+ chatAreaId,
+ handleReadMessage,
+ messageKey,
+ } = this.props;
+
+ if (messages && messages[0].id.includes(CHAT_POLL_RESULTS_MESSAGE)) {
+ return this.renderPollItem();
+ }
+
+ if (messages && messages[0].id.includes(CHAT_EXPORTED_PRESENTATION_MESSAGE)) {
+ return this.renderExportedPresentationItem();
+ }
+
+ if (messages && messages[0].id.includes(CHAT_USER_STATUS_MESSAGE)) {
+ return this.renderStatusItem();
+ }
+
+ return (
+ this.itemRef = element}
+ >
+
+ {messages.map((message) => (
+ message.text !== ''
+ ? (
+
+ ) : null
+ ))}
+
+
+ );
+ }
+
+ renderMessageItem() {
+ const {
+ timestamp,
+ chatAreaId,
+ scrollArea,
+ intl,
+ messages,
+ messageKey,
+ dispatch,
+ chatId,
+ read,
+ isFromMe,
+ lastReadByPartnerMessageTime,
+ isMessageReadFeedbackEnabled,
+ name,
+ color,
+ messageFromModerator,
+ avatar,
+ isOnline,
+ isSystemSender,
+ } = this.props;
+
+ const dateTime = new Date(timestamp);
+ const regEx = /]+>/i;
+ ChatLogger.debug('TimeWindowChatItem::renderMessageItem', this.props);
+ const defaultAvatarString = name?.toLowerCase().slice(0, 2) || ' ';
+ const shouldRenderPrivateMessageReadFeedback =
+ isMessageReadFeedbackEnabled &&
+ chatId !== CHAT_PUBLIC_ID &&
+ isFromMe &&
+ lastReadByPartnerMessageTime >= messages[messages.length - 1].time;
+ const emphasizedText = messageFromModerator && CHAT_EMPHASIZE_TEXT && chatId === CHAT_PUBLIC_ID;
+
+ return (
+ this.itemRef = element}
+ >
+
+
+
+ {defaultAvatarString}
+
+
+
+
+
+ {name}
+ {isOnline
+ ? null
+ : (
+
+ {`(${intl.formatMessage(intlMessages.offline)})`}
+
+ )}
+
+
+
+
+ {shouldRenderPrivateMessageReadFeedback
+ && (
+
+
+
+ )}
+
+
+ {messages.map((message) => (
+ {
+ if (!read) {
+ dispatch({
+ type: 'last_read_message_timestamp_changed',
+ value: {
+ chatId,
+ timestamp,
+ },
+ });
+ }
+ }}
+ scrollArea={scrollArea}
+ />
+ ))}
+
+
+
+
+ );
+ }
+
+ renderStatusItem() {
+ const {
+ timestamp,
+ color,
+ intl,
+ messages,
+ scrollArea,
+ chatAreaId,
+ messageKey,
+ dispatch,
+ chatId,
+ extra,
+ read,
+ name,
+ messageFromModerator,
+ avatar,
+ isOnline,
+ } = this.props;
+
+ const dateTime = new Date(timestamp);
+ ChatLogger.debug('TimeWindowChatItem::renderMessageItem', this.props);
+ const defaultAvatarString = name?.toLowerCase().slice(0, 2) || ' ';
+ const emphasizedTextClass = messageFromModerator && CHAT_EMPHASIZE_TEXT
+ && chatId === CHAT_PUBLIC_ID;
+
+ return messages ? (
+
+
+
+
+ {defaultAvatarString}
+
+
+
+
+
+ {name}
+ {isOnline
+ ? null
+ : (
+
+ {`(${intl.formatMessage(intlMessages.offline)})`}
+
+ )}
+
+
+
+
+
+
+ {messages.map((message) => {
+ const isSystemMsg = message.id === `SYSTEM_MESSAGE-${CHAT_USER_STATUS_MESSAGE}`;
+ return (
+ ${intl.formatMessage(intlMessages.away)}`
+ : ` ${intl.formatMessage(intlMessages.notAway)}`
+ : message.text}
+ time={message.time}
+ chatAreaId={chatAreaId}
+ dispatch={dispatch}
+ read={message.read}
+ chatUserMessageItem
+ handleReadMessage={(time) => {
+ if (!read) {
+ dispatch({
+ type: 'last_read_message_timestamp_changed',
+ value: {
+ chatId,
+ timestamp: time,
+ },
+ });
+ }
+ }}
+ scrollArea={scrollArea}
+ />
+ );
+ })}
+
+
+
+
+ ) : null;
+ }
+
+ renderPollItem() {
+ const {
+ timestamp,
+ color,
+ intl,
+ getPollResultString,
+ messages,
+ extra,
+ scrollArea,
+ chatAreaId,
+ lastReadMessageTime,
+ dispatch,
+ chatId,
+ read,
+ } = this.props;
+
+ const dateTime = new Date(timestamp);
+
+ return messages ? (
+
+ { this.item = ref; }}>
+
+
+ { }
+
+
+
+
+
+ {intl.formatMessage(intlMessages.pollResult)}
+
+
+
+
+
+ {
+ if (!read) {
+ dispatch({
+ type: 'last_read_message_timestamp_changed',
+ value: {
+ chatId,
+ timestamp: time,
+ },
+ });
+ }
+ }}
+ scrollArea={scrollArea}
+ color={color}
+ />
+
+
+
+ ) : null;
+ }
+
+ renderExportedPresentationItem() {
+ const {
+ timestamp,
+ color,
+ intl,
+ messages,
+ extra,
+ scrollArea,
+ chatAreaId,
+ lastReadMessageTime,
+ handleReadMessage,
+ dispatch,
+ read,
+ chatId,
+ getExportedPresentationString,
+ } = this.props;
+
+ const dateTime = new Date(timestamp);
+
+ return messages ? (
+ { e.stopPropagation(); }}
+ >
+ { this.item = ref; }}>
+
+
+
+
+
+
+
+
+
+
+
+ {
+ handleReadMessage(timestamp);
+
+ if (!read) {
+ dispatch({
+ type: 'last_read_message_timestamp_changed',
+ value: {
+ chatId,
+ timestamp,
+ },
+ });
+ }
+ }}
+ scrollArea={scrollArea}
+ color={color}
+ />
+
+
+
+ ) : null;
+ }
+
+ render() {
+ const {
+ systemMessage,
+ } = this.props;
+ ChatLogger.debug('TimeWindowChatItem::render', { ...this.props });
+ if (systemMessage) {
+ return this.renderSystemMessage();
+ }
+
+ return this.renderMessageItem();
+ }
+}
+
+TimeWindowChatItem.propTypes = propTypes;
+TimeWindowChatItem.defaultProps = defaultProps;
+
+export default injectIntl(TimeWindowChatItem);
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/container.jsx b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/container.jsx
new file mode 100644
index 00000000..6e25a936
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/container.jsx
@@ -0,0 +1,69 @@
+import React, { useContext } from 'react';
+import TimeWindowChatItem from './component';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import ChatService from '../../service';
+import { layoutSelect } from '../../../layout/context';
+import PollService from '/imports/ui/components/poll/service';
+import Auth from '/imports/ui/services/auth';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const TimeWindowChatItemContainer = (props) => {
+ const { message, messageId } = props;
+
+ const idChatOpen = layoutSelect((i) => i.idChatOpen);
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const {
+ sender,
+ senderName,
+ senderRole,
+ key,
+ timestamp,
+ content,
+ extra,
+ messageValues,
+ } = message;
+ const messages = content;
+
+ const user = (sender === 'SYSTEM') ? {
+ name: senderName,
+ color: '#01579b',
+ avatar: '',
+ role: ROLE_MODERATOR,
+ loggedOut: false,
+ } : users[Auth.meetingID][sender];
+ const messageKey = key;
+ const handleReadMessage = (tstamp) => ChatService.updateUnreadMessage(tstamp, idChatOpen);
+ return (
+
+ );
+};
+
+export default TimeWindowChatItemContainer;
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/message-chat-item/component.jsx b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/message-chat-item/component.jsx
new file mode 100644
index 00000000..d85bce85
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/message-chat-item/component.jsx
@@ -0,0 +1,189 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { debounce } from '/imports/utils/debounce';
+import fastdom from 'fastdom';
+import { injectIntl } from 'react-intl';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+
+const propTypes = {
+ text: PropTypes.string.isRequired,
+ time: PropTypes.number.isRequired,
+ lastReadMessageTime: PropTypes.number,
+ handleReadMessage: PropTypes.func.isRequired,
+ scrollArea: PropTypes.instanceOf(Element),
+ className: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+ lastReadMessageTime: 0,
+ scrollArea: undefined,
+};
+
+const eventsToBeBound = [
+ 'scroll',
+ 'resize',
+];
+
+const isElementInViewport = (el) => {
+ if (!el) return false;
+ const rect = el.getBoundingClientRect();
+
+ return (
+ rect.top >= 0
+ // This condition is for large messages that are bigger than client height
+ || rect.top + rect.height >= 0
+ );
+};
+
+class MessageChatItem extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.ticking = false;
+
+ this.handleMessageInViewport = debounce(this.handleMessageInViewport.bind(this), 50);
+ }
+
+ componentDidMount() {
+ this.listenToUnreadMessages();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ ChatLogger.debug('MessageChatItem::componentDidUpdate::props', { ...this.props }, { ...prevProps });
+ ChatLogger.debug('MessageChatItem::componentDidUpdate::state', { ...this.state }, { ...prevState });
+ this.listenToUnreadMessages();
+ }
+
+ componentWillUnmount() {
+ // This was added 3 years ago, but never worked. Leaving it around in case someone returns
+ // and decides it needs to be fixed like the one in listenToUnreadMessages()
+ // if (!lastReadMessageTime > time) {
+ // return;
+ // }
+ ChatLogger.debug('MessageChatItem::componentWillUnmount', this.props);
+ this.removeScrollListeners();
+ }
+
+ addScrollListeners() {
+ const {
+ scrollArea,
+ } = this.props;
+
+ if (scrollArea) {
+ eventsToBeBound.forEach(
+ e => scrollArea.addEventListener(e, this.handleMessageInViewport),
+ );
+ }
+ }
+
+ handleMessageInViewport() {
+ if (!this.ticking) {
+ fastdom.measure(() => {
+ const node = this.text;
+ const {
+ handleReadMessage,
+ time,
+ read,
+ } = this.props;
+
+ if (read) {
+ this.removeScrollListeners();
+ return;
+ }
+
+ if (isElementInViewport(node)) {
+ handleReadMessage(time);
+ this.removeScrollListeners();
+ }
+
+ this.ticking = false;
+ });
+ }
+
+ this.ticking = true;
+ }
+
+ removeScrollListeners() {
+ const {
+ scrollArea,
+ read,
+ } = this.props;
+
+ if (scrollArea && !read) {
+ eventsToBeBound.forEach(
+ e => scrollArea.removeEventListener(e, this.handleMessageInViewport),
+ );
+ }
+ }
+
+ // depending on whether the message is in viewport or not,
+ // either read it or attach a listener
+ listenToUnreadMessages() {
+ const {
+ handleReadMessage,
+ time,
+ read,
+ } = this.props;
+
+ if (read) {
+ return;
+ }
+
+ const node = this.text;
+
+ fastdom.measure(() => {
+ const {
+ read: newRead,
+ } = this.props;
+ // this function is called after so we need to get the updated lastReadMessageTime
+
+ if (newRead) {
+ return;
+ }
+
+ if (isElementInViewport(node)) { // no need to listen, the message is already in viewport
+ handleReadMessage(time);
+ } else {
+ this.addScrollListeners();
+ }
+ });
+ }
+
+ render() {
+ const {
+ text,
+ type,
+ className,
+ isSystemMessage,
+ chatUserMessageItem,
+ systemMessageType,
+ color,
+ } = this.props;
+ ChatLogger.debug('MessageChatItem::render', this.props);
+ if (type === 'poll') {
+ return (
+ { this.text = ref; }}
+ dangerouslySetInnerHTML={{ __html: text }}
+ data-test="chatPollMessageText"
+ />
+ );
+ } else {
+ return (
+
{ this.text = ref; }}
+ dangerouslySetInnerHTML={{ __html: text }}
+ data-test={isSystemMessage ? systemMessageType : chatUserMessageItem ? 'chatUserMessageText' : ''}
+ />
+ );
+ }
+ }
+}
+
+MessageChatItem.propTypes = propTypes;
+MessageChatItem.defaultProps = defaultProps;
+
+export default injectIntl(MessageChatItem);
diff --git a/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/styles.js b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/styles.js
new file mode 100644
index 00000000..ea92693d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/chat/time-window-list/time-window-chat-item/styles.js
@@ -0,0 +1,285 @@
+import styled from 'styled-components';
+import {
+ borderRadius,
+ borderSize,
+ chatPollMarginSm,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { lineHeightComputed, fontSizeBase, btnFontWeight } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ systemMessageBackgroundColor,
+ systemMessageBorderColor,
+ systemMessageFontColor,
+ highlightedMessageBackgroundColor,
+ highlightedMessageBorderColor,
+ colorHeading,
+ colorGrayLight,
+ palettePlaceholderText,
+ colorGrayLighter,
+ colorPrimary,
+ colorText,
+ colorSuccess,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import MessageChatItem from './message-chat-item/component';
+import Icon from '/imports/ui/components/common/icon/component';
+
+const Item = styled.div`
+ padding: calc(${lineHeightComputed} / 4) 0 calc(${lineHeightComputed} / 2) 0;
+ font-size: ${fontSizeBase};
+ pointer-events: auto;
+ [dir="rtl"] & {
+ direction: rtl;
+ }
+`;
+
+const Messages = styled.div`
+ > * {
+ &:first-child {
+ margin-top: calc(${lineHeightComputed} / 4);
+ }
+ }
+`;
+
+const StatusMessageChatItem = styled(MessageChatItem)`
+ background: ${systemMessageBackgroundColor};
+ border: 1px solid ${systemMessageBorderColor};
+ border-radius: ${borderRadius};
+ font-weight: ${btnFontWeight};
+ padding: ${fontSizeBase};
+ color: ${systemMessageFontColor};
+ margin-top: 0;
+ margin-bottom: 0;
+ overflow-wrap: break-word;
+ ${({ isSystemMsg }) => !isSystemMsg && `
+ flex: 1;
+ margin-top: calc(${lineHeightComputed} / 3);
+ margin-bottom: 0;
+ color: ${colorText};
+ word-wrap: break-word;
+ background: unset;
+ border: unset;
+ border-radius: unset;
+ font-weight: unset;
+ padding: unset;
+ `}
+ ${({ emphasizedMessage }) => emphasizedMessage && `
+ font-weight: bold;
+ `}
+`;
+
+const SystemMessageChatItem = styled(MessageChatItem)`
+ ${({ border }) => border && `
+ background-color: ${systemMessageBackgroundColor};
+ border: 1px solid ${systemMessageBorderColor};
+ border-radius: ${borderRadius};
+ font-weight: ${btnFontWeight};
+ padding: ${fontSizeBase};
+ color: ${systemMessageFontColor};
+ margin-top: 0px;
+ margin-bottom: 0px;
+ overflow-wrap: break-word;
+ `}
+
+ ${({ border }) => !border && `
+ color: ${systemMessageFontColor};
+ margin-top: 0px;
+ margin-bottom: 0px;
+ `}
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-flow: row;
+ flex: 1;
+ position: relative;
+ margin: ${borderSize} 0 0 ${borderSize};
+
+ ${({ isSystemSender }) => isSystemSender && `
+ background-color: ${highlightedMessageBackgroundColor};
+ border-left: 2px solid ${highlightedMessageBorderColor};
+ border-radius: 0px 3px 3px 0px;
+ padding: 8px 2px;
+ `}
+
+ [dir="rtl"] & {
+ margin: ${borderSize} ${borderSize} 0 0;
+ }
+`;
+
+const AvatarWrapper = styled.div`
+ flex-basis: 2.25rem;
+ flex-shrink: 0;
+ flex-grow: 0;
+ margin: 0 calc(${lineHeightComputed} / 2) 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 calc(${lineHeightComputed} / 2);
+ }
+`;
+
+const Content = styled.div`
+ flex: 1;
+ display: flex;
+ flex-flow: column;
+ overflow-x: hidden;
+ width: calc(100% - 1.7rem);
+`;
+
+const Meta = styled.div`
+ display: flex;
+ flex: 1;
+ flex-flow: row;
+ line-height: 1.35;
+ align-items: baseline;
+`;
+
+const Name = styled.div`
+ display: flex;
+ min-width: 0;
+ font-weight: 600;
+ position: relative;
+
+ &:first-child {
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ ${({ isOnline }) => isOnline && `
+ color: ${colorHeading};
+ `}
+
+ ${({ isOnline }) => !isOnline && `
+ text-transform: capitalize;
+ font-style: italic;
+
+ & > span {
+ text-align: right;
+ padding: 0 .1rem 0 0;
+
+ [dir="rtl"] & {
+ text-align: left;
+ padding: 0 0 0 .1rem;
+ }
+ }
+ `}
+`;
+
+const Offline = styled.span`
+ color: ${colorGrayLight};
+ font-weight: 100;
+ text-transform: lowercase;
+ font-style: italic;
+ font-size: 90%;
+ line-height: 1;
+ align-self: center;
+`;
+
+const Time = styled.time`
+ flex-shrink: 0;
+ flex-grow: 0;
+ flex-basis: 3.5rem;
+ color: ${palettePlaceholderText};
+ text-transform: uppercase;
+ font-size: 75%;
+ margin: 0 0 0 calc(${lineHeightComputed} / 2);
+
+ [dir="rtl"] & {
+ margin: 0 calc(${lineHeightComputed} / 2) 0 0;
+ }
+
+ & > span {
+ vertical-align: sub;
+ }
+`;
+
+const ChatItem = styled(MessageChatItem)`
+ flex: 1;
+ margin-top: calc(${lineHeightComputed} / 3);
+ margin-bottom: 0;
+ color: ${colorText};
+ word-wrap: break-word;
+
+ ${({ hasLink }) => hasLink && `
+ & > a {
+ color: ${colorPrimary};
+ }
+ `}
+
+ ${({ emphasizedMessage }) => emphasizedMessage && `
+ font-weight: bold;
+ `}
+`;
+
+const PollIcon = styled(Icon)`
+ bottom: 1px;
+`;
+
+const PollMessageChatItem = styled(MessageChatItem)`
+ flex: 1;
+ margin-top: calc(${lineHeightComputed} / 3);
+ margin-bottom: 0;
+ color: ${colorText};
+ word-wrap: break-word;
+
+ background-color: ${systemMessageBackgroundColor};
+ border: solid 1px ${colorGrayLighter};
+ border-radius: ${borderRadius};
+ padding: ${chatPollMarginSm};
+ padding-left: 1rem;
+ margin-top: ${chatPollMarginSm} !important;
+`;
+
+const PresentationWrapper = styled(Wrapper)`
+ display: flex;
+ flex-flow: row;
+ flex: 1;
+ position: relative;
+ margin: ${borderSize} 0 0 ${borderSize};
+ border-left: 2px solid ${colorPrimary};
+ border-radius: 2px;
+ padding: 6px 0 6px 6px;
+ background-color: ${systemMessageBackgroundColor};
+
+ [dir="rtl"] & {
+ margin: ${borderSize} ${borderSize} 0 0;
+ border-right: 2px solid ${colorPrimary};
+ border-left: none;
+ }
+`;
+
+const PresentationChatItem = styled(MessageChatItem)`
+ flex: 1;
+ margin-top: ${chatPollMarginSm};
+ margin-bottom: 0;
+ color: ${colorText};
+ word-wrap: break-word;
+`;
+
+const ReadIcon = styled(Icon)`
+ color: ${colorSuccess};
+ align-self: center;
+ margin-left: auto;
+ margin-right: .5rem;
+`;
+
+export default {
+ Item,
+ Messages,
+ SystemMessageChatItem,
+ Wrapper,
+ AvatarWrapper,
+ Content,
+ Meta,
+ Name,
+ Offline,
+ Time,
+ ChatItem,
+ PollIcon,
+ PollMessageChatItem,
+ PresentationChatItem,
+ PresentationWrapper,
+ StatusMessageChatItem,
+ ReadIcon,
+};
diff --git a/src/2.7.12/imports/ui/components/click-outside/component.jsx b/src/2.7.12/imports/ui/components/click-outside/component.jsx
new file mode 100644
index 00000000..d7f01883
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/click-outside/component.jsx
@@ -0,0 +1,39 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ onClick: PropTypes.func.isRequired,
+}
+
+class ClickOutside extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.childrenRefs = React.Children.map(this.props.children, () => React.createRef());
+ }
+
+ componentDidMount() {
+ document.addEventListener("click", this.handleClick);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener("click", this.handleClick);
+ }
+
+ handleClick = (e) => {
+ const { onClick } = this.props;
+ const isOutside = this.childrenRefs.every(ref => !ref.current.contains(e.target));
+ if (isOutside) {
+ onClick();
+ }
+ };
+
+ render() {
+ return React.Children.map(this.props.children, (element, idx) => {
+ return React.cloneElement(element, { ref: this.childrenRefs[idx] });
+ });
+ }
+}
+
+ClickOutside.propTypes = propTypes;
+
+export default ClickOutside;
diff --git a/src/2.7.12/imports/ui/components/common/button/base/component.jsx b/src/2.7.12/imports/ui/components/common/button/base/component.jsx
new file mode 100644
index 00000000..347cfb9e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/button/base/component.jsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { omit } from 'radash';
+
+const propTypes = {
+ /**
+ * Defines HTML disable Attribute
+ * @defaultValue false
+ */
+ disabled: PropTypes.bool,
+
+ /**
+ * Defines HTML Tag
+ * @defaultValue 'button'
+ */
+ tagName: PropTypes.string,
+
+ /**
+ * Defines the button label
+ * @defaultValue undefined
+ */
+ label: PropTypes.string.isRequired,
+
+ /**
+ * Defines the button click handler
+ * @defaultValue undefined
+ */
+ onClick: (props, propName, componentName) => {
+ if (!props.onClick && !props.onMouseDown && !props.onMouseUp) {
+ return new Error('One of props \'onClick\' or \'onMouseDown\' or' +
+ ` 'onMouseUp' was not specified in '${componentName}'.`);
+ }
+
+ return null;
+ },
+ onMouseDown: (props, propName, componentName) => {
+ if (!props.onClick && !props.onMouseDown && !props.onMouseUp) {
+ return new Error('One of props \'onClick\' or \'onMouseDown\' or' +
+ ` 'onMouseUp' was not specified in '${componentName}'.`);
+ }
+
+ return null;
+ },
+ onMouseUp: (props, propName, componentName) => {
+ if (!props.onClick && !props.onMouseDown && !props.onMouseUp) {
+ return new Error('One of props \'onClick\' or \'onMouseDown\' or' +
+ ` 'onMouseUp' was not specified in '${componentName}'.`);
+ }
+
+ return null;
+ },
+
+ onKeyPress: PropTypes.func,
+ onKeyDown: PropTypes.func,
+ onKeyUp: PropTypes.func,
+ setRef: PropTypes.func,
+};
+
+const defaultProps = {
+ disabled: false,
+ tagName: 'button',
+ onClick: undefined,
+ onMouseDown: undefined,
+ onMouseUp: undefined,
+ onKeyPress: undefined,
+ onKeyDown: undefined,
+ onKeyUp: undefined,
+ setRef: undefined,
+};
+
+/**
+ * Event handlers below are used to intercept a parent event handler from
+ * firing when the Button is disabled.
+ * Key press event handlers intercept firing for
+ * keyboard users to comply with ARIA standards.
+ */
+
+export default class ButtonBase extends React.Component {
+ constructor(props) {
+ super(props);
+
+ // Bind Mouse Event Handlers
+ this.internalClickHandler = this.internalClickHandler.bind(this);
+ this.internalDoubleClickHandler = this.internalDoubleClickHandler.bind(this);
+ this.internalMouseDownHandler = this.internalMouseDownHandler.bind(this);
+ this.internalMouseUpHandler = this.internalMouseUpHandler.bind(this);
+
+ // Bind Keyboard Event Handlers
+ this.internalKeyPressHandler = this.internalKeyPressHandler.bind(this);
+ this.internalKeyDownHandler = this.internalKeyDownHandler.bind(this);
+ this.internalKeyUpHandler = this.internalKeyUpHandler.bind(this);
+ }
+
+ validateDisabled(eventHandler, ...args) {
+ if (!this.props.disabled && typeof eventHandler === 'function') {
+ return eventHandler(...args);
+ }
+
+ return null;
+ }
+
+ // Define Mouse Event Handlers
+ internalClickHandler(...args) {
+ return this.validateDisabled(this.props.onClick, ...args);
+ }
+
+ internalDoubleClickHandler(...args) {
+ return this.validateDisabled(this.props.onDoubleClick, ...args);
+ }
+
+ internalMouseDownHandler(...args) {
+ return this.validateDisabled(this.props.onMouseDown, ...args);
+ }
+
+ internalMouseUpHandler(...args) {
+ return this.validateDisabled(this.props.onMouseUp, ...args);
+ }
+
+ // Define Keyboard Event Handlers
+ internalKeyPressHandler(...args) {
+ return this.validateDisabled(this.props.onKeyPress, ...args);
+ }
+
+ internalKeyDownHandler(...args) {
+ return this.validateDisabled(this.props.onKeyDown, ...args);
+ }
+
+ internalKeyUpHandler(...args) {
+ return this.validateDisabled(this.props.onKeyUp, ...args);
+ }
+
+ render() {
+ const Component = this.props.tagName;
+
+ const remainingProps = Object.assign({}, this.props);
+ delete remainingProps.label;
+ delete remainingProps.tagName;
+ delete remainingProps.disabled;
+
+ // Delete Mouse event handlers
+ delete remainingProps.onClick;
+ delete remainingProps.onDoubleClick;
+ delete remainingProps.onMouseDown;
+ delete remainingProps.onMouseUp;
+
+ // Delete Keyboard event handlers
+ delete remainingProps.onKeyPress;
+ delete remainingProps.onKeyDown;
+ delete remainingProps.onKeyUp;
+
+ // Delete setRef callback if it exists
+ delete remainingProps.setRef;
+
+ const styleProps = [
+ 'ghost',
+ 'circle',
+ 'block',
+ 'hasNotification',
+ 'isStyled',
+ 'isDownloadable',
+ 'animations',
+ 'small',
+ 'full',
+ 'iconRight',
+ 'isVisualEffects',
+ 'panning',
+ 'panSelected',
+ ];
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+ButtonBase.propTypes = propTypes;
+ButtonBase.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/common/button/button-emoji/ButtonEmoji.jsx b/src/2.7.12/imports/ui/components/common/button/button-emoji/ButtonEmoji.jsx
new file mode 100644
index 00000000..13a3710f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/button/button-emoji/ButtonEmoji.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+
+const propTypes = {
+ /**
+ * Defines the name of the emoji to be used, as defined in bbb-icons.css
+ * @type String
+ * @defaultValue ''
+ */
+ emoji: PropTypes.string,
+
+ label: PropTypes.string,
+
+ onClick: PropTypes.func,
+
+ onKeyDown: PropTypes.func,
+
+ onFocus: PropTypes.func,
+
+ tabIndex: PropTypes.number,
+
+ hideLabel: PropTypes.bool,
+
+ className: PropTypes.string,
+
+ rotate: PropTypes.bool,
+};
+
+const defaultProps = {
+ emoji: '',
+ label: '',
+ onKeyDown: null,
+ onFocus: null,
+ tabIndex: -1,
+ hideLabel: false,
+ onClick: null,
+ className: '',
+ rotate: false,
+};
+
+const ButtonEmoji = (props) => {
+ const {
+ hideLabel,
+ className,
+ hidden,
+ rotate,
+ ...newProps
+ } = props;
+
+ const {
+ emoji,
+ label,
+ tabIndex,
+ onClick,
+ } = newProps;
+
+ const IconComponent = ( );
+
+ return (
+
+
+
+
+
+ { !hideLabel && label }
+ { IconComponent }
+
+
+
+
+ );
+};
+
+export default ButtonEmoji;
+
+ButtonEmoji.propTypes = propTypes;
+ButtonEmoji.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/common/button/button-emoji/styles.js b/src/2.7.12/imports/ui/components/common/button/button-emoji/styles.js
new file mode 100644
index 00000000..3eb4fbf5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/button/button-emoji/styles.js
@@ -0,0 +1,91 @@
+import styled from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import {
+ btnDefaultColor,
+ btnDefaultBg,
+ colorBackground,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { btnSpacing } from '/imports/ui/stylesheets/styled-components/general';
+
+const EmojiButtonIcon = styled(Icon)`
+ position: absolute;
+ top: 0;
+ height: 40%;
+ left: 0;
+ width: 75%;
+ margin-left: 14%;
+ font-size: 50%;
+ margin-top: 30%;
+ color: ${btnDefaultColor};
+`;
+
+const Label = styled.span`
+ & + i,
+ & + button {
+ margin: 0 0 0 ${btnSpacing};
+
+ [dir="rtl"] & {
+ margin: 0 ${btnSpacing} 0 0;
+ }
+ }
+ &:hover {
+ opacity: .5;
+ }
+`;
+
+const EmojiButton = styled.button`
+ position: absolute;
+ border-radius: 50%;
+ width: 1em;
+ height: 1em;
+ right: -.2em;
+ bottom: 0;
+ background-color: ${btnDefaultBg};
+ overflow: hidden;
+ z-index: 2;
+ border: none;
+ padding: 0;
+
+ [dir="rtl"] & {
+ right: initial;
+ left: -.2em;
+ }
+
+ &:hover {
+ transform: scale(1.5);
+ transition-duration: 150ms;
+ }
+
+ & > i {
+ position: absolute;
+ top: 0;
+ height: 60%;
+ left: 0;
+ margin-left: 25%;
+ font-size: 50%;
+ margin-top: 40%;
+ color: ${btnDefaultColor};
+ }
+`;
+
+const EmojiButtonSpace = styled.div`
+ position: absolute;
+ height: 1.4em;
+ width: 1.4em;
+ background-color: ${colorBackground};
+ right: -.4em;
+ bottom: -.2em;
+ border-radius: 50%;
+
+ [dir="rtl"] & {
+ right: initial;
+ left: -.4em;
+ }
+`;
+
+export default {
+ EmojiButtonIcon,
+ Label,
+ EmojiButton,
+ EmojiButtonSpace,
+};
diff --git a/src/2.7.12/imports/ui/components/common/button/component.jsx b/src/2.7.12/imports/ui/components/common/button/component.jsx
new file mode 100644
index 00000000..c5327a48
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/button/component.jsx
@@ -0,0 +1,257 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+import Styled from './styles';
+import BaseButton from './base/component';
+import ButtonEmoji from './button-emoji/ButtonEmoji';
+
+const SIZES = [
+ 'jumbo', 'lg', 'md', 'sm',
+];
+
+const COLORS = [
+ 'default', 'primary', 'danger', 'warning', 'success', 'dark', 'light', 'offline', 'muted', 'secondary',
+];
+
+const propTypes = {
+ ...BaseButton.propTypes,
+ /**
+ * Defines the button size style
+ * @type {("lg"|"md"|"sm")}
+ * @defaultValue 'md'
+ */
+ size: PropTypes.oneOf(SIZES),
+
+ /**
+ * Defines the button color style
+ * @type {("default"|"primary"|"danger"|"success")}
+ * @defaultValue 'md'
+ */
+ color: PropTypes.oneOf(COLORS),
+
+ /**
+ * Defines if the button should be styled as a ghost (outline)
+ * @defaultValue false
+ */
+ ghost: PropTypes.bool,
+
+ /**
+ * Defines if the button should be styled as circle
+ * @defaultValue false
+ */
+ circle: PropTypes.bool,
+
+ /**
+ * Defines if the button should have `display: block`
+ * @defaultValue false
+ */
+ block: PropTypes.bool,
+
+ /**
+ * Defines the button icon
+ * @defaultValue undefined
+ */
+ icon: PropTypes.string,
+
+ /**
+ * Defines the button icon is on the right side
+ * @defaultValue false
+ */
+ iconRight: PropTypes.bool,
+
+ /**
+ * Defines the button label should be visible
+ * @defaultValue false
+ */
+ hideLabel: PropTypes.bool,
+
+ /**
+ * Optional SVG / html object can be passed to the button as an icon
+ * Has to be styled before being sent to the Button
+ * (e.g width, height, position and percentage-based object's coordinates)
+ * @defaultvalue undefined
+ */
+ customIcon: PropTypes.node,
+};
+
+const defaultProps = {
+ ...BaseButton.defaultProps,
+ size: 'md',
+ color: 'default',
+ ghost: false,
+ circle: false,
+ block: false,
+ iconRight: false,
+ hideLabel: false,
+ tooltipLabel: '',
+};
+
+export default class Button extends BaseButton {
+ _cleanProps(otherProps) {
+ const remainingProps = Object.assign({}, otherProps);
+ delete remainingProps.icon;
+ delete remainingProps.customIcon;
+ delete remainingProps.size;
+ delete remainingProps.color;
+ delete remainingProps.ghost;
+ delete remainingProps.circle;
+ delete remainingProps.block;
+ delete remainingProps.hideLabel;
+ delete remainingProps.tooltipLabel;
+
+ return remainingProps;
+ }
+
+ hasButtonEmojiComponent() {
+ const { children } = this.props;
+
+ if (!children) return false;
+
+ const buttonEmoji = React.Children.only(children);
+
+ return (buttonEmoji && buttonEmoji.type && buttonEmoji.type.name)
+ ? (buttonEmoji.type.name === ButtonEmoji.name)
+ : false;
+ }
+
+ render() {
+ const {
+ circle,
+ hideLabel,
+ label,
+ 'aria-label': ariaLabel,
+ 'aria-expanded': ariaExpanded,
+ tooltipLabel,
+ tooltipdelay,
+ tooltipplacement,
+ } = this.props;
+
+ const renderFuncName = circle ? 'renderCircle' : 'renderDefault';
+
+ if ((hideLabel && !ariaExpanded) || tooltipLabel) {
+ const buttonLabel = label || ariaLabel;
+ return (
+
+ {this[renderFuncName]()}
+
+ );
+ }
+
+ return this[renderFuncName]();
+ }
+
+ renderDefault() {
+ const {
+ className,
+ iconRight,
+ size,
+ color,
+ ghost,
+ circle,
+ block,
+ ...otherProps
+ } = this.props;
+
+ const remainingProps = this._cleanProps(otherProps);
+
+ return (
+
+ {this.renderIcon()}
+ {this.renderLabel()}
+
+ );
+ }
+
+ renderCircle() {
+ const {
+ className,
+ size,
+ iconRight,
+ children,
+ color,
+ ghost,
+ circle,
+ block,
+ ...otherProps
+ } = this.props;
+
+ const remainingProps = this._cleanProps(otherProps);
+
+ return (
+
+ {this.renderButtonEmojiSibling()}
+ {!iconRight ? null : this.renderLabel()}
+
+ {this.renderIcon()}
+
+ {iconRight ? null : this.renderLabel()}
+ {this.hasButtonEmojiComponent() ? children : null}
+
+ );
+ }
+
+ renderButtonEmojiSibling() {
+ if (!this.hasButtonEmojiComponent()) {
+ return null;
+ }
+
+ return ( );
+ }
+
+ renderIcon() {
+ const {
+ icon: iconName,
+ customIcon,
+ } = this.props;
+
+ if (iconName) {
+ return ( );
+ } if (customIcon) {
+ return customIcon;
+ }
+
+ return null;
+ }
+
+ renderLabel() {
+ const { label, hideLabel } = this.props;
+
+ return (
+
+ {label}
+ {!this.hasButtonEmojiComponent() ? this.props.children : null}
+
+ );
+ }
+}
+
+Button.propTypes = propTypes;
+Button.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/common/button/styles.js b/src/2.7.12/imports/ui/components/common/button/styles.js
new file mode 100644
index 00000000..1f1f8e00
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/button/styles.js
@@ -0,0 +1,1223 @@
+import styled from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import {
+ btnSpacing,
+ borderRadius,
+ borderSizeSmall,
+ borderSize,
+ borderSizeLarge,
+ smPaddingY,
+ smPaddingX,
+ mdPaddingY,
+ mdPaddingX,
+ lgPaddingY,
+ lgPaddingX,
+ jumboPaddingY,
+ jumboPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ fontSizeSmall,
+ fontSizeBase,
+ fontSizeLarge,
+ btnFontWeight,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ btnDefaultColor,
+ btnDefaultBg,
+ btnDefaultGhostColor,
+ btnDefaultGhostBg,
+ btnDefaultGhostActiveBg,
+ btnDefaultGhostBorder,
+ btnPrimaryBorder,
+ btnPrimaryColor,
+ btnPrimaryBg,
+ btnPrimaryHoverBg,
+ btnPrimaryActiveBg,
+ btnSuccessBorder,
+ btnSuccessColor,
+ btnSuccessBg,
+ btnWarningBorder,
+ btnWarningColor,
+ btnWarningBg,
+ btnDangerBorder,
+ btnDangerColor,
+ btnDangerBg,
+ btnDangerBgHover,
+ btnDarkBorder,
+ btnDarkColor,
+ btnDarkBg,
+ btnOfflineBorder,
+ btnOfflineColor,
+ btnOfflineBg,
+ btnMutedBorder,
+ btnMutedColor,
+ btnMutedBg,
+ colorWhite,
+ colorGray,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import BaseButton from './base/component';
+
+const ButtonIcon = styled(Icon)`
+ width: 1em;
+ height: 1em;
+ text-align: center;
+
+ &:before {
+ width: 1em;
+ height: 1em;
+ }
+
+ .buttonWrapper & {
+ font-size: 125%;
+ }
+
+ & + span {
+ margin: 0 0 0 ${btnSpacing};
+
+ [dir="rtl"] & {
+ margin: 0 ${btnSpacing} 0 0;
+ }
+ }
+`;
+
+const EmojiButtonSibling = styled.span`
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ z-index: 0;
+`;
+
+const ButtonLabel = styled.span`
+ & + i,
+ & + button {
+ margin: 0 0 0 ${btnSpacing};
+
+ [dir="rtl"] & {
+ margin: 0 ${btnSpacing} 0 0;
+ }
+ }
+ &:hover,
+ .buttonWrapper:hover & {
+ opacity: .5;
+ }
+
+ ${({ hideLabel }) => hideLabel && `
+ font-size: 0;
+ height: 0;
+ width: 0;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: hidden;
+ display: none !important;
+ `}
+`;
+
+const ButtonWrapper = styled(BaseButton)`
+ border: none;
+ overflow: visible !important;
+ display: inline-block;
+ cursor: pointer;
+
+ &:focus,
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:focus {
+ outline-style: solid;
+ }
+
+ &:-moz-focusring {
+ outline-color: transparent;
+ outline-offset: ${borderRadius};
+ }
+
+ &:active {
+ &:focus {
+ span:first-of-type::before {
+ border-radius: 50%;
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ }
+ }
+ }
+
+ line-height: 1.5;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ background: none;
+ padding: 0 !important;
+
+ &[aria-disabled="true"] > span {
+ cursor: not-allowed;
+ opacity: .65;
+ box-shadow: none;
+ }
+
+ & > span {
+ display: block;
+ text-align: center;
+ white-space: nowrap;
+ border: ${borderSizeSmall} solid transparent;
+ }
+
+ ${({ size }) => size === 'sm' && `
+ font-size: calc(${fontSizeSmall} * .85);
+ padding: ${smPaddingY} ${smPaddingX};
+
+ & > span {
+ border: ${borderSizeLarge} solid transparent;
+ }
+
+ & > label {
+ display: inline-block;
+ margin: 0 0 0 ${btnSpacing};
+
+ [dir="rtl"] & {
+ margin:0 ${btnSpacing} 0 0;
+ }
+ }
+ `}
+
+ ${({ size }) => size === 'md' && `
+ font-size: calc(${fontSizeBase} * .85);
+ padding: ${mdPaddingY} ${mdPaddingX};
+
+ & > span {
+ border: ${borderSizeLarge} solid transparent;
+ }
+ `}
+
+ ${({ size }) => size === 'lg' && `
+ font-size: ${fontSizeBase};
+ padding: ${lgPaddingY} ${lgPaddingX};
+ `}
+
+ ${({ size }) => size === 'jumbo' && `
+ font-size: 3rem;
+ padding: ${jumboPaddingY} ${jumboPaddingX};
+ `}
+
+ ${({ size, circle, color }) => size === 'lg' && circle && color === 'primary' && `
+ &:focus:not([aria-disabled="true"]){
+ & > span{
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnPrimaryBorder};
+ }
+ }
+
+ &:hover{
+ & > span{
+ filter: brightness(90%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryHoverBg} !important;
+ }
+ }
+
+ &:active:focus{
+ & > span{
+ filter: brightness(85%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg};
+ }
+ }
+
+ &:active{
+ & > span{
+ filter: brightness(85%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg};
+ }
+ }
+ `}
+
+ ${({
+ size, circle, ghost, color,
+ }) => size === 'lg' && circle && ghost && color === 'default' && `
+ span {
+ box-shadow: 0 0 1px 0px ${btnDefaultGhostColor} inset, 0 0 1px 0px ${btnDefaultGhostColor};
+ background-color: transparent !important;
+ border-color: ${btnDefaultGhostColor} !important;
+ }
+
+ & > span{
+ color: ${btnDefaultGhostColor};
+ }
+
+ &:focus:not([aria-disabled="true"]){
+ & > span{
+ background-color: ${btnDefaultGhostBg} !important;
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnDefaultGhostBorder};
+ border-color: transparent !important;
+ }
+ }
+
+ &:hover{
+ & > span{
+ filter: brightness(85%);
+ background-color: ${btnDefaultGhostBg} !important;
+ }
+ }
+
+ &:active:focus{
+ & > span{
+ filter: brightness(85%);
+ background-color: ${btnDefaultGhostActiveBg} !important;
+ }
+ }
+
+ &:active{
+ & > span{
+ filter: brightness(85%);
+ background-color: ${btnDefaultGhostActiveBg};
+ }
+ }
+ `}
+
+ ${({ ghost }) => ghost && `
+ & > span{
+ background-image: none;
+ background-color: transparent;
+ }
+ `}
+`;
+
+const ButtonSpan = styled.span`
+ border: none;
+ overflow: visible;
+ display: inline-block;
+ border-radius: ${borderSize};
+ font-weight: ${btnFontWeight};
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ cursor: pointer;
+ user-select: none;
+
+ &:-moz-focusring {
+ outline: none;
+ }
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ text-decoration: none;
+ }
+
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ }
+
+ &:active {
+ background-image: none;
+ }
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: .65;
+ box-shadow: none;
+ }
+
+ &,
+ &:active {
+ &:focus {
+ span:first-of-type::before {
+ border-radius: ${borderSize};
+ }
+ }
+ }
+
+ ${({ size }) => size === 'sm' && `
+ font-size: calc(${fontSizeSmall} * .85);
+ padding: ${smPaddingY} ${smPaddingX};
+ `}
+
+ ${({ size }) => size === 'md' && `
+ font-size: calc(${fontSizeBase} * .85);
+ padding: ${mdPaddingY} ${mdPaddingX};
+ `}
+
+ ${({ size }) => size === 'lg' && `
+ height: 3rem;
+ width: 3rem;
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ `}
+
+ ${({ size }) => size === 'jumbo' && `
+ font-size: 3rem;
+ padding: ${jumboPaddingY} ${jumboPaddingX};
+ `}
+
+ ${({ size, color }) => size === 'md' && color === 'light' && `
+ color: ${colorGray};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${colorGray};
+ box-shadow: 0 0 0 1px #CDD6E0 !important;
+ background-color: #DCE4EC !important;
+ }
+
+ &:hover {
+ color: hsl(210, 13%, 20%) !important;
+ background-color: #DCE4EC !important;
+ }
+
+ &:active {
+ color: hsl(210, 13%, 20%) !important;
+ background-color: hsl(210, 30%, 80%) !important;
+ }
+
+ &:focus:hover {
+ color: hsl(210, 13%, 20%) !important;
+ box-shadow: 0 0 0 1px #CDD6E0 !important;
+ background-color: #DCE4EC !important;
+ }
+
+ &:focus:active {
+ color: hsl(210, 13%, 20%) !important;
+ box-shadow: 0 0 0 1px #CDD6E0 !important;
+ background-color: hsl(210, 30%, 80%) !important;
+ }
+ `}
+
+ ${({ size, color }) => size === 'md' && color === 'dark' && `
+ color: ${colorWhite};
+ background: none !important;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${colorWhite};
+ box-shadow: 0 0 0 1px ${btnDefaultGhostBorder} !important;
+ background-color: ${btnDefaultGhostBg} !important;
+ }
+
+ &:hover {
+ color: hsl(0, 0%, 85%) !important;
+ background-color: ${btnDefaultGhostBg} !important;
+ }
+
+ &:active {
+ color: hsl(0, 0%, 85%) !important;
+ background-color: ${btnDefaultGhostActiveBg} !important;
+ }
+
+ &:focus:hover {
+ color: hsl(0, 0%, 85%) !important;
+ box-shadow: 0 0 0 1px ${btnDefaultGhostBorder} !important;
+ background-color: ${btnDefaultGhostBg} !important;
+ }
+
+ &:focus:active {
+ color: hsl(0, 0%, 85%) !important;
+ box-shadow: 0 0 0 1px ${btnDefaultGhostBorder} !important;
+ background-color: ${btnDefaultGhostActiveBg} !important;
+ }
+ `}
+
+ ${({ color, ghost }) => color === 'default' && !ghost && `
+ color: ${btnDefaultColor};
+ background-color: ${btnDefaultBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnDefaultColor};
+ background-color: ${btnDefaultBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnPrimaryBg};
+ }
+
+ &:hover & {
+ color: ${btnDefaultBg};
+ }
+ `}
+
+ ${({ color }) => color === 'primary' && `
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnPrimaryBg};
+ }
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnPrimaryColor};
+ }
+
+ `}
+
+ ${({ color }) => color === 'success' && `
+ color: ${btnSuccessColor};
+ background-color: ${btnSuccessBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnSuccessColor};
+ background-color: ${btnSuccessBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnSuccessBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnSuccessColor};
+ }
+ `}
+
+ ${({ color }) => color === 'warning' && `
+ color: ${btnWarningColor};
+ background-color: ${btnWarningBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnWarningColor};
+ background-color: ${btnWarningBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnWarningBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnWarningColor};
+ }
+ `}
+
+ ${({ color }) => color === 'danger' && `
+ color: ${btnDangerColor};
+ background-color: ${btnDangerBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnDangerColor};
+ background-color: ${btnDangerBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnDangerBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDangerColor};
+ }
+ `}
+
+ ${({ color }) => color === 'dark' && `
+ color: ${btnDarkColor};
+ background-color: ${btnDarkBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus {
+ color: ${btnDarkColor};
+ background-color: ${btnDarkBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnDarkBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDarkColor};
+ }
+ `}
+
+ ${({ color }) => color === 'offline' && `
+ color: ${btnOfflineColor};
+ background-color: ${btnOfflineBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnOfflineColor};
+ background-color: ${btnOfflineBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnOfflineBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnOfflineColor};
+ }
+ `}
+
+ ${({ color }) => color === 'muted' && `
+ color: ${btnMutedColor};
+ background-color: ${btnMutedBg};
+ border: ${borderSizeLarge} solid transparent;
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnMutedColor};
+ background-color: ${btnMutedBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnMutedBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnMutedColor};
+ }
+ `}
+
+ ${({ ghost, color, size }) => ghost && color === 'default' && size !== 'lg' && `
+ color: ${btnDefaultBg};
+ background-image: none;
+ background-color: transparent;
+ border: ${borderSizeLarge} solid transparent;
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnDefaultBg};
+ background-color: ${btnDefaultColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnDefaultBg} !important;
+ }
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDefaultBg};
+ background-color: ${btnDefaultColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'primary' && `
+ color: ${btnPrimaryBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnPrimaryBg};
+ background-color: ${btnPrimaryColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnPrimaryBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnPrimaryBg};
+ background-color: ${btnPrimaryColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'success' && `
+ color: ${btnSuccessBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnSuccessBg};
+ background-color: ${btnSuccessColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnSuccessBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnSuccessBg};
+ background-color: ${btnSuccessColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'warning' && `
+ color: ${btnWarningBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnWarningBg};
+ background-color: ${btnWarningColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnWarningBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnWarningBg};
+ background-color: ${btnWarningColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'danger' && `
+ color: ${btnDangerBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnDangerBg};
+ background-color: ${btnDangerColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnDangerBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDangerBg};
+ background-color: ${btnDangerColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'dark' && `
+ color: ${btnDarkBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnDarkBg};
+ background-color: ${btnDarkColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnDarkBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDarkBg};
+ background-color: ${btnDarkColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'offline' && `
+ color: ${btnOfflineBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnOfflineBg};
+ background-color: ${btnOfflineColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnOfflineBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnOfflineBg};
+ background-color: ${btnOfflineColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'muted' && `
+ color: ${btnMutedBg};
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnMutedBg};
+ background-color: ${btnMutedColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnMutedBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnMutedBg};
+ background-color: ${btnMutedColor};
+ }
+ `}
+
+ ${({ circle }) => circle && `
+ border-radius: 50%;
+ `}
+
+ ${({ circle, size }) => circle && size === 'sm' && `
+ padding: calc(${smPaddingX} / 2);
+ `}
+
+ ${({ circle, size }) => circle && size === 'md' && `
+ padding: calc(${mdPaddingX} / 2);
+ `}
+
+ ${({ circle, size }) => circle && size === 'lg' && `
+ padding: calc(${lgPaddingX} / 2);
+ `}
+
+ ${({ circle, size }) => circle && size === 'jumbo' && `
+ padding: calc(${jumboPaddingX} / 2);
+ `}
+
+ ${({ block }) => block && `
+ display: block;
+ width: 100%;
+ `}
+`;
+
+const Button = styled(BaseButton)`
+ border: ${borderSizeLarge} solid transparent;
+ border: none;
+ overflow: visible;
+ display: inline-block;
+ border-radius: ${borderSize};
+ font-weight: ${btnFontWeight};
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ cursor: pointer;
+ user-select: none;
+
+ &:-moz-focusring {
+ outline: none;
+ }
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ text-decoration: none;
+ }
+
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ }
+
+ &:active {
+ background-image: none;
+ }
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: .65;
+ box-shadow: none;
+ }
+
+ &,
+ &:active {
+ &:focus {
+ span:first-of-type::before {
+ border-radius: ${borderSize};
+ }
+ }
+ }
+
+ ${({ size }) => size === 'sm' && `
+ font-size: calc(${fontSizeSmall} * .85);
+ padding: ${smPaddingY} ${smPaddingX};
+ `}
+
+ ${({ size }) => size === 'md' && `
+ font-size: calc(${fontSizeBase} * .85);
+ padding: ${mdPaddingY} ${mdPaddingX};
+ `}
+
+ ${({ size }) => size === 'lg' && `
+ font-size: calc(${fontSizeLarge} * .85);
+ padding: ${lgPaddingY} ${lgPaddingX};
+ `}
+
+ ${({ size }) => size === 'jumbo' && `
+ font-size: 3rem;
+ padding: ${jumboPaddingY} ${jumboPaddingX};
+ `}
+
+ ${({ color }) => color === 'default' && `
+ color: ${btnDefaultColor};
+ background-color: ${btnDefaultBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnDefaultColor};
+ background-color: ${btnDefaultBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnPrimaryBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDefaultColor};
+ }
+ `}
+
+ ${({ color }) => color === 'primary' && `
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryBg};
+ border: ${borderSizeLarge} solid transparent !important;
+
+ &:focus:not([aria-disabled="true"]){
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnPrimaryBorder};
+ }
+
+ &:hover{
+ filter: brightness(90%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryHoverBg} !important;
+ }
+
+ &:active:focus{
+ filter: brightness(85%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg};
+ }
+
+ &:active{
+ filter: brightness(85%);
+ color: ${btnPrimaryColor};
+ background-color: ${btnPrimaryActiveBg} !important;
+ }
+ `}
+
+ ${({ color }) => color === 'success' && `
+ color: ${btnSuccessColor};
+ background-color: ${btnSuccessBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnSuccessColor};
+ background-color: ${btnSuccessBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnSuccessBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnSuccessColor};
+ }
+ `}
+
+ ${({ color }) => color === 'warning' && `
+ color: ${btnWarningColor};
+ background-color: ${btnWarningBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnWarningColor};
+ background-color: ${btnWarningBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnWarningBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnWarningColor};
+ }
+ `}
+
+ ${({ color }) => color === 'danger' && `
+ color: ${btnDangerColor};
+ background-color: ${btnDangerBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnDangerColor};
+ background-color: ${btnDangerBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnDangerBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDangerColor};
+ background-color: ${btnDangerBgHover};
+ }
+ `}
+
+ ${({ color }) => color === 'dark' && `
+ color: ${btnDarkColor};
+ background-color: ${btnDarkBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnDarkColor};
+ background-color: ${btnDarkBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnDarkBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDarkColor};
+ }
+ `}
+
+ ${({ color }) => color === 'offline' && `
+ color: ${btnOfflineColor};
+ background-color: ${btnOfflineBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnOfflineColor};
+ background-color: ${btnOfflineBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnOfflineBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnOfflineColor};
+ }
+ `}
+
+ ${({ color }) => color === 'muted' && `
+ color: ${btnMutedColor};
+ background-color: ${btnMutedBg};
+
+ &:focus,
+ .buttonWrapper:focus:not([aria-disabled="true"]) & {
+ color: ${btnMutedColor};
+ background-color: ${btnMutedBg};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnMutedBorder};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnMutedColor};
+ }
+ `}
+
+ ${({ color }) => color === 'secondary' && `
+ background: transparent;
+ color: ${colorGray};
+ border: 3px solid transparent;
+ border-radius: 4px;
+
+
+ &:focus {
+ background: hsl(210, 30%, 95%);
+ box-shadow: 0 0 0 ${borderSize} hsl(211, 87%, 80%);
+ }
+
+ &:hover {
+ background: hsl(210, 30%, 95%);
+ color: hsl(210, 13%, 35%);
+ }
+
+ &:active {
+ background: hsl(210, 30%, 89%);
+ color: hsl(210, 13%, 30%);
+ }
+
+ &:hover {
+ &:focus {
+ background: hsl(210, 30%, 95%);
+ color: hsl(210, 13%, 30%);
+ box-shadow: 0 0 0 ${borderSize} hsl(211, 87%, 80%);
+ }
+ }
+
+ &:focus {
+ &:active {
+ background: hsl(210, 30%, 89%);
+ color: hsl(210, 13%, 30%);
+ box-shadow: 0 0 0 ${borderSize} hsl(211, 87%, 80%);
+ }
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'default' && `
+ color: ${btnDefaultBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnDefaultBg};
+ background-color: ${btnDefaultColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnDefaultBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDefaultBg};
+ background-color: ${btnDefaultColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'primary' && `
+ color: ${btnPrimaryBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnPrimaryBg};
+ background-color: ${btnPrimaryColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnPrimaryBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnPrimaryBg};
+ background-color: ${btnPrimaryColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'success' && `
+ color: ${btnSuccessBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnSuccessBg};
+ background-color: ${btnSuccessColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnSuccessBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnSuccessBg};
+ background-color: ${btnSuccessColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'warning' && `
+ color: ${btnWarningBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnWarningBg};
+ background-color: ${btnWarningColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnWarningBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnWarningBg};
+ background-color: ${btnWarningColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'danger' && `
+ color: ${btnDangerBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnDangerBg};
+ background-color: ${btnDangerColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnDangerBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDangerBg};
+ background-color: ${btnDangerColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'dark' && `
+ color: ${btnDarkBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnDarkBg};
+ background-color: ${btnDarkColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnDarkBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnDarkBg};
+ background-color: ${btnDarkColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'offline' && `
+ color: ${btnOfflineBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnOfflineBg};
+ background-color: ${btnOfflineColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnOfflineBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnOfflineBg};
+ background-color: ${btnOfflineColor};
+ }
+ `}
+
+ ${({ ghost, color }) => ghost && color === 'muted' && `
+ color: ${btnMutedBg};
+ background-image: none;
+ background-color: transparent;
+
+ &:focus,
+ .buttonWrapper:focus & {
+ color: ${btnMutedBg};
+ background-color: ${btnMutedColor};
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSizeLarge} ${btnMutedBg};
+ }
+
+ &:hover,
+ .buttonWrapper:hover & {
+ color: ${btnMutedBg};
+ background-color: ${btnMutedColor};
+ }
+ `}
+
+ ${({ circle }) => circle && `
+ border-radius: 50%;
+ `}
+
+ ${({ circle, size }) => circle && size === 'sm' && `
+ padding: calc(${smPaddingX} / 2);
+ `}
+
+ ${({ circle, size }) => circle && size === 'md' && `
+ padding: calc(${mdPaddingX} / 2);
+ `}
+
+ ${({ circle, size }) => circle && size === 'lg' && `
+ padding: calc(${lgPaddingX} / 2);
+ `}
+
+ ${({ circle, size }) => circle && size === 'jumbo' && `
+ padding: calc(${jumboPaddingX} / 2);
+ `}
+
+ ${({ block }) => block && `
+ display: block;
+ width: 100%;
+ `}
+
+ ${({ iconRight }) => iconRight && `
+ display: flex;
+ flex-direction: row-reverse;
+ `}
+`;
+
+export default {
+ ButtonIcon,
+ EmojiButtonSibling,
+ ButtonLabel,
+ ButtonWrapper,
+ ButtonSpan,
+ Button,
+};
diff --git a/src/2.7.12/imports/ui/components/common/checkbox/base.jsx b/src/2.7.12/imports/ui/components/common/checkbox/base.jsx
new file mode 100644
index 00000000..8d08c4fb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/checkbox/base.jsx
@@ -0,0 +1,66 @@
+import React, { createRef, PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { findDOMNode } from 'react-dom';
+
+const propTypes = {
+ disabled: PropTypes.bool,
+ checked: PropTypes.bool,
+ onChange: PropTypes.func.isRequired,
+ ariaLabelledBy: PropTypes.string,
+ ariaLabel: PropTypes.string,
+ ariaDescribedBy: PropTypes.string,
+ ariaDesc: PropTypes.string,
+};
+
+const defaultProps = {
+ disabled: false,
+ checked: false,
+ ariaLabelledBy: null,
+ ariaLabel: null,
+ ariaDescribedBy: null,
+ ariaDesc: null,
+};
+
+export default class Base extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.onChange = props.onChange;
+ this.handleChange = this.handleChange.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+
+ this.element = createRef();
+ }
+
+ componentDidMount() {
+ const element = findDOMNode(this.element.current);
+ if (element) element.addEventListener('keydown', this.handleKeyDown);
+ }
+
+ componentWillUnmount() {
+ const element = findDOMNode(this.element.current);
+ if (element) element.removeEventListener('keydown', this.handleKeyDown);
+ }
+
+ handleKeyDown(event) {
+ const { key } = event;
+ const node = findDOMNode(this.element.current);
+ if (key === 'Enter' && node) {
+ const input = node.getElementsByTagName('input')[0];
+ input?.click();
+ }
+ }
+
+ handleChange() {
+ const { disabled, keyValue } = this.props;
+ if (disabled) return;
+ this.onChange(keyValue);
+ }
+
+ render() {
+ return null;
+ }
+}
+
+Base.propTypes = propTypes;
+Base.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/common/checkbox/component.jsx b/src/2.7.12/imports/ui/components/common/checkbox/component.jsx
new file mode 100644
index 00000000..057ebb45
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/checkbox/component.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import Base from './base';
+import Styled from './styles';
+
+export default class Checkbox extends Base {
+ render() {
+ const {
+ ariaLabel, ariaDesc, ariaDescribedBy, ariaLabelledBy, checked, disabled, label,
+ } = this.props;
+
+ const checkbox = (
+
+ );
+
+ return (
+ <>
+ {label ? (
+
+ ) : checkbox}
+
{ariaDesc}
+ >
+ );
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/common/checkbox/styles.js b/src/2.7.12/imports/ui/components/common/checkbox/styles.js
new file mode 100644
index 00000000..4cc5312e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/checkbox/styles.js
@@ -0,0 +1,25 @@
+import { colorText, colorSuccess } from '/imports/ui/stylesheets/styled-components/palette';
+import BaseCheckbox from '@mui/material/Checkbox';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import { styled } from '@mui/system';
+
+const Checkbox = styled(BaseCheckbox)(() => ({
+ '&.Mui-checked': {
+ color: `${colorSuccess} !important`,
+ },
+}));
+
+const Label = styled(FormControlLabel)(() => ({
+ '& .MuiFormControlLabel-label': {
+ fontFamily: 'inherit !important',
+ color: `${colorText} !important`,
+ },
+ '&.Mui-disabled': {
+ cursor: 'not-allowed !important',
+ },
+}));
+
+export default {
+ Checkbox,
+ Label,
+};
diff --git a/src/2.7.12/imports/ui/components/common/control-header/component.jsx b/src/2.7.12/imports/ui/components/common/control-header/component.jsx
new file mode 100644
index 00000000..2b3bcf2d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/control-header/component.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import Left from './left/component';
+import Right from './right/component';
+
+const Header = ({
+ leftButtonProps,
+ rightButtonProps,
+ customRightButton,
+ 'data-test': dataTest,
+ ...rest
+}) => {
+ const renderCloseButton = () => (
+
+ );
+
+ const renderCustomRightButton = () => (
+
+ {customRightButton}
+
+ );
+
+ return (
+
+ {leftButtonProps ? :
}
+ {customRightButton
+ ? renderCustomRightButton()
+ : rightButtonProps
+ ? renderCloseButton()
+ : null}
+
+ );
+}
+
+Header.propTypes = {
+ leftButtonProps: PropTypes.object,
+ rightButtonProps: PropTypes.object,
+ customRightButton: PropTypes.element,
+ dataTest: PropTypes.string,
+};
+
+export default Header;
diff --git a/src/2.7.12/imports/ui/components/common/control-header/left/component.jsx b/src/2.7.12/imports/ui/components/common/control-header/left/component.jsx
new file mode 100644
index 00000000..818ac655
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/control-header/left/component.jsx
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+class Left extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+Left.propTypes = {
+ accessKey: PropTypes.any,
+ 'aria-label': PropTypes.string,
+ 'data-test': PropTypes.string,
+ label: PropTypes.string,
+ onClick: PropTypes.func,
+};
+
+export default Left;
diff --git a/src/2.7.12/imports/ui/components/common/control-header/left/styles.js b/src/2.7.12/imports/ui/components/common/control-header/left/styles.js
new file mode 100644
index 00000000..9b227bc4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/control-header/left/styles.js
@@ -0,0 +1,31 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const HideButton = styled(Button)`
+ padding: 0;
+ margin: 0;
+ line-height: normal;
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ & > i,
+ & > i::before {
+ width: auto;
+ font-size: ${fontSizeBase} !important;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+`;
+
+export default { HideButton };
diff --git a/src/2.7.12/imports/ui/components/common/control-header/right/component.jsx b/src/2.7.12/imports/ui/components/common/control-header/right/component.jsx
new file mode 100644
index 00000000..f0cff6b9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/control-header/right/component.jsx
@@ -0,0 +1,32 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+class Right extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+Right.propTypes = {
+ accessKey: PropTypes.any,
+ 'aria-label': PropTypes.string,
+ 'data-test': PropTypes.string,
+ icon: PropTypes.string,
+ label: PropTypes.string,
+ onClick: PropTypes.func,
+};
+
+export default Right;
diff --git a/src/2.7.12/imports/ui/components/common/control-header/right/styles.js b/src/2.7.12/imports/ui/components/common/control-header/right/styles.js
new file mode 100644
index 00000000..63342cca
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/control-header/right/styles.js
@@ -0,0 +1,18 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const CloseButton = styled(Button)`
+ span:first-of-type {
+ padding: 0;
+ margin: 0;
+
+ & > i,
+ & > i::before {
+ width: auto;
+ font-size: ${fontSizeBase};
+ }
+ }
+`;
+
+export default { CloseButton };
diff --git a/src/2.7.12/imports/ui/components/common/control-header/styles.js b/src/2.7.12/imports/ui/components/common/control-header/styles.js
new file mode 100644
index 00000000..fa65691e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/control-header/styles.js
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+import { jumboPaddingY } from '/imports/ui/stylesheets/styled-components/general';
+
+const Header = styled.header`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: ${jumboPaddingY};
+`;
+
+const RightWrapper = styled.div`
+ & > div {
+ display: flex;
+ }
+`;
+
+export default {
+ Header,
+ RightWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/common/error-boundary/component.jsx b/src/2.7.12/imports/ui/components/common/error-boundary/component.jsx
new file mode 100644
index 00000000..a425935b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/error-boundary/component.jsx
@@ -0,0 +1,43 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import logger from '/imports/startup/client/logger';
+
+const propTypes = {
+ children: PropTypes.element.isRequired,
+ Fallback: PropTypes.func.isRequired,
+};
+
+class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { error: false, errorInfo: null };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ this.setState({
+ error,
+ errorInfo,
+ });
+ logger.error({
+ logCode: 'Error_Boundary_wrapper',
+ extraInfo: { error, errorInfo },
+ }, 'generic error boundary logger');
+ }
+
+ render() {
+ const { error } = this.state;
+ const { children, Fallback } = this.props;
+
+ return (error ? ( ) : children);
+ }
+}
+
+ErrorBoundary.propTypes = propTypes;
+
+export const withErrorBoundary = (WrappedComponent, FallbackComponent) => (props) => (
+
+
+
+);
+
+export default ErrorBoundary;
diff --git a/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-modal/component.jsx b/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-modal/component.jsx
new file mode 100644
index 00000000..e92c8246
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-modal/component.jsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import FallbackView from '../fallback-view/component';
+
+const intlMessages = defineMessages({
+ ariaTitle: {
+ id: 'app.error.fallback.modal.ariaTitle',
+ description: 'title announced when fallback modal is showed',
+ },
+});
+
+const FallbackModal = ({ error, intl }) => {
+ return (
+
+
+
+)};
+
+export default injectIntl(FallbackModal);
diff --git a/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/component.jsx b/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/component.jsx
new file mode 100644
index 00000000..030a8ee8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/component.jsx
@@ -0,0 +1,62 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.error.fallback.presentation.title',
+ description: 'title for presentation when fallback is showed',
+ },
+ description: {
+ id: 'app.error.fallback.presentation.description',
+ description: 'description for presentation when fallback is showed',
+ },
+ reloadButton: {
+ id: 'app.error.fallback.presentation.reloadButton',
+ description: 'Button label when fallback is showed',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ error: PropTypes.shape({
+ message: PropTypes.string.isRequired,
+ }),
+};
+
+const defaultProps = {
+ error: {
+ message: '',
+ },
+};
+
+const FallbackView = ({ error, intl }) => (
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+ {intl.formatMessage(intlMessages.description)}
+
+
+
+ {error.message}
+
+
+ window.location.reload()}
+ label={intl.formatMessage(intlMessages.reloadButton)}
+ />
+
+
+);
+
+FallbackView.propTypes = propTypes;
+FallbackView.defaultProps = defaultProps;
+
+export default injectIntl(FallbackView);
diff --git a/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/styles.js b/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/styles.js
new file mode 100644
index 00000000..4478b333
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fallback-errors/fallback-view/styles.js
@@ -0,0 +1,63 @@
+import styled from 'styled-components';
+import {
+ colorWhite,
+ colorBackground,
+ colorGrayLighter,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import Button from '/imports/ui/components/common/button/component';
+
+const Background = styled.div`
+ display: flex;
+ flex-flow: column;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: ${colorBackground};
+ color: ${colorWhite};
+ text-align: center;
+`;
+
+const CodeError = styled.h1`
+ margin: 0;
+ font-size: 3rem;
+ color: ${colorWhite};
+`;
+
+const Message = styled.h1`
+ margin: 0;
+ color: ${colorWhite};
+ font-size: 1.25rem;
+ font-weight: 400;
+`;
+
+const Separator = styled.div`
+ height: 0;
+ width: 5rem;
+ border: 1px solid ${colorGrayLighter};
+ margin: 1.5rem 0 1.5rem 0;
+ align-self: center;
+ opacity: .75;
+`;
+
+const SessionMessage = styled.div`
+ margin: 0;
+ color: ${colorWhite};
+ font-size: ${fontSizeBase};
+ font-weight: 400;
+ margin-bottom: 1.5rem;
+`;
+
+const ReloadButton = styled(Button)`
+ min-width: 9rem;
+ height: 2rem;
+`;
+
+export default {
+ Background,
+ CodeError,
+ Message,
+ Separator,
+ SessionMessage,
+ ReloadButton,
+};
diff --git a/src/2.7.12/imports/ui/components/common/file-reader/component.jsx b/src/2.7.12/imports/ui/components/common/file-reader/component.jsx
new file mode 100644
index 00000000..f7a48eeb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/file-reader/component.jsx
@@ -0,0 +1,179 @@
+import React, { useRef } from 'react';
+import { defineMessages } from 'react-intl';
+import { toast } from 'react-toastify';
+import { notify } from '/imports/ui/services/notification';
+import Icon from '/imports/ui/components/common/icon/component';
+import Styled from './styles';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const intlMessages = defineMessages({
+ maximumSizeExceeded: {
+ id: 'app.video.virtualBackground.maximumFileSizeExceeded',
+ description: 'Label for the maximum file size exceeded notification',
+ },
+ typeNotAllowed: {
+ id: 'app.video.virtualBackground.typeNotAllowed',
+ description: 'Label for the file type not allowed notification',
+ },
+ errorOnRead: {
+ id: 'app.video.virtualBackground.errorOnRead',
+ description: 'Label for the error on read notification',
+ },
+ uploaded: {
+ id: 'app.video.virtualBackground.uploaded',
+ description: 'Label for when the file is uploaded',
+ },
+ uploading: {
+ id: 'app.video.virtualBackground.uploading',
+ description: 'Label for when the file is uploading',
+ },
+});
+
+const STATUS = {
+ LOADING: 'loading',
+ DONE: 'done',
+ ERROR: 'error',
+};
+
+/**
+ * HOC for injecting a file reader utility.
+ * @param {React.Component} Component
+ * @param {string[]} mimeTypesAllowed String array containing MIME types allowed.
+ * @param {number} maxFileSize Max file size allowed in Mbytes.
+ * @returns A new component which accepts the same props as the wrapped component plus
+ * a function called readFile.
+ */
+const withFileReader = (
+ Component,
+ mimeTypesAllowed,
+ maxFileSize,
+) => (props) => {
+ const { intl } = props;
+ const toastId = useRef(null);
+
+ const parseFilename = (filename = '') => {
+ const substrings = filename.split('.');
+ substrings.pop();
+ const filenameWithoutExtension = substrings.join('');
+ return filenameWithoutExtension;
+ };
+
+ const renderToastContent = (text, status) => {
+ let icon;
+ let statusMessage;
+
+ switch (status) {
+ case STATUS.LOADING:
+ icon = 'blank';
+ statusMessage = intl.formatMessage(intlMessages.uploading);
+ break;
+ case STATUS.DONE:
+ icon = 'check';
+ statusMessage = intl.formatMessage(intlMessages.uploaded);
+ break;
+ case STATUS.ERROR:
+ default:
+ icon = 'circle_close'
+ statusMessage = intl.formatMessage(intlMessages.errorOnRead);
+ }
+
+ return (
+
+
+
+ {text}
+
+
+
+
+
+
+ {statusMessage}
+
+
+
+ );
+ };
+
+ const renderToast = (text = '', status = STATUS.DONE, callback) => {
+ if (toastId.current) {
+ toast.dismiss(toastId.current);
+ }
+
+ toastId.current = toast.info(renderToastContent(text, status), {
+ hideProgressBar: status === STATUS.DONE ? false : true,
+ autoClose: status === STATUS.DONE ? 5000 : false,
+ newestOnTop: true,
+ closeOnClick: true,
+ onClose: () => {
+ toastId.current = null;
+ },
+ onOpen: () => {
+ if (typeof callback === 'function') callback();
+ },
+ });
+ };
+
+ const readFile = (
+ file,
+ onSuccess = () => {},
+ onError = () => {},
+ ) => {
+ if (!file) return;
+
+ const { name, size, type } = file;
+ const sizeInKB = size / 1024;
+
+ if (sizeInKB > maxFileSize) {
+ notify(
+ intl.formatMessage(
+ intlMessages.maximumSizeExceeded,
+ { 0: (maxFileSize / 1000).toFixed(0) },
+ ),
+ 'error',
+ );
+ return onError(new Error('Maximum file size exceeded.'));
+ }
+
+ if (!mimeTypesAllowed.includes(type)) {
+ notify(
+ intl.formatMessage(intlMessages.typeNotAllowed),
+ 'error',
+ );
+ return onError(new Error('File type not allowed.'));
+ }
+
+ const filenameWithoutExtension = parseFilename(name);
+ const reader = new FileReader();
+
+ reader.onload = (e) => {
+ const data = {
+ filename: filenameWithoutExtension,
+ data: e.target.result,
+ uniqueId: uniqueId(),
+ };
+ renderToast(name, STATUS.DONE, () => { onSuccess(data); });
+ };
+
+ reader.onerror = () => {
+ renderToast(name, STATUS.ERROR, () => {
+ onError(new Error('Something went wrong when reading the file.'));
+ });
+ };
+
+ reader.onloadstart = () => {
+ renderToast(name, STATUS.LOADING);
+ };
+
+ reader.readAsDataURL(file);
+ };
+
+ return ;
+}
+
+export default withFileReader;
diff --git a/src/2.7.12/imports/ui/components/common/file-reader/styles.js b/src/2.7.12/imports/ui/components/common/file-reader/styles.js
new file mode 100644
index 00000000..6ddf3475
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/file-reader/styles.js
@@ -0,0 +1,93 @@
+import styled, { css, keyframes } from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import {
+ colorDanger,
+ colorGray,
+ colorGrayLightest,
+ colorSuccess,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fileLineWidth,
+ iconPaddingMd,
+ mdPaddingY,
+ statusIconSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ headingsFontWeight,
+} from '/imports/ui/stylesheets/styled-components/typography';
+
+const rotate = keyframes`
+ 0% { transform: rotate(0); }
+ 100% { transform: rotate(360deg); }
+`;
+
+const ToastIcon = styled(Icon)`
+ font-size: 117%;
+ width: ${statusIconSize};
+ height: ${statusIconSize};
+ position: relative;
+ left: 8px;
+
+ [dir="rtl"] & {
+ left: unset;
+ right: 8px;
+ }
+
+ ${({ done }) => done && `
+ color: ${colorSuccess};
+ `}
+
+ ${({ error }) => error && `
+ color: ${colorDanger};
+ `}
+
+ ${({ loading }) => loading && css`
+ color: ${colorGrayLightest};
+ border: 1px solid;
+ border-radius: 50%;
+ border-right-color: ${colorGray};
+ animation: ${rotate} 1s linear infinite;
+ `}
+`;
+
+const FileLine = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ width: ${fileLineWidth};
+ padding-bottom: ${iconPaddingMd};
+`;
+
+const ToastFileName = styled.span`
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ margin-left: ${mdPaddingY};
+ width: auto;
+ text-align: left;
+ font-weight: ${headingsFontWeight};
+
+ [dir="rtl"] & {
+ margin-right: ${mdPaddingY};
+ margin-left: 0;
+ text-align: right;
+ }
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+`;
+
+const Status = styled.span`
+ font-size: 70%;
+`;
+
+export default {
+ ToastIcon,
+ FileLine,
+ ToastFileName,
+ Content,
+ Status,
+};
diff --git a/src/2.7.12/imports/ui/components/common/fullscreen-button/component.jsx b/src/2.7.12/imports/ui/components/common/fullscreen-button/component.jsx
new file mode 100644
index 00000000..22c8af77
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fullscreen-button/component.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+
+const intlMessages = defineMessages({
+ fullscreenButton: {
+ id: 'app.fullscreenButton.label',
+ description: 'Fullscreen label',
+ },
+ fullscreenUndoButton: {
+ id: 'app.fullscreenUndoButton.label',
+ description: 'Undo fullscreen label',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ fullscreenRef: PropTypes.instanceOf(Element),
+ dark: PropTypes.bool,
+ bottom: PropTypes.bool,
+ isIphone: PropTypes.bool,
+ isFullscreen: PropTypes.bool,
+ elementName: PropTypes.string,
+ handleToggleFullScreen: PropTypes.func.isRequired,
+ color: PropTypes.string,
+ fullScreenStyle: PropTypes.bool,
+};
+
+const defaultProps = {
+ dark: false,
+ bottom: false,
+ isIphone: false,
+ isFullscreen: false,
+ elementName: '',
+ color: 'default',
+ fullScreenStyle: true,
+ fullscreenRef: null,
+};
+
+const FullscreenButtonComponent = ({
+ intl,
+ dark,
+ bottom,
+ elementName,
+ elementId,
+ elementGroup,
+ isIphone,
+ isFullscreen,
+ layoutContextDispatch,
+ currentElement,
+ currentGroup,
+ color,
+ fullScreenStyle,
+ fullscreenRef,
+ handleToggleFullScreen,
+}) => {
+ if (isIphone) return null;
+
+ const formattedLabel = (fullscreen) => (fullscreen
+ ? intl.formatMessage(
+ intlMessages.fullscreenUndoButton,
+ ({ 0: elementName || '' }),
+ )
+ : intl.formatMessage(
+ intlMessages.fullscreenButton,
+ ({ 0: elementName || '' }),
+ )
+ );
+
+ const handleClick = () => {
+ handleToggleFullScreen(fullscreenRef);
+ const newElement = (elementId === currentElement) ? '' : elementId;
+ const newGroup = (elementGroup === currentGroup) ? '' : elementGroup;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_FULLSCREEN_ELEMENT,
+ value: {
+ element: newElement,
+ group: newGroup,
+ },
+ });
+ };
+
+ return (
+
+ handleClick()}
+ label={formattedLabel(isFullscreen)}
+ hideLabel
+ isStyled={fullScreenStyle}
+ data-test="webcamFullscreenButton"
+ />
+
+ );
+};
+
+FullscreenButtonComponent.propTypes = propTypes;
+FullscreenButtonComponent.defaultProps = defaultProps;
+
+export default injectIntl(FullscreenButtonComponent);
diff --git a/src/2.7.12/imports/ui/components/common/fullscreen-button/container.jsx b/src/2.7.12/imports/ui/components/common/fullscreen-button/container.jsx
new file mode 100644
index 00000000..f4f7d620
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fullscreen-button/container.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import FullscreenButtonComponent from './component';
+import { layoutSelect, layoutDispatch } from '/imports/ui/components/layout/context';
+import FullscreenService from './service';
+
+const FullscreenButtonContainer = (props) => ;
+
+export default (props) => {
+ const handleToggleFullScreen = (ref) => FullscreenService.toggleFullScreen(ref);
+ const { isFullscreen } = props;
+
+ const isIphone = !!(navigator.userAgent.match(/iPhone/i));
+
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const { element: currentElement, group: currentGroup } = fullscreen;
+ const layoutContextDispatch = layoutDispatch();
+
+ return (
+
+ );
+};
diff --git a/src/2.7.12/imports/ui/components/common/fullscreen-button/service.js b/src/2.7.12/imports/ui/components/common/fullscreen-button/service.js
new file mode 100644
index 00000000..550df065
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fullscreen-button/service.js
@@ -0,0 +1,56 @@
+function getFullscreenElement() {
+ if (document.fullscreenElement) return document.fullscreenElement;
+ if (document.webkitFullscreenElement) return document.webkitFullscreenElement;
+ if (document.mozFullScreenElement) return document.mozFullScreenElement;
+ if (document.msFullscreenElement) return document.msFullscreenElement;
+ return null;
+}
+
+const isFullScreen = (element) => {
+ if (getFullscreenElement() && getFullscreenElement() === element) {
+ return true;
+ }
+ return false;
+};
+
+function cancelFullScreen() {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ } else if (document.mozCancelFullScreen) {
+ document.mozCancelFullScreen();
+ } else if (document.webkitExitFullscreen) {
+ document.webkitExitFullscreen();
+ }
+}
+
+function fullscreenRequest(element) {
+ if (element.requestFullscreen) {
+ element.requestFullscreen();
+ } else if (element.mozRequestFullScreen) {
+ element.mozRequestFullScreen();
+ } else if (element.webkitRequestFullscreen) {
+ element.webkitRequestFullscreen();
+ } else if (element.msRequestFullscreen) {
+ element.msRequestFullscreen();
+ } else {
+ return;
+ }
+ document.activeElement.blur();
+ element.focus();
+}
+
+const toggleFullScreen = (ref = null) => {
+ const element = ref || document.documentElement;
+
+ if (isFullScreen(element)) {
+ cancelFullScreen();
+ } else {
+ fullscreenRequest(element);
+ }
+};
+
+export default {
+ toggleFullScreen,
+ isFullScreen,
+ getFullscreenElement,
+};
diff --git a/src/2.7.12/imports/ui/components/common/fullscreen-button/styles.js b/src/2.7.12/imports/ui/components/common/fullscreen-button/styles.js
new file mode 100644
index 00000000..2e7300fb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/fullscreen-button/styles.js
@@ -0,0 +1,82 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ colorTransparent,
+ colorWhite,
+ colorBlack,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const FullscreenButtonWrapper = styled.div`
+ position: absolute;
+ right: 0;
+ left: auto;
+ background-color: ${colorTransparent};
+ cursor: pointer;
+ border: 0;
+ z-index: 2;
+ margin: 2px;
+
+ [dir="rtl"] & {
+ right: auto;
+ left :0;
+ }
+
+ [class*="presentationZoomControls"] & {
+ position: relative !important;
+ }
+
+ ${({ theme }) => theme === 'dark' && `
+ background-color: rgba(0,0,0,.3);
+
+ & button i {
+ color: ${colorWhite};
+ }
+ `}
+
+ ${({ theme }) => theme === 'light' && `
+ background-color: ${colorTransparent};
+
+ & button i {
+ color: ${colorBlack};
+ }
+ `}
+
+ ${({ position }) => position === 'bottom' && `
+ bottom: 0;
+ `}
+
+ ${({ position }) => position === 'top' && `
+ top: 0;
+ `}
+`;
+
+const FullscreenButton = styled(Button)`
+ ${({ isStyled }) => isStyled && `
+ &,
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: ${colorTransparent} !important;
+ border: none !important;
+
+ i {
+ border: none !important;
+ background-color: ${colorTransparent} !important;
+ }
+ }
+ padding: 5px;
+
+ &:hover {
+ border: 0;
+ }
+
+ i {
+ font-size: 1rem;
+ }
+ `}
+`;
+
+export default {
+ FullscreenButtonWrapper,
+ FullscreenButton,
+};
diff --git a/src/2.7.12/imports/ui/components/common/icon/component.jsx b/src/2.7.12/imports/ui/components/common/icon/component.jsx
new file mode 100644
index 00000000..8199fb5d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/icon/component.jsx
@@ -0,0 +1,36 @@
+import React, { memo } from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import { omit } from 'radash';
+import Styled from './styles';
+
+const propTypes = {
+ iconName: PropTypes.string.isRequired,
+ prependIconName: PropTypes.string,
+ rotate: PropTypes.bool,
+};
+
+const defaultProps = {
+ prependIconName: 'icon-bbb-',
+ rotate: false,
+};
+
+const Icon = ({
+ className,
+ prependIconName,
+ iconName,
+ rotate,
+ ...props
+}) => (
+
+);
+
+export default memo(Icon);
+
+Icon.propTypes = propTypes;
+Icon.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/common/icon/styles.js b/src/2.7.12/imports/ui/components/common/icon/styles.js
new file mode 100644
index 00000000..6f5e2ca5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/icon/styles.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const Icon = styled.i`
+ ${({ $rotate }) => $rotate && `
+ transform: rotate(180deg);
+ margin-top: 20%;
+ `}
+`;
+
+export default {
+ Icon,
+};
diff --git a/src/2.7.12/imports/ui/components/common/loading-screen/component.jsx b/src/2.7.12/imports/ui/components/common/loading-screen/component.jsx
new file mode 100644
index 00000000..83e827ca
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/loading-screen/component.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Styled from './styles';
+import Settings from '/imports/ui/services/settings';
+
+const { animations } = Settings.application;
+
+const LoadingScreen = ({ children }) => (
+
+
+
+
+
+
+
+ {children}
+
+
+);
+
+export default LoadingScreen;
diff --git a/src/2.7.12/imports/ui/components/common/loading-screen/styles.js b/src/2.7.12/imports/ui/components/common/loading-screen/styles.js
new file mode 100644
index 00000000..b0b40dba
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/loading-screen/styles.js
@@ -0,0 +1,72 @@
+import styled, { css, keyframes } from 'styled-components';
+import { mdPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+import { loaderBg, loaderBullet, colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+
+const Background = styled.div`
+ position: fixed;
+ display: flex;
+ flex-flow: column;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: ${loaderBg};
+ z-index: 4;
+`;
+
+const skBouncedelay = keyframes`
+ 0%,
+ 80%,
+ 100% {
+ transform: scale(0);
+ }
+ 40% {
+ transform: scale(1.0);
+ }
+`;
+
+const Spinner = styled.div`
+ width: 100%;
+ text-align: center;
+ height: 22px;
+ margin-bottom: ${mdPaddingX};
+
+ & > div {
+ width: 18px;
+ height: 18px;
+ margin: 0 5px;
+ background-color: ${loaderBullet};
+ border-radius: 100%;
+ display: inline-block;
+
+ ${({ animations }) => animations && css`
+ animation: ${skBouncedelay} 1.4s infinite ease-in-out both;
+ `}
+ }
+`;
+
+const Bounce1 = styled.div`
+ ${({ animations }) => animations && `
+ animation-delay: -0.32s !important;
+ `}
+`;
+
+const Bounce2 = styled.div`
+ ${({ animations }) => animations && `
+ animation-delay: -0.16s !important;
+ `}
+`;
+
+const Message = styled.div`
+ font-size: ${fontSizeLarge};
+ color: ${colorWhite};
+ text-align: center;
+`;
+
+export default {
+ Background,
+ Spinner,
+ Bounce1,
+ Bounce2,
+ Message,
+};
diff --git a/src/2.7.12/imports/ui/components/common/locales-dropdown/component.jsx b/src/2.7.12/imports/ui/components/common/locales-dropdown/component.jsx
new file mode 100644
index 00000000..46fbca02
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/locales-dropdown/component.jsx
@@ -0,0 +1,86 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+
+const DEFAULT_VALUE = 'select';
+const DEFAULT_KEY = -1;
+
+const propTypes = {
+ allLocales: PropTypes.arrayOf(PropTypes.object).isRequired,
+ value: PropTypes.string,
+ handleChange: PropTypes.func.isRequired,
+ elementId: PropTypes.string.isRequired,
+ selectMessage: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+ value: null,
+};
+
+class LocalesDropdown extends PureComponent {
+ // returns an array with the base language list + variations of currently selected language
+ filterLocaleVariations(value) {
+ const { allLocales } = this.props;
+ if (allLocales) {
+ if (Meteor.settings.public.app.showAllAvailableLocales) {
+ return allLocales;
+ }
+
+ // splits value if not empty
+ const splitValue = (value) ? value.split('-')[0] : '';
+
+ const allLocaleCodes = [];
+ allLocales.map((item) => allLocaleCodes.push(item.locale));
+
+ /*
+ locales show if:
+ 1. it is a general version of a locale with no specific locales
+ 2. it is a specific version of a selected locale with many specific versions
+ 3. it is a specific version of a locale with no general locale
+ */
+ return allLocales.filter(
+ (locale) => (!locale.locale.includes('-') || locale.locale.split('-')[0] === splitValue || !allLocaleCodes.includes(locale.locale.split('-')[0])),
+ );
+ }
+ return [];
+ }
+
+ render() {
+ const {
+ value, handleChange, elementId, selectMessage, ariaLabel, intl,
+ } = this.props;
+ const defaultLocale = value || DEFAULT_VALUE;
+
+ const availableLocales = this.filterLocaleVariations(value);
+
+ return (
+
+
+ {selectMessage}
+
+ {availableLocales.map((localeItem) => {
+ const localizedName = localeItem.locale !== value && intl.formatMessage({
+ id: `app.submenu.application.localeDropdown.${localeItem.locale}`,
+ defaultMessage: ``,
+ });
+
+ return (
+
+ {localeItem.name}{localizedName && ` - ${localizedName}`}
+
+ );
+ })}
+
+ );
+ }
+}
+
+LocalesDropdown.propTypes = propTypes;
+LocalesDropdown.defaultProps = defaultProps;
+
+export default injectIntl(LocalesDropdown);
diff --git a/src/2.7.12/imports/ui/components/common/menu/component.jsx b/src/2.7.12/imports/ui/components/common/menu/component.jsx
new file mode 100644
index 00000000..e678b499
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/menu/component.jsx
@@ -0,0 +1,269 @@
+import React from "react";
+import PropTypes from "prop-types";
+import { defineMessages, injectIntl } from "react-intl";
+import { Divider } from "@mui/material";
+import Icon from "/imports/ui/components/common/icon/component";
+import { SMALL_VIEWPORT_BREAKPOINT } from '/imports/ui/components/layout/enums';
+import KEY_CODES from '/imports/utils/keyCodes';
+
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ close: {
+ id: 'app.dropdown.close',
+ description: 'Close button label',
+ },
+ active: {
+ id: 'app.dropdown.list.item.activeLabel',
+ description: 'active item label',
+ },
+});
+
+class BBBMenu extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ anchorEl: null,
+ };
+
+ this.optsToMerge = {};
+ this.autoFocus = false;
+
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.handleClick = this.handleClick.bind(this);
+ this.handleClose = this.handleClose.bind(this);
+ }
+
+ componentDidUpdate() {
+ const { anchorEl } = this.state;
+ const { open } = this.props;
+ if (open === false && anchorEl) {
+ this.setState({ anchorEl: null });
+ } else if (open === true && !anchorEl) {
+ this.setState({ anchorEl: this.anchorElRef });
+ }
+ }
+
+ handleKeyDown(event) {
+ const { anchorEl } = this.state;
+ const { isHorizontal } = this.props;
+ const isMenuOpen = Boolean(anchorEl);
+
+ const previousKey = isHorizontal ? KEY_CODES.ARROW_LEFT : KEY_CODES.ARROW_UP;
+ const nextKey = isHorizontal ? KEY_CODES.ARROW_RIGHT : KEY_CODES.ARROW_DOWN;
+
+ if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.which)) {
+ this.handleClose();
+ return;
+ }
+
+ if (isMenuOpen && [previousKey, nextKey].includes(event.which)) {
+ event.preventDefault();
+ event.stopPropagation();
+ const menuItems = Array.from(document.querySelectorAll('[data-key^="menuItem-"]'));
+ if (menuItems.length === 0) return;
+
+ const focusedIndex = menuItems.findIndex(item => item === document.activeElement);
+ const nextIndex = event.which === previousKey ? focusedIndex - 1 : focusedIndex + 1;
+ let indexToFocus = 0;
+ if (nextIndex < 0) {
+ indexToFocus = menuItems.length - 1;
+ } else if (nextIndex >= menuItems.length) {
+ indexToFocus = 0;
+ } else {
+ indexToFocus = nextIndex;
+ }
+
+ menuItems[indexToFocus].focus();
+ }
+ };
+
+ handleClick(event) {
+ this.setState({ anchorEl: event.currentTarget });
+ };
+
+ handleClose(event) {
+ const { onCloseCallback } = this.props;
+ this.setState({ anchorEl: null }, onCloseCallback());
+
+ if (event) {
+ event.persist();
+
+ if (event.type === 'click') {
+ setTimeout(() => {
+ document.activeElement.blur();
+ }, 0);
+ }
+ }
+ };
+
+ makeMenuItems() {
+ const { actions, selectedEmoji, intl, isHorizontal, isMobile, roundButtons, keepOpen } = this.props;
+
+ return actions?.map(a => {
+ const { dataTest, label, onClick, key, disabled, description, selected } = a;
+ const emojiSelected = key?.toLowerCase()?.includes(selectedEmoji?.toLowerCase());
+
+ let customStyles = {
+ paddingLeft: '16px',
+ paddingRight: '16px',
+ paddingTop: '12px',
+ paddingBottom: '12px',
+ marginLeft: '0px',
+ marginRight: '0px',
+ };
+
+ if (a.customStyles) {
+ customStyles = { ...customStyles, ...a.customStyles };
+ }
+
+ return [
+ a.dividerTop && ,
+ {
+ onClick();
+ const close = !keepOpen && !key?.includes('setstatus') && !key?.includes('back');
+ // prevent menu close for sub menu actions
+ if (close) this.handleClose(event);
+ event.stopPropagation();
+ }}>
+
+ {a.icon ? : null}
+ {label}
+ {description && {`${description}${selected ? ` - ${intl.formatMessage(intlMessages.active)}` : ''}`}
}
+ {a.iconRight ? : null}
+
+ ,
+ a.divider &&
+ ];
+ }) ?? [];
+ }
+
+ render() {
+ const { anchorEl } = this.state;
+ const {
+ trigger,
+ intl,
+ customStyles,
+ dataTest,
+ opts,
+ accessKey,
+ open,
+ renderOtherComponents,
+ customAnchorEl,
+ hasRoundedCorners,
+ overrideMobileStyles,
+ isHorizontal,
+ } = this.props;
+ const actionsItems = this.makeMenuItems();
+
+ const roundedCornersStyles = { borderRadius: '1.8rem' };
+ let menuStyles = { zIndex: 999 };
+
+ if (customStyles) {
+ menuStyles = { ...menuStyles, ...customStyles };
+ }
+
+ if (isHorizontal) {
+ const horizontalStyles = { display: 'flex' };
+ menuStyles = { ...menuStyles, ...horizontalStyles};
+ }
+
+ return (
+ <>
+ {
+ e.persist();
+ const firefoxInputSource = !([1, 5].includes(e.nativeEvent.mozInputSource)); // 1 = mouse, 5 = touch (firefox only)
+ const chromeInputSource = !(['mouse', 'touch'].includes(e.nativeEvent.pointerType));
+
+ this.optsToMerge.autoFocus = firefoxInputSource && chromeInputSource;
+ this.handleClick(e);
+ }}
+ onKeyPress={(e) => {
+ e.persist();
+ if (e.which !== KEY_CODES.ENTER) return null;
+ this.handleClick(e);
+ }}
+ accessKey={accessKey}
+ ref={(ref) => this.anchorElRef = ref}
+ role="button"
+ tabIndex={-1}
+ >
+ {trigger}
+
+
+
+ {actionsItems}
+ {renderOtherComponents}
+ {!overrideMobileStyles && anchorEl && window.innerWidth < SMALL_VIEWPORT_BREAKPOINT &&
+
+ }
+
+ >
+ );
+ }
+}
+
+BBBMenu.defaultProps = {
+ opts: {
+ id: "default-dropdown-menu",
+ autoFocus: false,
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: "true",
+ anchorOrigin: { vertical: 'top', horizontal: 'right' },
+ transformorigin: { vertical: 'top', horizontal: 'right' },
+ },
+ onCloseCallback: () => { },
+ dataTest: '',
+};
+
+BBBMenu.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+
+ trigger: PropTypes.element.isRequired,
+
+ actions: PropTypes.array.isRequired,
+
+ onCloseCallback: PropTypes.func,
+ dataTest: PropTypes.string,
+ open: PropTypes.bool,
+ customStyles: PropTypes.object,
+ opts: PropTypes.object,
+ accessKey: PropTypes.string,
+};
+
+export default injectIntl(BBBMenu);
diff --git a/src/2.7.12/imports/ui/components/common/menu/styles.js b/src/2.7.12/imports/ui/components/common/menu/styles.js
new file mode 100644
index 00000000..6f586b33
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/menu/styles.js
@@ -0,0 +1,129 @@
+import styled from 'styled-components';
+import Button from "/imports/ui/components/common/button/component";
+import Icon from '/imports/ui/components/common/icon/component';
+import MenuItem from "@mui/material/MenuItem";
+import { colorWhite, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+import { mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Menu from "@mui/material/Menu";
+
+const MenuWrapper = styled(Menu)`
+ ${({ isMobile }) => isMobile && `
+ flex-direction: column;
+ align-items: center;
+ padding: .5rem 0;
+ `}
+
+ ${({ $isHorizontal, isMobile }) => ($isHorizontal || isMobile) && `
+ ul {
+ display: flex;
+ }
+
+ li:hover {
+ background-color: unset !important;
+ }
+
+ `}
+`;
+
+const MenuItemWrapper = styled.div`
+ display: flex;
+ flex-flow: row;
+ width: 100%;
+ align-items: center;
+`;
+
+const Option = styled.div`
+ line-height: 1;
+ margin-right: 1.65rem;
+ margin-left: .5rem;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ padding: .1rem 0;
+
+ [dir="rtl"] & {
+ margin-right: .5rem;
+ margin-left: 1.65rem;
+ }
+
+ ${({ isHorizontal, isMobile }) => (isHorizontal || isMobile) && `
+ margin-right: 0;
+ margin-left: 0;
+ `}
+`;
+
+const CloseButton = styled(Button)`
+ position: fixed;
+ bottom: 0;
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ height: 5rem;
+ background-color: ${colorWhite};
+ padding: 1rem;
+
+ border-radius: 0;
+ z-index: 1011;
+ font-size: calc(${fontSizeLarge} * 1.1);
+ box-shadow: 0 0 0 2rem ${colorWhite} !important;
+ border: ${colorWhite} !important;
+ cursor: pointer !important;
+
+ @media ${mediumUp} {
+ display: none;
+ }
+`;
+
+const IconRight = styled(Icon)`
+ display: flex;
+ justify-content: flex-end;
+ flex: 1;
+`;
+
+const BBBMenuItem = styled(MenuItem)`
+ transition: none !important;
+ font-size: 90% !important;
+
+ &:focus,
+ &:hover {
+ i {
+ color: #FFF;
+ }
+ color: #FFF !important;
+ background-color: ${colorPrimary} !important;
+ }
+
+ ${({ emoji }) => emoji === 'yes' && `
+ div,
+ i {
+ color: ${colorPrimary};
+ }
+
+ &:focus,
+ &:hover {
+ div,
+ i {
+ color: #FFF ;
+ }
+ }
+ `}
+ ${({ $roundButtons }) => $roundButtons && `
+ &:focus,
+ &:hover {
+ background-color: ${colorWhite} !important;
+ div div div {
+ background-color: ${colorPrimary} !important;
+ border: 1px solid ${colorPrimary} !important;
+ }
+ }
+ `}
+`;
+
+export default {
+ MenuWrapper,
+ MenuItemWrapper,
+ Option,
+ CloseButton,
+ IconRight,
+ BBBMenuItem,
+};
diff --git a/src/2.7.12/imports/ui/components/common/modal/base/component.jsx b/src/2.7.12/imports/ui/components/common/modal/base/component.jsx
new file mode 100644
index 00000000..1ff67802
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/base/component.jsx
@@ -0,0 +1,55 @@
+import React, { useCallback, useEffect } from 'react';
+import Styled from './styles';
+
+const BaseModal = (props) => {
+ const {
+ setIsOpen,
+ modalName,
+ children,
+ isOpen,
+ onRequestClose,
+ className,
+ overlayClassName,
+ dataTest,
+ priority,
+ } = props;
+
+ const closeEventHandler = useCallback(() => {
+ setIsOpen(false);
+ }, []);
+ useEffect(() => {
+ // Only add event listener if name is specified
+ if (!modalName) return () => null;
+
+ const closeEventName = `CLOSE_MODAL_${modalName.toUpperCase()}`;
+
+ // Listen to close event on mount
+ document.addEventListener(closeEventName, closeEventHandler);
+
+ // Remove listener on unmount
+ return () => {
+ document.removeEventListener(closeEventName, closeEventHandler);
+ };
+ }, []);
+ const priorityValue = priority || 'low';
+
+ return (
+ document.querySelector('#modals-container')}
+ isOpen={isOpen}
+ onRequestClose={onRequestClose}
+ className={className}
+ overlayClassName={overlayClassName}
+ shouldReturnFocusAfterClose={false}
+ data={{
+ test: dataTest,
+ }}
+ {...props}
+ >
+ {children}
+
+ );
+};
+
+export default { BaseModal };
diff --git a/src/2.7.12/imports/ui/components/common/modal/base/styles.js b/src/2.7.12/imports/ui/components/common/modal/base/styles.js
new file mode 100644
index 00000000..9cd1c837
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/base/styles.js
@@ -0,0 +1,25 @@
+import styled from 'styled-components';
+import { ModalScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+import { borderRadius } from '/imports/ui/stylesheets/styled-components/general';
+import { smallOnly, mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+
+const BaseModal = styled(ModalScrollboxVertical)`
+ max-width: 60vw;
+ max-height: 100%;
+ border-radius: ${borderRadius};
+ background: ${colorWhite};
+ overflow: auto;
+
+ @media ${smallOnly} {
+ max-width: 95vw;
+ }
+
+ @media ${mediumUp} {
+ max-width: 80vw;
+ }
+`;
+
+export default {
+ BaseModal,
+};
diff --git a/src/2.7.12/imports/ui/components/common/modal/confirmation/component.jsx b/src/2.7.12/imports/ui/components/common/modal/confirmation/component.jsx
new file mode 100644
index 00000000..ae6eb3ec
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/confirmation/component.jsx
@@ -0,0 +1,119 @@
+import React, { Component } from 'react';
+import { defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const messages = defineMessages({
+ yesLabel: {
+ id: 'app.confirmationModal.yesLabel',
+ description: 'confirm button label',
+ },
+ noLabel: {
+ id: 'app.endMeeting.noLabel',
+ description: 'cancel confirm button label',
+ },
+});
+
+const propTypes = {
+ confirmButtonColor: PropTypes.string,
+ disableConfirmButton: PropTypes.bool,
+ description: PropTypes.string,
+};
+
+const defaultProps = {
+ confirmButtonColor: 'primary',
+ disableConfirmButton: false,
+ description: '',
+};
+
+class ConfirmationModal extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ checked: false,
+ };
+ }
+
+ render() {
+ const {
+ intl,
+ setIsOpen,
+ onConfirm,
+ title,
+ titleMessageId,
+ titleMessageExtra,
+ checkboxMessageId,
+ confirmButtonColor,
+ confirmButtonLabel,
+ confirmButtonDataTest,
+ confirmParam,
+ disableConfirmButton,
+ description,
+ isOpen,
+ onRequestClose,
+ priority,
+ } = this.props;
+
+ const {
+ checked,
+ } = this.state;
+
+ const hasCheckbox = !!checkboxMessageId;
+
+ return (
+ setIsOpen(false)}
+ contentLabel={title}
+ title={title || intl.formatMessage({ id: titleMessageId }, { 0: titleMessageExtra })}
+ {...{
+ isOpen,
+ onRequestClose,
+ priority,
+ }}
+ >
+
+
+
+ {description}
+
+ { hasCheckbox ? (
+
+ this.setState({ checked: !checked })}
+ checked={checked}
+ aria-label={intl.formatMessage({ id: checkboxMessageId })}
+ />
+ {intl.formatMessage({ id: checkboxMessageId })}
+
+ ) : null }
+
+
+
+ {
+ onConfirm(confirmParam, checked);
+ setIsOpen(false);
+ }}
+ />
+ setIsOpen(false)}
+ />
+
+
+
+ );
+ }
+}
+
+ConfirmationModal.propTypes = propTypes;
+ConfirmationModal.defaultProps = defaultProps;
+
+export default ConfirmationModal;
diff --git a/src/2.7.12/imports/ui/components/common/modal/confirmation/styles.js b/src/2.7.12/imports/ui/components/common/modal/confirmation/styles.js
new file mode 100644
index 00000000..58c8fdcd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/confirmation/styles.js
@@ -0,0 +1,81 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import {
+ smPaddingX,
+ mdPaddingX,
+ lgPaddingY,
+ jumboPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+import { lineHeightBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const ConfirmationModal = styled(ModalSimple)`
+ padding: ${mdPaddingX};
+`;
+
+const Container = styled.div`
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ padding: 0;
+ margin-top: 0;
+ margin: auto;
+`;
+
+const Description = styled.div`
+ text-align: center;
+ line-height: ${lineHeightBase};
+ color: ${colorGray};
+ margin-bottom: ${jumboPaddingY};
+`;
+
+const DescriptionText = styled.span`
+ white-space: pre-line;
+`;
+
+const Checkbox = styled.input`
+ position: relative;
+ top: 0.134rem;
+ margin-right: 0.5rem;
+
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: 0.5rem;
+ }
+`;
+
+const Footer = styled.div`
+ display:flex;
+ margin-bottom: ${lgPaddingY};
+`;
+
+const ConfirmationButton = styled(Button)`
+ padding-right: ${jumboPaddingY};
+ padding-left: ${jumboPaddingY};
+ margin: 0 ${smPaddingX} 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+`;
+
+const CancelButton = styled(ConfirmationButton)`
+ margin: 0;
+`;
+
+const Label = styled.label`
+ display: block;
+`;
+
+export default {
+ ConfirmationModal,
+ Container,
+ Description,
+ DescriptionText,
+ Checkbox,
+ Footer,
+ ConfirmationButton,
+ CancelButton,
+ Label,
+};
diff --git a/src/2.7.12/imports/ui/components/common/modal/fullscreen/component.jsx b/src/2.7.12/imports/ui/components/common/modal/fullscreen/component.jsx
new file mode 100644
index 00000000..e0da8fdf
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/fullscreen/component.jsx
@@ -0,0 +1,149 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ modalClose: {
+ id: 'app.modal.close',
+ description: 'Close',
+ },
+ modalCloseDescription: {
+ id: 'app.modal.close.description',
+ description: 'Disregards changes and closes the modal',
+ },
+ modalDone: {
+ id: 'app.modal.confirm',
+ description: 'Close',
+ },
+ modalDoneDescription: {
+ id: 'app.modal.confirm.description',
+ description: 'Disregards changes and closes the modal',
+ },
+ newTabLabel: {
+ id: 'app.modal.newTab',
+ description: 'aria label used to indicate opening a new window',
+ },
+});
+
+const propTypes = {
+ title: PropTypes.string.isRequired,
+ confirm: PropTypes.shape({
+ callback: PropTypes.func.isRequired,
+ disabled: PropTypes.bool,
+ }),
+ dismiss: PropTypes.shape({
+ callback: PropTypes.func,
+ disabled: PropTypes.bool,
+ }),
+ preventClosing: PropTypes.bool,
+ shouldCloseOnOverlayClick: PropTypes.bool,
+};
+
+const defaultProps = {
+ shouldCloseOnOverlayClick: false,
+ confirm: {
+ disabled: false,
+ },
+ dismiss: {
+ callback: () => {},
+ disabled: false,
+ },
+ preventClosing: false,
+};
+
+class ModalFullscreen extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.handleAction = this.handleAction.bind(this);
+ }
+
+ handleAction(name) {
+ const { confirm, dismiss } = this.props;
+ const { callback: callBackConfirm } = confirm;
+ const { callback: callBackDismiss } = dismiss;
+
+ let callback;
+
+ switch (name) {
+ case 'confirm':
+ callback = callBackConfirm;
+ break;
+ case 'dismiss':
+ callback = callBackDismiss;
+ break;
+ default:
+ break;
+ }
+
+ return callback();
+ }
+
+ render() {
+ const {
+ intl,
+ title,
+ confirm,
+ dismiss,
+ className,
+ children,
+ isOpen,
+ preventClosing,
+ ...otherProps
+ } = this.props;
+
+ const popoutIcon = confirm.icon === 'popout_window';
+ let confirmAriaLabel = `${confirm.label || intl.formatMessage(intlMessages.modalDone)} `;
+ if (popoutIcon) {
+ confirmAriaLabel = `${confirmAriaLabel} ${intl.formatMessage(intlMessages.newTabLabel)}`;
+ }
+
+ return (
+
+
+ {title}
+
+ this.handleAction('dismiss')}
+ aria-describedby="modalDismissDescription"
+ color="secondary"
+ />
+ this.handleAction('confirm')}
+ aria-describedby="modalConfirmDescription"
+ icon={confirm.icon || null}
+ iconRight={popoutIcon}
+ popout={popoutIcon ? 'popout' : 'simple'}
+ />
+
+
+
+ {children}
+
+ {intl.formatMessage(intlMessages.modalCloseDescription)}
+ {intl.formatMessage(intlMessages.modalDoneDescription)}
+
+ );
+ }
+}
+
+ModalFullscreen.propTypes = propTypes;
+ModalFullscreen.defaultProps = defaultProps;
+
+export default injectIntl(ModalFullscreen);
diff --git a/src/2.7.12/imports/ui/components/common/modal/fullscreen/styles.js b/src/2.7.12/imports/ui/components/common/modal/fullscreen/styles.js
new file mode 100644
index 00000000..66467ccc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/fullscreen/styles.js
@@ -0,0 +1,93 @@
+import styled from 'styled-components';
+import Styled from '../base/component';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ borderSize,
+ smPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ lineHeightComputed,
+ headingsFontWeight,
+ fontSizeLarger,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorGrayLightest,
+ colorText,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const FullscreenModal = styled(Styled.BaseModal)`
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ align-self: flex-start;
+ padding: calc(${lineHeightComputed} / 2) ${lineHeightComputed};
+
+ @media ${smallOnly} {
+ width: 100%;
+ }
+`;
+
+const Header = styled.header`
+ display: flex;
+ padding: ${lineHeightComputed} 0;
+ border-bottom: ${borderSize} solid ${colorGrayLightest};
+`;
+
+const Title = styled.h1`
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ flex: 1;
+ margin: 0;
+ font-size: ${fontSizeLarger};
+ font-weight: ${headingsFontWeight};
+`;
+
+const Actions = styled.div`
+ flex: 0 1 35%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+`;
+
+const Content = styled.div`
+ color: ${colorText};
+ font-weight: normal;
+ padding: ${lineHeightComputed} 0;
+`;
+
+const DismissButton = styled(Button)`
+ flex: 0 1 48%;
+`;
+
+const ConfirmButton = styled(Button)`
+ flex: 0 1 48%;
+
+ ${({ popout }) => popout === 'popout' && `
+ & > i {
+ bottom: ${borderSize};
+ left: ${smPaddingX};
+
+ [dir="rtl"] & {
+ left: -0.5rem;
+ transform: rotateY(180deg);
+ }
+ }
+ `}
+`;
+
+export default {
+ FullscreenModal,
+ Header,
+ Title,
+ Actions,
+ Content,
+ DismissButton,
+ ConfirmButton,
+};
diff --git a/src/2.7.12/imports/ui/components/common/modal/header/component.jsx b/src/2.7.12/imports/ui/components/common/modal/header/component.jsx
new file mode 100644
index 00000000..111d6a35
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/header/component.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import Styled from './styles';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ hideBorder: PropTypes.bool,
+ headerPosition: PropTypes.string,
+ shouldShowCloseButton: PropTypes.bool,
+ modalDismissDescription: PropTypes.string,
+ closeButtonProps: PropTypes.shape({
+ label: PropTypes.string,
+ 'aria-label': PropTypes.string,
+ onClick: PropTypes.func,
+ }),
+};
+
+const defaultProps = {
+ hideBorder: true,
+ headerPosition: 'inner',
+ shouldShowCloseButton: true,
+ modalDismissDescription: '',
+ closeButtonProps: {},
+};
+
+class Header extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ const {
+ children,
+ closeButtonProps,
+ headerPosition,
+ hideBorder,
+ modalDismissDescription,
+ shouldShowCloseButton,
+ ...other
+ } = this.props;
+
+ if (!shouldShowCloseButton && !children) return null;
+
+ const headerOnTop = headerPosition === 'top';
+ const innerHeader = headerPosition === 'inner';
+
+ return (
+
+
+ {children}
+
+ {shouldShowCloseButton ? (
+
+ ) : null}
+ {modalDismissDescription}
+
+ );
+ }
+};
+
+Header.propTypes = propTypes;
+Header.defaultProps = defaultProps;
+
+export default Header;
diff --git a/src/2.7.12/imports/ui/components/common/modal/header/styles.js b/src/2.7.12/imports/ui/components/common/modal/header/styles.js
new file mode 100644
index 00000000..927b667d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/header/styles.js
@@ -0,0 +1,94 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { TitleElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import {
+ colorGrayDark,
+ colorGrayLighter,
+ colorText,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ mdPaddingX,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ fontSizeBase,
+ fontSizeLarge,
+ headingsFontWeight,
+ lineHeightComputed,
+} from '/imports/ui/stylesheets/styled-components/typography';
+
+const Header = styled.header`
+ margin: 0;
+ padding: 0;
+ border: none;
+ display: grid;
+
+ ${({ $headerOnTop }) => $headerOnTop && `
+ grid-template-columns: auto min-content;
+ grid-template-rows: min-content;
+ `}
+
+ ${({ $innerHeader }) => $innerHeader && `
+ grid-template-columns: auto;
+ grid-template-rows: min-content min-content;
+ `}
+
+ ${({ $hideBorder }) => !$hideBorder && `
+ padding: calc(${lineHeightComputed} / 2) 0;
+ border-bottom: ${borderSize} solid ${colorGrayLighter};
+ `}
+`;
+
+const Title = styled(TitleElipsis)`
+ display: block;
+ text-align: center;
+ font-weight: ${headingsFontWeight};
+ font-size: calc(${fontSizeLarge} + 0.05rem);
+ color: ${colorGrayDark};
+ white-space: normal;
+ margin: 0;
+ line-height: calc(${lineHeightComputed} * 2);
+
+ @media ${smallOnly} {
+ font-size: ${fontSizeBase};
+ padding: 0 ${mdPaddingX};
+ }
+
+ ${({ $headerOnTop }) => $headerOnTop && `
+ grid-area: 1 / 1 / 2 / 3;
+ `}
+
+ ${({ $innerHeader }) => $innerHeader && `
+ grid-area: 2 / 1 / 3 / 2;
+ `}
+
+ ${({ $hasMarginBottom }) => $hasMarginBottom && `
+ margin-bottom: ${mdPaddingX};
+ `}
+`;
+
+const DismissButton = styled(Button)`
+ & > span:first-child {
+ border-color: transparent;
+ background-color: transparent;
+
+ & > i { color: ${colorText}; }
+ }
+
+ ${({ $headerOnTop }) => $headerOnTop && `
+ grid-area: 1 / 2 / 2 / 3;
+ `}
+
+ ${({ $innerHeader }) => $innerHeader && `
+ grid-area: 1 / 1 / 2 / 2;
+ `}
+
+ justify-self: end;
+`;
+
+export default {
+ Header,
+ Title,
+ DismissButton,
+};
diff --git a/src/2.7.12/imports/ui/components/common/modal/random-user/component.jsx b/src/2.7.12/imports/ui/components/common/modal/random-user/component.jsx
new file mode 100644
index 00000000..11b31ec7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/random-user/component.jsx
@@ -0,0 +1,216 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import AudioService from '/imports/ui/components/audio/service';
+import Styled from './styles';
+
+const SELECT_RANDOM_USER_COUNTDOWN = Meteor.settings.public.selectRandomUser.countdown;
+
+const messages = defineMessages({
+ noViewers: {
+ id: 'app.modal.randomUser.noViewers.description',
+ description: 'Label displayed when no viewers are avaiable',
+ },
+ selected: {
+ id: 'app.modal.randomUser.selected.description',
+ description: 'Label shown to the selected user',
+ },
+ randUserTitle: {
+ id: 'app.modal.randomUser.title',
+ description: 'Modal title label',
+ },
+ whollbeSelected: {
+ id: 'app.modal.randomUser.who',
+ description: 'Label shown during the selection',
+ },
+ onlyOneViewerTobeSelected: {
+ id: 'app.modal.randomUser.alone',
+ description: 'Label shown when only one viewer to be selected',
+ },
+ reselect: {
+ id: 'app.modal.randomUser.reselect.label',
+ description: 'select new random user button label',
+ },
+ ariaModalTitle: {
+ id: 'app.modal.randomUser.ariaLabel.title',
+ description: 'modal title displayed to screen reader',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ numAvailableViewers: PropTypes.number.isRequired,
+ randomUserReq: PropTypes.func.isRequired,
+};
+
+class RandomUserSelect extends Component {
+ constructor(props) {
+ super(props);
+
+ if (props.currentUser.presenter) {
+ props.randomUserReq();
+ }
+
+ if (SELECT_RANDOM_USER_COUNTDOWN) {
+ this.state = {
+ count: 0,
+ };
+ this.play = this.play.bind(this);
+ }
+ }
+
+ iterateSelection() {
+ if (this.props.mappedRandomlySelectedUsers.length > 1) {
+ const that = this;
+ setTimeout(delay(that.props.mappedRandomlySelectedUsers, 1), that.props.mappedRandomlySelectedUsers[1][1]);
+ function delay(arr, num) {
+ that.setState({
+ count: num,
+ });
+ if (num < that.props.mappedRandomlySelectedUsers.length - 1) {
+ setTimeout(() => { delay(arr, num + 1); }, arr[num + 1][1]);
+ }
+ }
+ }
+ }
+
+ componentDidMount() {
+ const { keepModalOpen, toggleKeepModalOpen, currentUser } = this.props;
+
+ if (currentUser.presenter && !keepModalOpen) {
+ toggleKeepModalOpen();
+ }
+
+ if (SELECT_RANDOM_USER_COUNTDOWN && !currentUser.presenter) {
+ this.iterateSelection();
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (SELECT_RANDOM_USER_COUNTDOWN) {
+ if (this.props.currentUser.presenter && this.state.count === 0) {
+ this.iterateSelection();
+ }
+
+ if ((prevState.count !== this.state.count) && this.props.keepModalOpen) {
+ this.play();
+ }
+ }
+ }
+
+ play() {
+ AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId}`
+ + '/resources/sounds/Poll.mp3');
+ }
+
+ reselect() {
+ if (SELECT_RANDOM_USER_COUNTDOWN) {
+ this.setState({
+ count: 0,
+ });
+ }
+ this.props.randomUserReq();
+ }
+
+ render() {
+ const {
+ keepModalOpen,
+ toggleKeepModalOpen,
+ intl,
+ setIsOpen,
+ numAvailableViewers,
+ currentUser,
+ clearRandomlySelectedUser,
+ mappedRandomlySelectedUsers,
+ isOpen,
+ priority,
+ } = this.props;
+
+ const counter = SELECT_RANDOM_USER_COUNTDOWN ? this.state.count : 0;
+ if (mappedRandomlySelectedUsers.length < counter + 1) return null;
+
+ const selectedUser = SELECT_RANDOM_USER_COUNTDOWN ? mappedRandomlySelectedUsers[counter][0] :
+ mappedRandomlySelectedUsers[mappedRandomlySelectedUsers.length - 1][0];
+ const countDown = SELECT_RANDOM_USER_COUNTDOWN ?
+ mappedRandomlySelectedUsers.length - this.state.count - 1 : 0;
+
+ let viewElement;
+ let title;
+
+ const amISelectedUser = currentUser.userId === selectedUser.userId;
+ if (numAvailableViewers < 1 || (currentUser.presenter && amISelectedUser)) { // there's no viewers to select from,
+ // or when you are the presenter but selected, which happens when the presenter ability is passed to somebody
+ // and people are entering and leaving the meeting
+ // display modal informing presenter that there's no viewers to select from
+ viewElement = (
+
+
+ {intl.formatMessage(messages.noViewers)}
+
+
+ );
+ title = intl.formatMessage(messages.randUserTitle);
+ } else { // viewers are available
+ if (!selectedUser) return null; // rendering triggered before selectedUser is available
+
+ // display modal with random user selection
+ viewElement = (
+
+
+ {selectedUser.name.slice(0, 2)}
+
+
+ {selectedUser.name}
+
+ {currentUser.presenter
+ && countDown === 0
+ && (
+ this.reselect()}
+ data-test="selectAgainRadomUser"
+ />
+ )}
+
+ );
+ title = countDown == 0
+ ? amISelectedUser
+ ? `${intl.formatMessage(messages.selected)}`
+ : numAvailableViewers == 1 && currentUser.presenter
+ ? `${intl.formatMessage(messages.onlyOneViewerTobeSelected)}`
+ : `${intl.formatMessage(messages.randUserTitle)}`
+ : `${intl.formatMessage(messages.whollbeSelected)} ${countDown}`;
+ }
+ if (keepModalOpen) {
+ return (
+ {
+ if (currentUser.presenter) clearRandomlySelectedUser();
+ toggleKeepModalOpen();
+ setIsOpen(false);
+ }}
+ contentLabel={intl.formatMessage(messages.ariaModalTitle)}
+ title={title}
+ {...{
+ isOpen,
+ priority,
+ }}
+ >
+ {viewElement}
+
+ );
+ } else {
+ return null;
+ }
+ }
+}
+
+RandomUserSelect.propTypes = propTypes;
+export default injectIntl(RandomUserSelect);
diff --git a/src/2.7.12/imports/ui/components/common/modal/random-user/container.jsx b/src/2.7.12/imports/ui/components/common/modal/random-user/container.jsx
new file mode 100644
index 00000000..5c92e9e2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/random-user/container.jsx
@@ -0,0 +1,106 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Meetings from '/imports/api/meetings';
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import { makeCall } from '/imports/ui/services/api';
+import RandomUserSelect from './component';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import logger from '/imports/startup/client/logger';
+
+const SELECT_RANDOM_USER_ENABLED = Meteor.settings.public.selectRandomUser.enabled;
+
+// A value that is used by component to remember
+// whether it should be open or closed after a render
+let keepModalOpen = true;
+
+// A value that stores the previous indicator
+let updateIndicator = 1;
+
+const toggleKeepModalOpen = () => { keepModalOpen = ! keepModalOpen; };
+
+const RandomUserSelectContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const { randomlySelectedUser } = props;
+
+ let mappedRandomlySelectedUsers = [];
+
+ const currentUser = {
+ userId: Auth.userID,
+ presenter: users[Auth.meetingID][Auth.userID].presenter
+ };
+
+ try {
+ if (!currentUser.presenter // this functionality does not bother presenter
+ && (!keepModalOpen) // we only ween a change if modal has been closed before
+ && (randomlySelectedUser[0][1] !== updateIndicator)// if tey are different, a user was generated
+ ) { keepModalOpen = true; } // reopen modal
+ if (!currentUser.presenter) { updateIndicator = randomlySelectedUser[0][1]; } // keep indicator up to date
+ } catch (err) {
+ logger.error({
+ logCode: 'Random_USer_Error',
+ extraInfo: {
+ stackTrace: err,
+ },
+ },
+ '\nIssue in Random User Select container caused by back-end crash'
+ + '\nValue of 6 randomly selected users was passed as '
+ + `{${randomlySelectedUser}}`
+ + '\nHowever, it is handled.'
+ + '\nError message:'
+ + `\n${err}`);
+ }
+
+ if (randomlySelectedUser) {
+ mappedRandomlySelectedUsers = randomlySelectedUser.map((ui) => {
+ const selectedUser = users[Auth.meetingID][ui[0]];
+ if (selectedUser){
+ return [{
+ userId: selectedUser.userId,
+ avatar: selectedUser.avatar,
+ color: selectedUser.color,
+ name: selectedUser.name,
+ }, ui[1]];
+ }
+ });
+ }
+
+ return (
+
+ );
+};
+export default withTracker(() => {
+ const viewerPool = Users.find({
+ meetingId: Auth.meetingID,
+ presenter: { $ne: true },
+ role: { $eq: 'VIEWER' },
+ }, {
+ fields: {
+ userId: 1,
+ },
+ }).fetch();
+
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, {
+ fields: {
+ randomlySelectedUser: 1,
+ },
+ });
+
+ const randomUserReq = () => (SELECT_RANDOM_USER_ENABLED ? makeCall('setRandomUser') : null);
+
+ const clearRandomlySelectedUser = () => (SELECT_RANDOM_USER_ENABLED ? makeCall('clearRandomlySelectedUser') : null);
+
+ return ({
+ toggleKeepModalOpen,
+ numAvailableViewers: viewerPool.length,
+ randomUserReq,
+ clearRandomlySelectedUser,
+ randomlySelectedUser: meeting.randomlySelectedUser,
+ });
+})(RandomUserSelectContainer);
diff --git a/src/2.7.12/imports/ui/components/common/modal/random-user/styles.js b/src/2.7.12/imports/ui/components/common/modal/random-user/styles.js
new file mode 100644
index 00000000..c93c238e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/random-user/styles.js
@@ -0,0 +1,57 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ fontSizeXXL,
+ headingsFontWeight,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ mdPaddingX,
+ smPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+
+const ModalViewContainer = styled.div`
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+
+ & > div {
+ margin-bottom: 1rem;
+ }
+`;
+
+const ModalAvatar = styled.div`
+ height: 6rem;
+ width: 6rem;
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: white;
+ font-size: ${fontSizeXXL};
+ font-weight: 400;
+ margin-bottom: ${smPaddingX};
+ text-transform: capitalize;
+`;
+
+const SelectedUserName = styled.div`
+ margin-bottom: ${mdPaddingX};
+ font-weight: ${headingsFontWeight};
+ font-size: 2rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ position: relative;
+ width: 100%;
+ text-align: center;
+`;
+
+const SelectButton = styled(Button)`
+ margin-bottom: ${mdPaddingX};
+`;
+
+export default {
+ ModalViewContainer,
+ ModalAvatar,
+ SelectedUserName,
+ SelectButton,
+};
diff --git a/src/2.7.12/imports/ui/components/common/modal/simple/component.jsx b/src/2.7.12/imports/ui/components/common/modal/simple/component.jsx
new file mode 100644
index 00000000..67356128
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/simple/component.jsx
@@ -0,0 +1,143 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import FocusTrap from 'focus-trap-react';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ modalClose: {
+ id: 'app.modal.close',
+ description: 'Close',
+ },
+ modalCloseDescription: {
+ id: 'app.modal.close.description',
+ description: 'Disregards changes and closes the modal',
+ },
+});
+
+const propTypes = {
+ title: PropTypes.string,
+ dismiss: PropTypes.shape({
+ callback: PropTypes.func,
+ }),
+ headerPosition: PropTypes.string,
+ shouldCloseOnOverlayClick: PropTypes.bool,
+ shouldShowCloseButton: PropTypes.bool,
+ overlayClassName: PropTypes.string,
+ modalisOpen: PropTypes.bool,
+};
+
+const defaultProps = {
+ title: '',
+ dismiss: {
+ callback: null,
+ },
+ shouldCloseOnOverlayClick: true,
+ shouldShowCloseButton: true,
+ overlayClassName: 'modalOverlay',
+ headerPosition: 'inner',
+ modalisOpen: false,
+};
+
+class ModalSimple extends Component {
+ constructor(props) {
+ super(props);
+ this.modalRef = React.createRef();
+ this.handleDismiss = this.handleDismiss.bind(this);
+ this.handleRequestClose = this.handleRequestClose.bind(this);
+ this.handleOutsideClick = this.handleOutsideClick.bind(this);
+ }
+
+ componentDidMount() {
+ document.addEventListener('mousedown', this.handleOutsideClick, false);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('mousedown', this.handleOutsideClick, false);
+ }
+
+ handleDismiss() {
+ const { modalHide, dismiss } = this.props;
+ if (!dismiss || !modalHide) return;
+ modalHide(dismiss.callback);
+ }
+
+ handleRequestClose(event) {
+ const { onRequestClose } = this.props;
+ const closeModal = onRequestClose || this.handleDismiss;
+
+ closeModal();
+
+ if (event && event.type === 'click') {
+ setTimeout(() => {
+ if (document.activeElement) {
+ document.activeElement.blur();
+ }
+ }, 0);
+ }
+ }
+
+ handleOutsideClick(e) {
+ const { modalisOpen } = this.props;
+ if (this.modalRef.current && !this.modalRef.current.contains(e.target) && modalisOpen) {
+ this.handleRequestClose(e);
+ }
+ }
+
+ render() {
+ const {
+ id,
+ intl,
+ title,
+ hideBorder,
+ dismiss,
+ className,
+ modalisOpen,
+ onRequestClose,
+ shouldShowCloseButton,
+ contentLabel,
+ headerPosition,
+ 'data-test': dataTest,
+ children,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+ {title || ''}
+
+
+ {children}
+
+
+
+
+ );
+ }
+}
+
+ModalSimple.propTypes = propTypes;
+ModalSimple.defaultProps = defaultProps;
+
+export default injectIntl(ModalSimple);
diff --git a/src/2.7.12/imports/ui/components/common/modal/simple/styles.js b/src/2.7.12/imports/ui/components/common/modal/simple/styles.js
new file mode 100644
index 00000000..c7319c51
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/modal/simple/styles.js
@@ -0,0 +1,37 @@
+import styled from 'styled-components';
+import Styled from '../base/component';
+import {
+ borderSize,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorWhite,
+ colorText,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import ModalHeader from '/imports/ui/components/common/modal/header/component';
+
+const SimpleModal = styled(Styled.BaseModal)`
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ display: flex;
+ flex-direction: column;
+ padding: ${mdPaddingX};
+ box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.5);
+ background-color: ${colorWhite} !important;
+`;
+
+const Header = styled(ModalHeader)``;
+
+const Content = styled.div`
+ overflow: visible;
+ color: ${colorText};
+ font-weight: normal;
+ padding: 0;
+`;
+
+export default {
+ SimpleModal,
+ Header,
+ Content,
+};
diff --git a/src/2.7.12/imports/ui/components/common/radio/component.jsx b/src/2.7.12/imports/ui/components/common/radio/component.jsx
new file mode 100644
index 00000000..6aad46a1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/radio/component.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import Base from '../checkbox/base';
+import Styled from './styles';
+
+export default class Radio extends Base {
+ render() {
+ const {
+ ariaLabel, ariaDesc, ariaDescribedBy, ariaLabelledBy, checked, disabled, label,
+ } = this.props;
+
+ const radio = (
+ }
+ icon={ }
+ disabled={disabled}
+ inputProps={{
+ 'aria-label': ariaLabel,
+ 'aria-describedby': ariaDescribedBy,
+ 'aria-labelledby': ariaLabelledBy,
+ }}
+ onChange={this.handleChange}
+ ref={this.element}
+ />
+ );
+
+ return (
+ <>
+ {label ? (
+
+ ) : radio}
+ {ariaDesc}
+ >
+ );
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/common/radio/styles.js b/src/2.7.12/imports/ui/components/common/radio/styles.js
new file mode 100644
index 00000000..df52803b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/radio/styles.js
@@ -0,0 +1,33 @@
+import styled from 'styled-components';
+import { colorText, colorSuccess } from '/imports/ui/stylesheets/styled-components/palette';
+import Icon from '/imports/ui/components/common/icon/component';
+import BaseRadio from '@mui/material/Radio';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import { styled as muiStyled } from '@mui/system';
+
+const Radio = muiStyled(BaseRadio)(() => ({
+ '&.Mui-checked': {
+ color: `${colorSuccess} !important`,
+ },
+}));
+
+const Label = muiStyled(FormControlLabel)(() => ({
+ '& .MuiFormControlLabel-label': {
+ fontFamily: 'inherit !important',
+ color: `${colorText} !important`,
+ },
+ '&.Mui-disabled': {
+ cursor: 'not-allowed !important',
+ },
+}));
+
+const RadioIcon = styled(Icon)``;
+
+const RadioIconChecked = styled(RadioIcon)``;
+
+export default {
+ RadioIcon,
+ RadioIconChecked,
+ Radio,
+ Label,
+};
diff --git a/src/2.7.12/imports/ui/components/common/switch/component.jsx b/src/2.7.12/imports/ui/components/common/switch/component.jsx
new file mode 100644
index 00000000..2d05e4ac
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/switch/component.jsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import Toggle from 'react-toggle';
+import { defineMessages, injectIntl } from 'react-intl';
+import Settings from '/imports/ui/services/settings';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ on: {
+ id: 'app.switch.onLabel',
+ description: 'label for toggle switch on state',
+ },
+ off: {
+ id: 'app.switch.offLabel',
+ description: 'label for toggle switch off state',
+ },
+});
+
+const defaultProps = {
+ showToggleLabel: true,
+ invertColors: false,
+};
+
+class Switch extends Toggle {
+ render() {
+ const {
+ intl,
+ icons: _icons,
+ ariaLabelledBy,
+ ariaDescribedBy,
+ ariaLabel,
+ ariaDesc,
+ showToggleLabel,
+ invertColors,
+ disabled,
+ ...inputProps
+ } = this.props;
+
+ const { animations } = Settings.application;
+
+ const {
+ checked,
+ hasFocus,
+ } = this.state;
+
+ return (
+
+
+
+ {showToggleLabel ? intl.formatMessage(intlMessages.on) : null}
+
+
+ {showToggleLabel ? intl.formatMessage(intlMessages.off) : null}
+
+
+
+
+ { this.input = ref; }}
+ onFocus={this.handleFocus}
+ onBlur={this.handleBlur}
+ type="checkbox"
+ tabIndex="0"
+ disabled={disabled}
+ aria-label={ariaLabel}
+ aria-describedby={ariaDescribedBy}
+ />
+ {ariaDesc}
+
+ );
+ }
+}
+
+Switch.defaultProps = defaultProps;
+
+export default injectIntl(Switch);
diff --git a/src/2.7.12/imports/ui/components/common/switch/styles.js b/src/2.7.12/imports/ui/components/common/switch/styles.js
new file mode 100644
index 00000000..8750c2cc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/switch/styles.js
@@ -0,0 +1,170 @@
+import styled, { css } from 'styled-components';
+import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
+import { colorDanger, colorSuccess } from '/imports/ui/stylesheets/styled-components/palette';
+
+const Switch = styled.div`
+ &:hover,
+ &:focus,
+ &:focus-within {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:focus,
+ &:focus-within {
+ outline-style: solid;
+ }
+
+ display: inline-block;
+ position: relative;
+ cursor: pointer;
+ background-color: transparent;
+ border: 0;
+ padding: 0;
+
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ -webkit-tap-highlight-color: transparent;
+
+ ${({ disabled }) => disabled && `
+ cursor: not-allowed;
+ opacity: 0.5;
+ `}
+
+ ${({ disabled, animations }) => disabled && animations && `
+ transition: opacity 0.25s;
+ `}
+`;
+
+const ToggleTrack = styled.div`
+ overflow: hidden;
+ width: 3.5rem;
+ height: 1.5rem;
+ padding: 0;
+ border-radius: 2rem;
+ background-color: ${colorDanger};
+
+ [dir="rtl"] & {
+ width: 4rem;
+ }
+
+ ${({ animations }) => animations && `
+ transition: all 0.2s ease;
+ `}
+
+ ${({ checked }) => checked && `
+ background-color: ${colorSuccess};
+ `}
+
+ ${({ invertColors, checked }) => invertColors && !checked && `
+ background-color: ${colorSuccess} !important;
+ `}
+
+ ${({ invertColors, checked }) => invertColors && checked && `
+ background-color: ${colorDanger} !important;
+ `}
+
+`;
+
+const ToggleTrackCheck = styled.div`
+ position: absolute;
+ color: white;
+ width: 1rem;
+ line-height: 1.5rem;
+ font-size: 0.8rem;
+ left: 0.5rem;
+ opacity: 0;
+
+ [dir="rtl"] & {
+ left: 0.8rem;
+ }
+
+ ${({ animations }) => animations && `
+ transition: opacity 0.25s ease;
+ `}
+
+ ${({ checked }) => checked && `
+ opacity: 1;
+ transition: opacity calc(var(--enableAnimation) * 0.25s) ease;
+ `}
+`;
+
+const ToggleTrackX = styled.div`
+ position: absolute;
+ color: white;
+ width: 1rem;
+ line-height: 1.5rem;
+ font-size: 0.8rem;
+ left: 1.7rem;
+ opacity: 1;
+
+ [dir="rtl"] & {
+ left: 2.2rem;
+ }
+
+ ${({ animations }) => animations && `
+ transition: opacity 0.25s ease;
+ `}
+
+ ${({ checked }) => checked && `
+ opacity: 0;
+ `}
+`;
+
+const ToggleThumb = styled.div`
+ position: absolute;
+ top: 1px;
+ left: ${({ isRTL }) => isRTL ? '2.6rem' : '1px'};
+ width: 1.35rem;
+ height: 1.35rem;
+ border-radius: 50%;
+ background-color: #FAFAFA;
+ box-sizing: border-box;
+ box-shadow: 2px 0px 10px -1px rgba(0,0,0,0.4);
+
+ ${({ animations }) => animations && `
+ transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms;
+ `}
+
+ ${({ checked }) => checked && css`
+ left: ${({ isRTL }) => isRTL ? '1px' : '2.1rem' };
+ box-shadow: -2px 0px 10px -1px rgba(0,0,0,0.4);
+ `}
+
+ ${({ hasFocus }) => hasFocus && `
+ box-shadow: 0px 0px 2px 3px #0F70D7;
+ `}
+
+ ${({ disabled }) => !disabled && `
+ &:active{
+ box-shadow: 0px 0px 5px 5px #0F70D7;
+ }
+ `}
+`;
+
+const ScreenreaderInput = styled.input`
+ border: 0;
+ clip: rect(0 0 0 0);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+`;
+
+export default {
+ Switch,
+ ToggleTrack,
+ ToggleTrackCheck,
+ ToggleTrackX,
+ ToggleThumb,
+ ScreenreaderInput,
+};
diff --git a/src/2.7.12/imports/ui/components/common/toast/component.jsx b/src/2.7.12/imports/ui/components/common/toast/component.jsx
new file mode 100644
index 00000000..e5827977
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/toast/component.jsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { toast } from 'react-toastify';
+import Icon from '/imports/ui/components/common/icon/component';
+import Styled from './styles';
+
+const propTypes = {
+ icon: PropTypes.string,
+ message: PropTypes.node.isRequired,
+ type: PropTypes.oneOf(Object.values(toast.TYPE)).isRequired,
+};
+
+const defaultProps = {
+ icon: null,
+};
+
+const defaultIcons = {
+ [toast.TYPE.INFO]: 'help',
+ [toast.TYPE.SUCCESS]: 'checkmark',
+ [toast.TYPE.WARNING]: 'warning',
+ [toast.TYPE.ERROR]: 'close',
+ [toast.TYPE.DEFAULT]: 'about',
+};
+
+const Toast = ({
+ icon,
+ type,
+ message,
+ content,
+ small,
+}) => (
+
+
+
+
+
+
+ {message}
+
+
+ {content
+ ? (
+
+
+
+ {content}
+
+
+ ) : null}
+
+);
+
+export default Toast;
+
+Toast.propTypes = propTypes;
+Toast.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/common/toast/container.jsx b/src/2.7.12/imports/ui/components/common/toast/container.jsx
new file mode 100644
index 00000000..ba94c8ff
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/toast/container.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import Styled from './styles';
+import Settings from '/imports/ui/services/settings';
+
+class ToastContainer extends React.Component {
+ // we never want this component to update since will break Toastify
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ const { animations } = Settings.application;
+
+ return (
+ )}
+ autoClose={5000}
+ toastClassName="toastClass"
+ bodyClassName="toastBodyClass"
+ progressClassName="toastProgressClass"
+ newestOnTop={false}
+ hideProgressBar={false}
+ closeOnClick
+ pauseOnHover
+ />
+ );
+ }
+}
+
+export default ToastContainer;
diff --git a/src/2.7.12/imports/ui/components/common/toast/inject-notify/component.jsx b/src/2.7.12/imports/ui/components/common/toast/inject-notify/component.jsx
new file mode 100644
index 00000000..acbf1daa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/toast/inject-notify/component.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import { notify } from '/imports/ui/services/notification';
+
+const injectNotify = ComponentToWrap =>
+ props => ( );
+
+export default injectNotify;
diff --git a/src/2.7.12/imports/ui/components/common/toast/styles.js b/src/2.7.12/imports/ui/components/common/toast/styles.js
new file mode 100644
index 00000000..f054bff9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/toast/styles.js
@@ -0,0 +1,210 @@
+import styled from 'styled-components';
+import { ToastContainer as Toastify } from 'react-toastify';
+import Icon from '/imports/ui/components/common/icon/component';
+import {
+ fontSizeSmallest,
+ fontSizeSmaller,
+ fontSizeSmall,
+ lineHeightComputed,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorGrayDark,
+ toastDefaultColor,
+ toastDefaultBg,
+ toastInfoColor,
+ toastInfoBg,
+ toastSuccessColor,
+ toastSuccessBg,
+ toastErrorColor,
+ toastErrorBg,
+ toastWarningColor,
+ toastWarningBg,
+ colorGrayLighter,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ toastOffsetSm,
+ smPaddingX,
+ borderSizeSmall,
+ toastIconMd,
+ toastIconSm,
+ jumboPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const CloseIcon = styled(Icon)`
+ background: transparent;
+ outline: none;
+ border: none;
+ cursor: pointer;
+ opacity: .5;
+ font-size: ${fontSizeSmallest};
+ color: ${colorGrayDark};
+ line-height: 0;
+ position: relative;
+ font-size: 70%;
+ left: ${toastOffsetSm};
+
+ [dir="rtl"] & {
+ left: auto;
+ right: ${toastOffsetSm};
+ }
+
+ ${({ animations }) => animations && `
+ transition: .3s ease;
+ `}
+
+ &:before {
+ margin: inherit inherit inherit -.4rem;
+
+ [dir="rtl"] & {
+ margin: inherit -.4rem inherit inherit;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ opacity: 1;
+ }
+
+ @media ${smallOnly} {
+ position: relative;
+ font-size: ${fontSizeSmaller};
+ left: auto;
+ }
+`;
+
+const ToastContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ ${({ small }) => small && `
+ background-color: inherit;
+ `}
+`;
+
+const ToastIcon = styled.div`
+ align-self: flex-start;
+ margin: 0 ${smPaddingX} auto 0;
+ width: ${toastIconMd};
+ height: ${toastIconMd};
+ border-radius: 50%;
+ position: relative;
+ flex-shrink: 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 auto ${smPaddingX};
+ }
+
+ & > i {
+ line-height: 0;
+ color: inherit;
+ position: absolute;
+ top: 50%;
+ width: 100%;
+ }
+
+ ${({ small }) => small && `
+ width: ${toastIconSm};
+ height: ${toastIconSm};
+ & > i {
+ font-size: 70%;
+ }
+ `}
+`;
+
+const ToastMessage = styled.div`
+ margin-top: auto;
+ margin-bottom: auto;
+ font-size: ${fontSizeSmall};
+ max-height: 15vh;
+ overflow: auto;
+
+ ${({ small }) => small && `
+ font-size: 80%;
+ `}
+`;
+
+const BackgroundColorInherit = styled.div`
+ position: relative;
+`;
+
+const Separator = styled.div`
+ position: relative;
+ width: 100%;
+ height: ${borderSizeSmall};
+ background-color: ${colorGrayLighter};
+ margin-top: calc(${lineHeightComputed} * .5);
+ margin-bottom: calc(${lineHeightComputed} * .5);
+`;
+
+const Toast = styled.div`
+ display: flex;
+
+ ${({ type }) => type === 'default' && `
+ & .toastIcon {
+ color: ${toastDefaultColor};
+ background-color: ${toastDefaultBg};
+ }
+ `}
+
+ ${({ type }) => type === 'error' && `
+ & .toastIcon {
+ color: ${toastErrorColor};
+ background-color: ${toastErrorBg};
+ }
+ `}
+
+ ${({ type }) => type === 'info' && `
+ & .toastIcon {
+ color: ${toastInfoColor};
+ background-color: ${toastInfoBg};
+ }
+ `}
+
+ ${({ type }) => type === 'success' && `
+ & .toastIcon {
+ color: ${toastSuccessColor};
+ background-color: ${toastSuccessBg};
+ }
+ `}
+
+ ${({ type }) => type === 'warning' && `
+ & .toastIcon {
+ color: ${toastWarningColor};
+ background-color: ${toastWarningBg};
+ }
+ `}
+`;
+
+const ToastifyContainer = styled(Toastify)`
+ z-index: 998;
+ position: fixed;
+ min-width: 20rem !important;
+ max-width: 23rem !important;
+ box-sizing: border-box;
+ right: ${jumboPaddingY};
+ left: auto;
+ top: 4.5rem;
+ max-height: 75vh;
+ overflow: hidden;
+
+ [dir="rtl"] & {
+ right: auto;
+ left: ${jumboPaddingY};
+ }
+
+ @media ${smallOnly} {
+ width: 75%;
+ }
+`;
+
+export default {
+ CloseIcon,
+ ToastContainer,
+ ToastIcon,
+ ToastMessage,
+ BackgroundColorInherit,
+ Separator,
+ Toast,
+ ToastifyContainer,
+};
diff --git a/src/2.7.12/imports/ui/components/common/tooltip/bbbtip.css b/src/2.7.12/imports/ui/components/common/tooltip/bbbtip.css
new file mode 100644
index 00000000..2a179d9b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/tooltip/bbbtip.css
@@ -0,0 +1,17 @@
+.tippy-box[data-theme~='bbbtip']{
+ color:#fff;
+ background-color:#333333;
+ padding: .25rem .5rem;
+ border-radius: 4px;
+}
+
+.tippy-box[data-theme~='bbbtip']>.tippy-svg-arrow{
+ fill: #333333;
+ background-color: transparent;
+}
+
+.tippy-box[data-theme~='bbbtip']>.tippy-content{
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/src/2.7.12/imports/ui/components/common/tooltip/component.jsx b/src/2.7.12/imports/ui/components/common/tooltip/component.jsx
new file mode 100644
index 00000000..5aa3b5e8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/tooltip/component.jsx
@@ -0,0 +1,200 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+import { ESCAPE } from '/imports/utils/keyCodes';
+import Settings from '/imports/ui/services/settings';
+import Tippy, { roundArrow } from 'tippy.js';
+import 'tippy.js/dist/svg-arrow.css';
+import 'tippy.js/animations/shift-away.css';
+import './bbbtip.css';
+import BaseButton from '/imports/ui/components/common/button/base/component';
+import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const ANIMATION_DURATION = 350;
+const ANIMATION_DELAY = [150, 50];
+const DEFAULT_ANIMATION = 'shift-away';
+const ANIMATION_NONE = 'none';
+const TIP_OFFSET = [0, 10];
+
+const propTypes = {
+ title: PropTypes.string,
+ position: PropTypes.oneOf(['bottom','top']),
+ children: PropTypes.element.isRequired,
+ className: PropTypes.string,
+};
+
+const defaultProps = {
+ position: 'bottom',
+ className: null,
+ title: '',
+};
+
+class Tooltip extends Component {
+ static buttonComponentHasButtonEmoji(_component) {
+ return (
+ _component
+ && (_component.type === BaseButton)
+ && (_component.props)
+ && (_component.props.children)
+ && (typeof _component.props.children.find === 'function')
+ && (!!_component.props.children.find((_child) => (
+ _child && _child.type === ButtonEmoji
+ )))
+ );
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.tippySelectorId = uniqueId('tippy-');
+ this.onShow = this.onShow.bind(this);
+ this.onHide = this.onHide.bind(this);
+ this.handleEscapeHide = this.handleEscapeHide.bind(this);
+ }
+
+ componentDidMount() {
+ const {
+ position,
+ title,
+ delay,
+ placement,
+ } = this.props;
+
+ const { animations } = Settings.application;
+
+ const overridePlacement = placement ? placement : position;
+ let overrideDelay;
+ if (animations) {
+ overrideDelay = delay ? [delay, ANIMATION_DELAY[1]] : ANIMATION_DELAY;
+ } else {
+ overrideDelay = delay ? [delay, 0] : [ANIMATION_DELAY[0], 0];
+ }
+
+ const options = {
+ aria: null,
+ allowHTML: false,
+ animation: animations ? DEFAULT_ANIMATION : ANIMATION_NONE,
+ appendTo: document.body,
+ arrow: roundArrow,
+ boundary: 'window',
+ content: title,
+ delay: overrideDelay,
+ duration: animations ? ANIMATION_DURATION : 0,
+ interactive: true,
+ interactiveBorder: 10,
+ onShow: this.onShow,
+ onHide: this.onHide,
+ offset: TIP_OFFSET,
+ placement: overridePlacement,
+ touch: ['hold', 1000],
+ theme: 'bbbtip',
+ multiple: false,
+ };
+ this.tooltip = Tippy(`#${this.tippySelectorId}`, options);
+ }
+
+ componentDidUpdate() {
+ const { animations } = Settings.application;
+ const { title } = this.props;
+ const elements = document.querySelectorAll('[id^="tippy-"]');
+
+ Array.from(elements).filter((e) => {
+ const instance = e._tippy;
+
+ if (!instance) return false;
+
+ const animation = animations ? DEFAULT_ANIMATION : ANIMATION_NONE;
+
+ if (animation === instance.props.animation) return false;
+
+ return true;
+ }).forEach((e) => {
+ const instance = e._tippy;
+ const newProps = {
+ animation: animations
+ ? DEFAULT_ANIMATION : ANIMATION_NONE,
+ duration: animations ? ANIMATION_DURATION : 0,
+ };
+ if (!e.getAttribute("delay")) {
+ newProps["delay"] = animations ? ANIMATION_DELAY : [ANIMATION_DELAY[0], 0];
+ }
+ instance.setProps(newProps);
+ });
+
+ const elem = document.getElementById(this.tippySelectorId);
+ const opts = { content: title, appendTo: document.body };
+ if (elem && elem._tippy) elem._tippy.setProps(opts);
+ }
+
+ componentWillUnmount() {
+ setTimeout(() => {
+ const tooltip = this.tooltip[0];
+ if (tooltip) tooltip.hide();
+ }, 150);
+ }
+
+ onShow() {
+ document.addEventListener('keyup', this.handleEscapeHide);
+ }
+
+ onHide() {
+ document.removeEventListener('keyup', this.handleEscapeHide);
+ }
+
+ handleEscapeHide(e) {
+ if (this.tooltip
+ && e.keyCode === ESCAPE
+ && this.tooltip.tooltips
+ && this.tooltip.tooltips[0]) {
+ this.tooltip.tooltips[0].hide();
+ }
+ }
+
+ render() {
+ const {
+ children,
+ className,
+ title,
+ ...restProps
+ } = this.props;
+
+ let WrappedComponent;
+ let WrappedComponentBound;
+
+ if (Tooltip.buttonComponentHasButtonEmoji(children)) {
+ const { children: grandChildren } = children.props;
+
+ let otherChildren;
+
+ [WrappedComponent, ...otherChildren] = grandChildren;
+
+ WrappedComponentBound = React.cloneElement(WrappedComponent, {
+ id: this.tippySelectorId,
+ className: cx(WrappedComponent.props.className, className),
+ key: this.tippySelectorId,
+ });
+
+ const ParentComponent = React.Children.only(children);
+ const updatedChildren = [WrappedComponentBound, ...otherChildren];
+
+ return React.cloneElement(ParentComponent, null,
+ updatedChildren);
+ }
+
+ WrappedComponent = React.Children.only(children);
+
+ WrappedComponentBound = React.cloneElement(WrappedComponent, {
+ ...restProps,
+ id: this.tippySelectorId,
+ className: cx(children.props.className, className),
+ });
+
+ return WrappedComponentBound;
+ }
+}
+
+export default Tooltip;
+
+Tooltip.defaultProps = defaultProps;
+Tooltip.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/common/tooltip/container.jsx b/src/2.7.12/imports/ui/components/common/tooltip/container.jsx
new file mode 100644
index 00000000..df9a29b6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/common/tooltip/container.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import Tooltip from './component';
+
+const TooltipContainer = props => ;
+
+export default withTracker(() => ({
+ fullscreen: FullscreenService.getFullscreenElement(),
+}))(TooltipContainer);
diff --git a/src/2.7.12/imports/ui/components/components-data/chat-context/adapter.jsx b/src/2.7.12/imports/ui/components/components-data/chat-context/adapter.jsx
new file mode 100644
index 00000000..006795dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/chat-context/adapter.jsx
@@ -0,0 +1,156 @@
+import { useContext, useEffect, useState } from 'react';
+import { throttle } from '/imports/utils/throttle';
+import { ChatContext, ACTIONS, MESSAGE_TYPES } from './context';
+import { UsersContext } from '../users-context/context';
+import { makeCall } from '/imports/ui/services/api';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import Auth from '/imports/ui/services/auth';
+import CollectionEventsBroker from '/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker';
+
+let prevUserData = {};
+let currentUserData = {};
+let messageQueue = [];
+let referenceIds = {};
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+const CHAT_CLEAR_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_clear;
+const ITENS_PER_PAGE = CHAT_CONFIG.itemsPerPage;
+const TIME_BETWEEN_FETCHS = CHAT_CONFIG.timeBetweenFetchs;
+const EVENT_NAME = 'bbb-group-chat-messages-subscription-has-stoppped';
+const EVENT_NAME_SUBSCRIPTION_READY = 'bbb-group-chat-messages-subscriptions-ready';
+
+const getMessagesBeforeJoinCounter = async () => {
+ const counter = await makeCall('chatMessageBeforeJoinCounter');
+ return counter;
+};
+
+const startSyncMessagesbeforeJoin = async (dispatch) => {
+ const chatsMessagesCount = await getMessagesBeforeJoinCounter();
+ const pagesPerChat = chatsMessagesCount.map((chat) => ({
+ ...chat,
+ pages: Math.ceil(chat.count / ITENS_PER_PAGE),
+ syncedPages: 0,
+ }));
+
+ const syncRoutine = async (chatsToSync) => {
+ if (!chatsToSync.length) return;
+
+ const pagesToFetch = [...chatsToSync].sort((a, b) => a.pages - b.pages);
+ const chatWithLessPages = pagesToFetch[0];
+ chatWithLessPages.syncedPages += 1;
+ const messagesFromPage = await makeCall(
+ 'fetchMessagePerPage',
+ chatWithLessPages.chatId,
+ chatWithLessPages.syncedPages,
+ );
+
+ if (messagesFromPage.length) {
+ dispatch({
+ type: ACTIONS.ADDED,
+ value: messagesFromPage,
+ messageType: MESSAGE_TYPES.HISTORY,
+ });
+ dispatch({
+ type: ACTIONS.SYNC_STATUS,
+ value: {
+ chatId: chatWithLessPages.chatId,
+ percentage: Math.floor((chatWithLessPages.syncedPages / chatWithLessPages.pages) * 100),
+ },
+ });
+ }
+
+ await new Promise((r) => setTimeout(r, TIME_BETWEEN_FETCHS));
+ syncRoutine(pagesToFetch.filter((chat) => !(chat.syncedPages > chat.pages)));
+ };
+ syncRoutine(pagesPerChat);
+};
+
+const Adapter = () => {
+ const usingChatContext = useContext(ChatContext);
+ const { dispatch } = usingChatContext;
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const [syncStarted, setSync] = useState(true);
+ const [subscriptionReady, setSubscriptionReady] = useState(false);
+ ChatLogger.trace('chatAdapter::body::users', users[Auth.meetingID]);
+
+ useEffect(() => {
+ window.addEventListener(EVENT_NAME, () => {
+ /* needed to prevent an issue with dupĺicated messages when user role is changed
+ more info: https://github.com/bigbluebutton/bigbluebutton/issues/11842 */
+ if (prevUserData.role && prevUserData?.role !== currentUserData?.role) {
+ dispatch({
+ type: ACTIONS.CLEAR_STREAM_MESSAGES,
+ });
+ }
+ });
+
+ window.addEventListener(EVENT_NAME_SUBSCRIPTION_READY, () => {
+ setSubscriptionReady(true);
+ });
+ }, []);
+
+ useEffect(() => {
+ const connectionStatus = Meteor.status();
+ if (connectionStatus.connected && !syncStarted && Auth.userID && subscriptionReady) {
+ setTimeout(() => {
+ setSync(true);
+ startSyncMessagesbeforeJoin(dispatch);
+ }, 1000);
+ }
+ }, [Meteor.status().connected, syncStarted, Auth.userID, subscriptionReady]);
+
+ /* needed to prevent an issue with dupĺicated messages when user role is changed
+ more info: https://github.com/bigbluebutton/bigbluebutton/issues/11842 */
+ useEffect(() => {
+ if (users[Auth.meetingID] && users[Auth.meetingID][Auth.userID]) {
+ if (currentUserData?.role !== users[Auth.meetingID][Auth.userID]?.role) {
+ prevUserData = currentUserData;
+ referenceIds = {};
+ }
+ currentUserData = users[Auth.meetingID][Auth.userID];
+ }
+ }, [usingUsersContext]);
+
+ useEffect(() => {
+ if (!Meteor.status().connected) return () => null;
+ setSync(false);
+ dispatch({
+ type: ACTIONS.CLEAR_ALL,
+ });
+ const throttledDispatch = throttle(
+ () => {
+ const dispatchedMessageQueue = [...messageQueue];
+ messageQueue = [];
+ dispatch({
+ type: ACTIONS.ADDED,
+ value: dispatchedMessageQueue,
+ messageType: MESSAGE_TYPES.STREAM,
+ });
+ },
+ 1000,
+ { trailing: true, leading: true },
+ );
+
+ const insertToContext = (fields) => {
+ if (fields.id === `${SYSTEM_CHAT_TYPE}-${CHAT_CLEAR_MESSAGE}`) {
+ messageQueue = [];
+ dispatch({
+ type: ACTIONS.REMOVED,
+ });
+ }
+ if (referenceIds[fields.referenceId]) return;
+
+ referenceIds[fields.referenceId] = true;
+ messageQueue.push(fields);
+ throttledDispatch();
+ };
+
+ CollectionEventsBroker.addListener('group-chat-msg', 'added', insertToContext);
+ }, [Meteor.status().connected, Meteor.connection._lastSessionId]);
+
+ return null;
+};
+
+export default Adapter;
diff --git a/src/2.7.12/imports/ui/components/components-data/chat-context/context.jsx b/src/2.7.12/imports/ui/components/components-data/chat-context/context.jsx
new file mode 100644
index 00000000..da6649db
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/chat-context/context.jsx
@@ -0,0 +1,397 @@
+import React, {
+ createContext,
+ useReducer,
+} from 'react';
+
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import Storage from '/imports/ui/services/storage/session';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+import UserService from '/imports/ui/components/user-list/service';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
+const PUBLIC_GROUP_CHAT_KEY = CHAT_CONFIG.public_group_id;
+const SYSTEM_CHAT_TYPE = CHAT_CONFIG.type_system;
+const CHAT_EXPORTED_PRESENTATION_MESSAGE = CHAT_CONFIG.system_messages_keys
+ .chat_exported_presentation;
+const CHAT_POLL_RESULTS_MESSAGE = CHAT_CONFIG.system_messages_keys.chat_poll_result;
+const CLOSED_CHAT_LIST_KEY = 'closedChatList';
+
+export const ACTIONS = {
+ TEST: 'test',
+ ADDED: 'added',
+ CHANGED: 'changed',
+ REMOVED: 'removed',
+ LAST_READ_MESSAGE_TIMESTAMP_CHANGED: 'last_read_message_timestamp_changed',
+ INIT: 'initial_structure',
+ SYNC_STATUS: 'sync_status',
+ HAS_MESSAGE_TO_SYNC: 'has_message_to_sync',
+ CLEAR_ALL: 'clear_all',
+ CLEAR_STREAM_MESSAGES: 'clear_stream_messages',
+};
+
+export const MESSAGE_TYPES = {
+ //messages before user login, synced via makecall
+ HISTORY: 'history',
+ // messages after user login, synced via subscription
+ STREAM: 'stream',
+};
+
+export const getGroupingTime = () => Meteor.settings.public.chat.grouping_messages_window;
+export const getGroupChatId = () => Meteor.settings.public.chat.public_group_id;
+export const getLoginTime = () => (Users.findOne({ userId: Auth.userID }) || {}).authTokenValidatedTime || 0;
+
+const generateTimeWindow = (timestamp) => {
+ const groupingTime = getGroupingTime();
+ const dateInMilliseconds = Math.floor(timestamp);
+ const groupIndex = Math.floor(dateInMilliseconds / groupingTime)
+ const date = groupIndex * 30000;
+ return date;
+}
+
+export const ChatContext = createContext();
+
+const removedMessagesReadState = {};
+
+const generateStateWithNewMessage = (msg, state, msgType = MESSAGE_TYPES.HISTORY) => {
+
+ const timeWindow = generateTimeWindow(msg.timestamp);
+ const userId = msg.sender;
+ const keyName = userId + '-' + timeWindow;
+ const msgBuilder = (msg, chat) => {
+ const msgTimewindow = generateTimeWindow(msg.timestamp);
+ const key = msg.sender + '-' + msgTimewindow;
+ const chatIndex = chat?.chatIndexes[key];
+ const {
+ _id,
+ ...restMsg
+ } = msg;
+
+ const indexValue = chatIndex ? (chatIndex + 1) : 1;
+ const messageKey = key + '-' + indexValue;
+ const tempGroupMessage = {
+ [messageKey]: {
+ ...restMsg,
+ key: messageKey,
+ lastTimestamp: msg.timestamp,
+ read: msg.chatId === PUBLIC_CHAT_KEY && msg.timestamp <= getLoginTime() ? true : !!removedMessagesReadState[msg.id],
+ content: [
+ { id: msg.id, text: msg.message, time: msg.timestamp },
+ ],
+ }
+ };
+
+ return [tempGroupMessage, msg.sender, indexValue, msg.senderName];
+ };
+
+ let stateMessages = state[msg.chatId];
+
+ if (!stateMessages) {
+ if (msg.chatId === getGroupChatId()) {
+ state[msg.chatId] = {
+ count: 0,
+ chatIndexes: {},
+ preJoinMessages: {},
+ posJoinMessages: {},
+ synced: true,
+ unreadTimeWindows: new Set(),
+ unreadCount: 0,
+ };
+ } else {
+ state[msg.chatId] = {
+ count: 0,
+ lastSender: '',
+ lastSenderName: '',
+ synced: true,
+ chatIndexes: {},
+ messageGroups: {},
+ unreadTimeWindows: new Set(),
+ unreadCount: 0,
+ };
+ stateMessages = state[msg.chatId];
+ }
+
+ stateMessages = state[msg.chatId];
+ }
+
+ const forPublicChat = msg.timestamp < getLoginTime() ? stateMessages.preJoinMessages : stateMessages.posJoinMessages;
+ const forPrivateChat = stateMessages.messageGroups;
+ const messageGroups = msg.chatId === getGroupChatId() ? forPublicChat : forPrivateChat;
+ const timewindowIndex = stateMessages.chatIndexes[keyName];
+ const groupMessage = messageGroups[keyName + '-' + timewindowIndex];
+ const fromSameSender = groupMessage
+ && groupMessage.sender !== stateMessages.lastSender
+ && groupMessage.senderName !== stateMessages.lastSenderName;
+
+ if (!groupMessage || fromSameSender || msg.id.startsWith(SYSTEM_CHAT_TYPE)) {
+ const [tempGroupMessage, sender, newIndex, senderName] = msgBuilder(msg, stateMessages);
+ stateMessages.lastSender = sender;
+ stateMessages.lastSenderName = senderName;
+ stateMessages.chatIndexes[keyName] = newIndex;
+ stateMessages.lastTimewindow = keyName + '-' + newIndex;
+ ChatLogger.trace('ChatContext::formatMsg::msgBuilder::tempGroupMessage', tempGroupMessage);
+
+ const messageGroupsKeys = Object.keys(tempGroupMessage);
+ messageGroupsKeys.forEach(key => {
+ messageGroups[key] = tempGroupMessage[key];
+ const message = tempGroupMessage[key];
+ message.messageType = msgType;
+ const previousMessage = message.timestamp <= getLoginTime();
+ const amIPresenter = UserService.isUserPresenter(Auth.userID);
+ const shouldAddPresentationExportMessage = message.id
+ .includes(CHAT_EXPORTED_PRESENTATION_MESSAGE) && !amIPresenter;
+ const shouldAddPollResultMessage = message.id
+ .includes(CHAT_POLL_RESULTS_MESSAGE) && !amIPresenter;
+ if (
+ !previousMessage
+ && message.sender !== Auth.userID
+ && (!message.id.startsWith(SYSTEM_CHAT_TYPE) || shouldAddPollResultMessage
+ || shouldAddPresentationExportMessage)
+ && !message.read
+ ) {
+ stateMessages.unreadTimeWindows.add(key);
+ }
+ });
+ } else {
+ if (groupMessage) {
+ if (groupMessage.sender === stateMessages.lastSender) {
+ const previousMessage = msg.timestamp <= getLoginTime();
+ const timeWindowKey = keyName + '-' + stateMessages.chatIndexes[keyName];
+ const read = previousMessage ? true : !!removedMessagesReadState[groupMessage.id];
+
+ messageGroups[timeWindowKey] = {
+ ...groupMessage,
+ lastTimestamp: msg.timestamp,
+ read,
+ content: [
+ ...groupMessage.content,
+ { id: msg.id, text: msg.message, time: msg.timestamp }
+ ],
+ };
+ if (!read && groupMessage.sender !== Auth.userID) {
+ stateMessages.unreadTimeWindows.add(timeWindowKey);
+ }
+ }
+ }
+ }
+
+ return state;
+}
+
+const reducer = (state, action) => {
+ switch (action.type) {
+ case ACTIONS.TEST: {
+ ChatLogger.debug(ACTIONS.TEST);
+ return {
+ ...state,
+ ...action.value,
+ };
+ }
+ case ACTIONS.ADDED: {
+ ChatLogger.debug(ACTIONS.ADDED);
+
+ const batchMsgs = action.value;
+ const closedChatsToOpen = new Set();
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
+ const newState = batchMsgs.reduce((acc, i) => {
+ const message = i;
+ const chatId = message.chatId;
+ const chatClosedTimestamp = currentClosedChats.find(closedChat => closedChat.chatId === chatId)?.timestamp;
+ if (
+ chatId !== PUBLIC_GROUP_CHAT_KEY
+ && chatClosedTimestamp
+ && message.timestamp > chatClosedTimestamp ) {
+ closedChatsToOpen.add(chatId)
+ }
+ return generateStateWithNewMessage(message, acc, action.messageType);
+ }, state);
+
+ if (closedChatsToOpen.size) {
+ const closedChats = currentClosedChats.filter(closedChat => !closedChatsToOpen.has(closedChat.chatId));
+ Storage.setItem(CLOSED_CHAT_LIST_KEY, closedChats);
+ }
+ // const newState = generateStateWithNewMessage(action.value, state);
+ return { ...newState };
+ }
+ case ACTIONS.CHANGED: {
+ return {
+ ...state,
+ ...action.value,
+ };
+ }
+ case ACTIONS.REMOVED: {
+ ChatLogger.debug(ACTIONS.REMOVED);
+ if (state[PUBLIC_GROUP_CHAT_KEY]) {
+ state[PUBLIC_GROUP_CHAT_KEY] = {
+ count: 0,
+ lastSender: '',
+ chatIndexes: {},
+ syncing: false,
+ preJoinMessages: {},
+ posJoinMessages: {},
+ unreadTimeWindows: new Set(),
+ unreadCount: 0,
+ };
+ }
+ return state;
+ }
+ case ACTIONS.LAST_READ_MESSAGE_TIMESTAMP_CHANGED: {
+ ChatLogger.debug(ACTIONS.LAST_READ_MESSAGE_TIMESTAMP_CHANGED);
+ const { timestamp, chatId } = action.value;
+ const newState = {
+ ...state,
+ };
+ const selectedChatId = chatId === PUBLIC_CHAT_KEY ? PUBLIC_GROUP_CHAT_KEY : chatId;
+ const chat = state[selectedChatId];
+ if (!chat) return state;
+ ['posJoinMessages', 'preJoinMessages', 'messageGroups'].forEach(messageGroupName => {
+ const messageGroup = chat[messageGroupName];
+ if (messageGroup) {
+ const timeWindowsids = Object.keys(messageGroup);
+ timeWindowsids.forEach(timeWindowId => {
+ const timeWindow = messageGroup[timeWindowId];
+ if (timeWindow) {
+ if (!timeWindow.read) {
+ if (timeWindow.lastTimestamp <= timestamp) {
+ newState[selectedChatId].unreadTimeWindows.delete(timeWindowId);
+
+ newState[selectedChatId][messageGroupName][timeWindowId] = {
+ ...timeWindow,
+ read: true,
+ };
+
+
+ newState[selectedChatId] = {
+ ...newState[selectedChatId],
+ };
+ newState[selectedChatId][messageGroupName] = {
+ ...newState[selectedChatId][messageGroupName],
+ };
+ newState[chatId === PUBLIC_CHAT_KEY ? PUBLIC_GROUP_CHAT_KEY : chatId][messageGroupName][timeWindowId] = {
+ ...newState[selectedChatId][messageGroupName][timeWindowId],
+ };
+ }
+ }
+ }
+ });
+ }
+ });
+ return newState;
+ }
+ case ACTIONS.INIT: {
+ ChatLogger.debug(ACTIONS.INIT);
+ const { chatId } = action;
+ const newState = { ...state };
+
+ if (!newState[chatId]) {
+ newState[chatId] = {
+ count: 0,
+ lastSender: '',
+ chatIndexes: {},
+ messageGroups: {},
+ unreadTimeWindows: new Set(),
+ unreadCount: 0,
+ };
+ }
+ return state;
+ }
+ case ACTIONS.SYNC_STATUS: {
+ ChatLogger.debug(ACTIONS.SYNC_STATUS);
+ const newState = { ...state };
+ newState[action.value.chatId].syncedPercent = action.value.percentage;
+ newState[action.value.chatId].syncing = action.value.percentage < 100 ? true : false;
+
+ return newState;
+ }
+ case ACTIONS.CLEAR_ALL: {
+ ChatLogger.debug(ACTIONS.CLEAR_ALL);
+ const newState = { ...state };
+ const chatIds = Object.keys(newState);
+ chatIds.forEach((chatId) => {
+ newState[chatId] = chatId === PUBLIC_GROUP_CHAT_KEY ?
+ {
+ count: 0,
+ lastSender: '',
+ chatIndexes: {},
+ preJoinMessages: {},
+ posJoinMessages: {},
+ syncing: false,
+ syncedPercent: 0,
+ unreadTimeWindows: new Set(),
+ unreadCount: 0,
+ }
+ :
+ {
+ count: 0,
+ lastSender: '',
+ chatIndexes: {},
+ messageGroups: {},
+ syncing: false,
+ syncedPercent: 0,
+ unreadTimeWindows: new Set(),
+ unreadCount: 0,
+ };
+ });
+ return newState;
+ }
+ // BBB don't remove individual messages, so when a message is removed it means the chat is cleared ( by admin, or for resync )
+ // considering it, we remove all messages from all chats
+ case ACTIONS.CLEAR_STREAM_MESSAGES: {
+ ChatLogger.debug(ACTIONS.CLEAR_STREAM_MESSAGES);
+ const newState = { ...state };
+ const chatIds = Object.keys(newState);
+ chatIds.forEach((chatId) => {
+ const chat = newState[chatId];
+ ['posJoinMessages', 'messageGroups'].forEach((group) => {
+ const messages = chat[group];
+ if (messages) {
+ const timeWindowIds = Object.keys(messages);
+ timeWindowIds.forEach((timeWindowId) => {
+ const timeWindow = messages[timeWindowId];
+ if (timeWindow.messageType === MESSAGE_TYPES.STREAM) {
+ chat.unreadTimeWindows.delete(timeWindowId);
+ removedMessagesReadState[newState[chatId][group][timeWindowId].id] = newState[chatId][group][timeWindowId].read;
+ delete newState[chatId][group][timeWindowId];
+ }
+ });
+ }
+ })
+ });
+
+ return newState;
+ }
+ default: {
+ throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
+ }
+ }
+};
+
+export const ChatContextProvider = (props) => {
+ const [chatContextState, chatContextDispatch] = useReducer(reducer, {});
+ ChatLogger.debug('dispatch', chatContextDispatch);
+ return (
+
+ {props.children}
+
+ );
+}
+
+
+export const ContextConsumer = Component => props => (
+
+ {contexts => }
+
+);
+
+export default {
+ ContextConsumer,
+ ChatContextProvider,
+}
diff --git a/src/2.7.12/imports/ui/components/components-data/group-chat-context/adapter.jsx b/src/2.7.12/imports/ui/components/components-data/group-chat-context/adapter.jsx
new file mode 100644
index 00000000..13644aae
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/group-chat-context/adapter.jsx
@@ -0,0 +1,25 @@
+import { useContext, useEffect } from 'react';
+import GroupChat from '/imports/api/group-chat';
+import { GroupChatContext, ACTIONS } from './context';
+
+const Adapter = () => {
+ const usingGroupChatContext = useContext(GroupChatContext);
+ const { dispatch } = usingGroupChatContext;
+
+ useEffect(() => {
+ const groupChatCursor = GroupChat.find({});
+
+ groupChatCursor.observe({
+ added: (obj) => {
+ dispatch({
+ type: ACTIONS.ADDED,
+ value: {
+ groupChat: obj,
+ },
+ });
+ },
+ });
+ }, []);
+};
+
+export default Adapter;
diff --git a/src/2.7.12/imports/ui/components/components-data/group-chat-context/context.jsx b/src/2.7.12/imports/ui/components/components-data/group-chat-context/context.jsx
new file mode 100644
index 00000000..7fe3f4e9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/group-chat-context/context.jsx
@@ -0,0 +1,76 @@
+import React, {
+ createContext,
+ useReducer,
+} from 'react';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+
+export const ACTIONS = {
+ TEST: 'test',
+ ADDED: 'added',
+ CHANGED: 'changed',
+ REMOVED: 'removed',
+};
+
+export const GroupChatContext = createContext();
+
+const reducer = (state, action) => {
+ switch (action.type) {
+ case ACTIONS.TEST: {
+ return {
+ ...state,
+ ...action.value,
+ };
+ }
+
+ case ACTIONS.ADDED:
+ case ACTIONS.CHANGED: {
+ ChatLogger.debug('GroupChatContextProvider::reducer::added', { ...action });
+ const { groupChat } = action.value;
+
+ const newState = {
+ ...state,
+ [groupChat.chatId]: {
+ ...groupChat,
+ },
+ };
+ return newState;
+ }
+
+ case ACTIONS.REMOVED: {
+ ChatLogger.debug('GroupChatContextProvider::reducer::removed', { ...action });
+
+ return state;
+ }
+ default: {
+ throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
+ }
+ }
+};
+
+export const GroupChatContextProvider = (props) => {
+ const [groupChatContextState, groupChatContextDispatch] = useReducer(reducer, {});
+ ChatLogger.debug('UsersContextProvider::groupChatContextState', groupChatContextState);
+ return (
+
+ {props.children}
+
+ );
+};
+
+export const GroupChatContextConsumer = Component => props => (
+
+ {contexts => }
+
+);
+
+export default {
+ GroupChatContextConsumer,
+ GroupChatContextProvider,
+};
diff --git a/src/2.7.12/imports/ui/components/components-data/users-context/adapter.jsx b/src/2.7.12/imports/ui/components/components-data/users-context/adapter.jsx
new file mode 100644
index 00000000..4953f2e7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/users-context/adapter.jsx
@@ -0,0 +1,84 @@
+import { useContext, useEffect } from 'react';
+import { CurrentUser } from '/imports/api/users';
+import Users from '/imports/api/users';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import { UsersContext, ACTIONS } from './context';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+
+const Adapter = () => {
+ const usingUsersContext = useContext(UsersContext);
+ const { dispatch } = usingUsersContext;
+
+ useEffect(() => {
+ const usersPersistentDataCursor = UsersPersistentData.find({}, { sort: { timestamp: 1 } });
+ usersPersistentDataCursor.observe({
+ added: (obj) => {
+ ChatLogger.debug('usersAdapter::observe::added_persistent_user', obj);
+ dispatch({
+ type: ACTIONS.ADDED_USER_PERSISTENT_DATA,
+ value: {
+ user: obj,
+ },
+ });
+ },
+ changed: (obj) => {
+ ChatLogger.debug('usersAdapter::observe::changed_persistent_user', obj);
+ dispatch({
+ type: ACTIONS.CHANGED_USER_PERSISTENT_DATA,
+ value: {
+ user: obj,
+ },
+ });
+ },
+ removed: (obj) => {
+ ChatLogger.debug('usersAdapter::observe::removed', obj);
+ dispatch({
+ type: ACTIONS.REMOVED,
+ value: {
+ user: obj,
+ },
+ });
+ },
+ });
+ }, []);
+
+ useEffect(() => {
+ const usersCursor = Users.find({}, { sort: { timestamp: 1 } });
+ const CurrentUserCursor = CurrentUser.find({});
+ usersCursor.observe({
+ added: (obj) => {
+ ChatLogger.debug('usersAdapter::observe::added', obj);
+ dispatch({
+ type: ACTIONS.ADDED,
+ value: {
+ user: obj,
+ },
+ });
+ },
+ changed: (obj) => {
+ dispatch({
+ type: ACTIONS.CHANGED,
+ value: {
+ user: obj,
+ },
+ });
+ },
+ });
+
+ CurrentUserCursor.observe({
+ added: (obj) => {
+ ChatLogger.debug('usersAdapter::observe::current-user::added', obj);
+ dispatch({
+ type: ACTIONS.ADDED,
+ value: {
+ user: obj,
+ },
+ });
+ },
+ });
+ }, []);
+
+ return null;
+};
+
+export default Adapter;
diff --git a/src/2.7.12/imports/ui/components/components-data/users-context/context.jsx b/src/2.7.12/imports/ui/components/components-data/users-context/context.jsx
new file mode 100644
index 00000000..b9722e2b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/users-context/context.jsx
@@ -0,0 +1,133 @@
+import React, {
+ createContext,
+ useReducer,
+} from 'react';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+
+export const ACTIONS = {
+ TEST: 'test',
+ ADDED: 'added',
+ CHANGED: 'changed',
+ REMOVED: 'removed',
+ ADDED_USER_PERSISTENT_DATA: 'added_user_persistent_data',
+ CHANGED_USER_PERSISTENT_DATA: 'changed_user_persistent_data',
+};
+
+export const UsersContext = createContext();
+
+const reducer = (state, action) => {
+ switch (action.type) {
+ case ACTIONS.TEST: {
+ return {
+ ...state,
+ ...action.value,
+ };
+ }
+
+ case ACTIONS.ADDED:
+ case ACTIONS.CHANGED: {
+ ChatLogger.debug('UsersContextProvider::reducer::added', { ...action });
+ const { user } = action.value;
+
+ const newState = { ...state };
+
+ if (!newState[user.meetingId]) {
+ newState[user.meetingId] = {};
+ }
+ newState[user.meetingId][user.userId] = {
+ ...user,
+ };
+ return newState;
+ }
+ case ACTIONS.REMOVED: {
+ ChatLogger.debug('UsersContextProvider::reducer::removed', { ...action });
+
+ const { user } = action.value;
+ const stateUser = state[user.meetingId][user.userId];
+ if (stateUser) {
+ const newState = { ...state };
+ newState[user.meetingId][user.userId] = {
+ ...stateUser,
+ loggedOut: true,
+ };
+
+ return newState;
+ }
+ return state;
+ }
+
+ // USER PERSISTENT DATA
+ case ACTIONS.ADDED_USER_PERSISTENT_DATA: {
+ const { user } = action.value;
+ if (state[user.meetingId] && state[user.meetingId][user.userId]) {
+ if (state[user.meetingId][user.userId].loggedOut) {
+ const newState = { ...state };
+ newState[user.meetingId][user.userId] = {
+ ...state[user.meetingId][user.userId],
+ loggedOut: false,
+ };
+
+ return newState;
+ }
+ return state;
+ }
+
+ const newState = { ...state };
+
+ if (!newState[user.meetingId]) {
+ newState[user.meetingId] = {};
+ }
+ newState[user.meetingId][user.userId] = {
+ ...user,
+ };
+ return newState;
+ }
+ case ACTIONS.CHANGED_USER_PERSISTENT_DATA: {
+ const { user } = action.value;
+ const stateUser = state[user.meetingId][user.userId];
+ if (stateUser) {
+ const newState = { ...state };
+ newState[user.meetingId][user.userId] = {
+ ...stateUser,
+ ...user,
+ };
+
+ return newState;
+ }
+ return state;
+ }
+ default: {
+ throw new Error(`Unexpected action: ${JSON.stringify(action)}`);
+ }
+ }
+};
+
+export const UsersContextProvider = (props) => {
+ const [usersContextState, usersContextDispatch] = useReducer(reducer, {});
+ ChatLogger.debug('UsersContextProvider::usersContextState', usersContextState);
+ return (
+
+ {props.children}
+
+ );
+};
+
+export const UsersContextConsumer = Component => props => (
+
+ {contexts => }
+
+);
+
+export const withUsersConsumer = Component => UsersContextConsumer(Component);
+
+export default {
+ UsersContextConsumer,
+ UsersContextProvider,
+};
diff --git a/src/2.7.12/imports/ui/components/components-data/users-context/service.js b/src/2.7.12/imports/ui/components/components-data/users-context/service.js
new file mode 100644
index 00000000..dc0512dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/components-data/users-context/service.js
@@ -0,0 +1,31 @@
+import {
+ useState, useContext, useRef, useEffect,
+} from 'react';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import { throttle } from '/imports/utils/throttle';
+
+const USER_JOIN_UPDATE_THROTTLE_TIME = 1000;
+
+export default function useContextUsers() {
+ const usingUsersContext = useContext(UsersContext);
+ const { users: contextUsers } = usingUsersContext;
+
+ const [users, setUsers] = useState(null);
+ const [isReady, setIsReady] = useState(true);
+
+ const throttledSetUsers = useRef(throttle(() => {
+ setUsers(contextUsers);
+ setIsReady(true);
+ },
+ USER_JOIN_UPDATE_THROTTLE_TIME, { trailing: true }));
+
+ useEffect(() => {
+ setIsReady(false);
+ throttledSetUsers.current();
+ }, [contextUsers]);
+
+ return {
+ users,
+ isReady,
+ };
+}
diff --git a/src/2.7.12/imports/ui/components/connection-status/button/component.jsx b/src/2.7.12/imports/ui/components/connection-status/button/component.jsx
new file mode 100644
index 00000000..21058847
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/button/component.jsx
@@ -0,0 +1,123 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import Button from '/imports/ui/components/common/button/component';
+import ConnectionStatusModalContainer from '/imports/ui/components/connection-status/modal/container';
+import ConnectionStatusService from '/imports/ui/components/connection-status/service';
+import Icon from '/imports/ui/components/connection-status/icon/component';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ label: {
+ id: 'app.connection-status.label',
+ description: 'Connection status button label',
+ },
+ description: {
+ id: 'app.connection-status.description',
+ description: 'Connection status button description',
+ },
+});
+
+class ConnectionStatusButton extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isModalOpen: false,
+ }
+ }
+
+ renderIcon(level = 'normal') {
+ return(
+
+
+
+ );
+ }
+
+ setModalIsOpen = (isOpen) => this.setState({ isModalOpen: isOpen });
+
+ renderModal(isModalOpen) {
+ return (
+ isModalOpen ?
+ : null
+ )
+ }
+
+ render() {
+ const {
+ intl,
+ connected,
+ } = this.props;
+ const { isModalOpen } = this.state;
+
+
+ if (!connected) {
+ return (
+
+ {}}
+ data-test="connectionStatusButton"
+ />
+ {this.renderModal(isModalOpen)}
+
+ );
+ }
+
+ const {
+ stats,
+ } = this.props;
+
+ let color;
+ switch (stats) {
+ case 'warning':
+ color = 'success';
+ break;
+ case 'danger':
+ color = 'warning';
+ ConnectionStatusService.notification('warning', intl);
+ break;
+ case 'critical':
+ color = 'danger';
+ ConnectionStatusService.notification('error', intl);
+ break;
+ default:
+ color = 'success';
+ }
+
+ const currentStatus = stats ? stats : 'normal';
+
+ return (
+
+ this.setState({isModalOpen: true})}
+ data-test="connectionStatusButton"
+ />
+ {this.renderModal(isModalOpen)}
+
+ );
+ }
+}
+
+export default injectIntl(ConnectionStatusButton);
diff --git a/src/2.7.12/imports/ui/components/connection-status/button/container.jsx b/src/2.7.12/imports/ui/components/connection-status/button/container.jsx
new file mode 100644
index 00000000..50b42475
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/button/container.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { Meteor } from 'meteor/meteor';
+import { withTracker } from 'meteor/react-meteor-data';
+import ConnectionStatusService from '../service';
+import ConnectionStatusButtonComponent from './component';
+
+const connectionStatusButtonContainer = props => ;
+
+export default withTracker(() => {
+ const { connected } = Meteor.status();
+
+ return {
+ connected,
+ stats: ConnectionStatusService.getStats(),
+ };
+})(connectionStatusButtonContainer);
diff --git a/src/2.7.12/imports/ui/components/connection-status/button/styles.js b/src/2.7.12/imports/ui/components/connection-status/button/styles.js
new file mode 100644
index 00000000..0b70eab9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/button/styles.js
@@ -0,0 +1,15 @@
+import styled from 'styled-components';
+
+const IconWrapper = styled.div`
+ width: 1.025rem;
+ height: 1.025rem;
+`;
+
+const ButtonWrapper = styled.div`
+ margin: 0 .5rem;
+`;
+
+export default {
+ IconWrapper,
+ ButtonWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/connection-status/icon/component.jsx b/src/2.7.12/imports/ui/components/connection-status/icon/component.jsx
new file mode 100644
index 00000000..851e7e7b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/icon/component.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import Styled from './styles';
+
+const STATS = {
+ critical: {
+ bars: 1,
+ },
+ danger: {
+ bars: 2,
+ },
+ warning: {
+ bars: 3,
+ },
+ normal: {
+ bars: 4,
+ },
+};
+
+const Icon = ({ level, grayscale }) => (
+ <>
+
+
+ = 2} />
+ = 3} />
+ = 4} />
+
+ >
+);
+
+export default Icon;
diff --git a/src/2.7.12/imports/ui/components/connection-status/icon/styles.js b/src/2.7.12/imports/ui/components/connection-status/icon/styles.js
new file mode 100644
index 00000000..81714a51
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/icon/styles.js
@@ -0,0 +1,86 @@
+import styled from 'styled-components';
+import {
+ colorWhite,
+ colorDanger,
+ colorWarning,
+ colorSuccess,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const SignalBars = styled.div`
+ align-items: flex-end;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ height: 100%;
+
+ ${({ grayscale }) => grayscale && `
+ & > div {
+ background-color: ${colorWhite};
+ }
+ `}
+
+ ${({ grayscale, level }) => !grayscale && level === 'critical' && `
+ & > div {
+ background-color: ${colorDanger};
+ }
+ `}
+
+ ${({ grayscale, level }) => !grayscale && level === 'danger' && `
+ & > div {
+ background-color: ${colorWarning};
+ }
+ `}
+
+ ${({ grayscale, level }) => !grayscale && level === 'warning' && `
+ & > div {
+ background-color: ${colorSuccess};
+ }
+ `}
+
+ ${({ grayscale, level }) => !grayscale && level === 'normal' && `
+ & > div {
+ background-color: ${colorWhite};
+ }
+ `}
+`;
+
+const Bar = styled.div`
+ width: 20%;
+ border-radius: .46875em;
+`;
+
+const FirstBar = styled(Bar)`
+ height: 25%;
+`;
+
+const SecondBar = styled(Bar)`
+ height: 50%;
+
+ ${({ active }) => !active && `
+ opacity: .5;
+ `}
+`;
+
+const ThirdBar = styled(Bar)`
+ height: 75%;
+
+ ${({ active }) => !active && `
+ opacity: .5;
+ `}
+`;
+
+const FourthBar = styled(Bar)`
+ height: 100%;
+
+ ${({ active }) => !active && `
+ opacity: .5;
+ `}
+`;
+
+export default {
+ SignalBars,
+ FirstBar,
+ SecondBar,
+ ThirdBar,
+ FourthBar,
+};
diff --git a/src/2.7.12/imports/ui/components/connection-status/modal/component.jsx b/src/2.7.12/imports/ui/components/connection-status/modal/component.jsx
new file mode 100644
index 00000000..5dfd87fc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/modal/component.jsx
@@ -0,0 +1,588 @@
+import React, { PureComponent } from 'react';
+import { FormattedTime, defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+import Icon from '/imports/ui/components/connection-status/icon/component';
+import Service from '../service';
+import Styled from './styles';
+import ConnectionStatusHelper from '../status-helper/container';
+
+const NETWORK_MONITORING_INTERVAL_MS = 2000;
+const MIN_TIMEOUT = 3000;
+
+const intlMessages = defineMessages({
+ ariaTitle: {
+ id: 'app.connection-status.ariaTitle',
+ description: 'Connection status aria title',
+ },
+ title: {
+ id: 'app.connection-status.title',
+ description: 'Connection status title',
+ },
+ description: {
+ id: 'app.connection-status.description',
+ description: 'Connection status description',
+ },
+ empty: {
+ id: 'app.connection-status.empty',
+ description: 'Connection status empty',
+ },
+ more: {
+ id: 'app.connection-status.more',
+ description: 'More about conectivity issues',
+ },
+ audioLabel: {
+ id: 'app.settings.audioTab.label',
+ description: 'Audio label',
+ },
+ videoLabel: {
+ id: 'app.settings.videoTab.label',
+ description: 'Video label',
+ },
+ copy: {
+ id: 'app.connection-status.copy',
+ description: 'Copy network data',
+ },
+ copied: {
+ id: 'app.connection-status.copied',
+ description: 'Copied network data',
+ },
+ offline: {
+ id: 'app.connection-status.offline',
+ description: 'Offline user',
+ },
+ dataSaving: {
+ id: 'app.settings.dataSavingTab.description',
+ description: 'Description of data saving',
+ },
+ webcam: {
+ id: 'app.settings.dataSavingTab.webcam',
+ description: 'Webcam data saving switch',
+ },
+ screenshare: {
+ id: 'app.settings.dataSavingTab.screenShare',
+ description: 'Screenshare data saving switch',
+ },
+ on: {
+ id: 'app.switch.onLabel',
+ description: 'label for toggle switch on state',
+ },
+ off: {
+ id: 'app.switch.offLabel',
+ description: 'label for toggle switch off state',
+ },
+ no: {
+ id: 'app.connection-status.no',
+ description: 'No to is using turn',
+ },
+ yes: {
+ id: 'app.connection-status.yes',
+ description: 'Yes to is using turn',
+ },
+ usingTurn: {
+ id: 'app.connection-status.usingTurn',
+ description: 'User is using turn server',
+ },
+ jitter: {
+ id: 'app.connection-status.jitter',
+ description: 'Jitter buffer in ms',
+ },
+ lostPackets: {
+ id: 'app.connection-status.lostPackets',
+ description: 'Number of lost packets',
+ },
+ audioUploadRate: {
+ id: 'app.connection-status.audioUploadRate',
+ description: 'Label for audio current upload rate',
+ },
+ audioDownloadRate: {
+ id: 'app.connection-status.audioDownloadRate',
+ description: 'Label for audio current download rate',
+ },
+ videoUploadRate: {
+ id: 'app.connection-status.videoUploadRate',
+ description: 'Label for video current upload rate',
+ },
+ videoDownloadRate: {
+ id: 'app.connection-status.videoDownloadRate',
+ description: 'Label for video current download rate',
+ },
+ connectionStats: {
+ id: 'app.connection-status.connectionStats',
+ description: 'Label for Connection Stats tab',
+ },
+ myLogs: {
+ id: 'app.connection-status.myLogs',
+ description: 'Label for My Logs tab',
+ },
+ sessionLogs: {
+ id: 'app.connection-status.sessionLogs',
+ description: 'Label for Session Logs tab',
+ },
+ next: {
+ id: 'app.connection-status.next',
+ description: 'Label for the next page of the connection stats tab',
+ },
+ prev: {
+ id: 'app.connection-status.prev',
+ description: 'Label for the previous page of the connection stats tab',
+ },
+ clientNotResponding: {
+ id: 'app.connection-status.clientNotRespondingWarning',
+ description: 'Text for Client not responding warning',
+ },
+});
+
+const propTypes = {
+ setModalIsOpen: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+const isConnectionStatusEmpty = (connectionStatus) => {
+ // Check if it's defined
+ if (!connectionStatus) return true;
+
+ // Check if it's an array
+ if (!Array.isArray(connectionStatus)) return true;
+
+ // Check if is empty
+ if (connectionStatus.length === 0) return true;
+
+ return false;
+};
+
+class ConnectionStatusComponent extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ const { intl } = this.props;
+
+ this.help = Service.getHelp();
+ this.state = {
+ selectedTab: 0,
+ hasNetworkData: false,
+ copyButtonText: intl.formatMessage(intlMessages.copy),
+ networkData: {
+ user: {
+
+ },
+ audio: {
+ audioCurrentUploadRate: 0,
+ audioCurrentDownloadRate: 0,
+ jitter: 0,
+ packetsLost: 0,
+ transportStats: {},
+ },
+ video: {
+ videoCurrentUploadRate: 0,
+ videoCurrentDownloadRate: 0,
+ },
+ },
+ };
+ this.setButtonMessage = this.setButtonMessage.bind(this);
+ this.rateInterval = null;
+ this.audioUploadLabel = intl.formatMessage(intlMessages.audioUploadRate);
+ this.audioDownloadLabel = intl.formatMessage(intlMessages.audioDownloadRate);
+ this.videoUploadLabel = intl.formatMessage(intlMessages.videoUploadRate);
+ this.videoDownloadLabel = intl.formatMessage(intlMessages.videoDownloadRate);
+ this.handleSelectTab = this.handleSelectTab.bind(this);
+ }
+
+ async componentDidMount() {
+ this.startMonitoringNetwork();
+ }
+
+ componentWillUnmount() {
+ Meteor.clearInterval(this.rateInterval);
+ }
+
+ handleSelectTab(tab) {
+ this.setState({
+ selectedTab: tab,
+ });
+ }
+
+ setButtonMessage(msg) {
+ this.setState({
+ copyButtonText: msg,
+ });
+ }
+
+ /**
+ * Start monitoring the network data.
+ * @return {Promise} A Promise that resolves when process started.
+ */
+ async startMonitoringNetwork() {
+ let previousData = await Service.getNetworkData();
+ this.rateInterval = Meteor.setInterval(async () => {
+ const data = await Service.getNetworkData();
+
+ const {
+ outbound: audioCurrentUploadRate,
+ inbound: audioCurrentDownloadRate,
+ } = Service.calculateBitsPerSecond(data.audio, previousData.audio);
+
+ const inboundRtp = Service.getDataType(data.audio, 'inbound-rtp')[0];
+
+ const jitter = inboundRtp
+ ? inboundRtp.jitterBufferAverage
+ : 0;
+
+ const packetsLost = inboundRtp
+ ? inboundRtp.packetsLost
+ : 0;
+
+ const audio = {
+ audioCurrentUploadRate,
+ audioCurrentDownloadRate,
+ jitter,
+ packetsLost,
+ transportStats: data.audio.transportStats,
+ };
+
+ const {
+ outbound: videoCurrentUploadRate,
+ inbound: videoCurrentDownloadRate,
+ } = Service.calculateBitsPerSecondFromMultipleData(data.video,
+ previousData.video);
+
+ const video = {
+ videoCurrentUploadRate,
+ videoCurrentDownloadRate,
+ };
+
+ const { user } = data;
+
+ const networkData = {
+ user,
+ audio,
+ video,
+ };
+
+ previousData = data;
+ this.setState({
+ networkData,
+ hasNetworkData: true,
+ });
+ }, NETWORK_MONITORING_INTERVAL_MS);
+ }
+
+ /**
+ * Copy network data to clipboard
+ * @return {Promise} A Promise that is resolved after data is copied.
+ *
+ *
+ */
+ async copyNetworkData() {
+ const { intl } = this.props;
+ const {
+ networkData,
+ hasNetworkData,
+ } = this.state;
+
+ if (!hasNetworkData) return;
+
+ this.setButtonMessage(intl.formatMessage(intlMessages.copied));
+
+ const data = JSON.stringify(networkData, null, 2);
+
+ await navigator.clipboard.writeText(data);
+
+ this.copyNetworkDataTimeout = setTimeout(() => {
+ this.setButtonMessage(intl.formatMessage(intlMessages.copy));
+ }, MIN_TIMEOUT);
+ }
+
+ renderEmpty() {
+ const { intl } = this.props;
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages.empty)}
+
+
+
+
+ );
+ }
+
+ renderConnections() {
+ const {
+ connectionStatus,
+ intl,
+ } = this.props;
+
+ const { selectedTab } = this.state;
+
+ if (isConnectionStatusEmpty(connectionStatus)) return this.renderEmpty();
+
+ let connections = connectionStatus;
+ if (selectedTab === 1) {
+ connections = connections.filter(conn => conn.you);
+ if (isConnectionStatusEmpty(connections)) return this.renderEmpty();
+ }
+
+ return connections.map((conn, index) => {
+ const dateTime = new Date(conn.timestamp);
+ return (
+
+
+
+
+ {conn.name.toLowerCase().slice(0, 2)}
+
+
+
+
+
+ {conn.name}
+ {conn.offline ? ` (${intl.formatMessage(intlMessages.offline)})` : null}
+
+
+
+
+
+
+
+ { conn.notResponding && !conn.offline
+ ? (
+
+ {intl.formatMessage(intlMessages.clientNotResponding)}
+
+ ) : null }
+
+
+
+ { conn.timestamp ?
+
+
+
+ : null
+ }
+
+
+
+ );
+ });
+ }
+
+ /**
+ * Render network data , containing information abount current upload and
+ * download rates
+ * @return {Object} The component to be renderized.
+ */
+ renderNetworkData() {
+ const { enableNetworkStats } = Meteor.settings.public.app;
+
+ if (!enableNetworkStats) {
+ return null;
+ }
+
+ const {
+ audioUploadLabel,
+ audioDownloadLabel,
+ videoUploadLabel,
+ videoDownloadLabel,
+ } = this;
+
+ const { intl, setModalIsOpen } = this.props;
+
+ const { networkData } = this.state;
+
+ const {
+ audioCurrentUploadRate,
+ audioCurrentDownloadRate,
+ jitter,
+ packetsLost,
+ transportStats,
+ } = networkData.audio;
+
+ const {
+ videoCurrentUploadRate,
+ videoCurrentDownloadRate,
+ } = networkData.video;
+
+ let isUsingTurn = '--';
+
+ if (transportStats) {
+ switch (transportStats.isUsingTurn) {
+ case true:
+ isUsingTurn = intl.formatMessage(intlMessages.yes);
+ break;
+ case false:
+ isUsingTurn = intl.formatMessage(intlMessages.no);
+ break;
+ default:
+ break;
+ }
+ }
+
+ return (
+
+
+
+ setModalIsOpen(false)} />
+
+
+
+
+
+ {`${audioUploadLabel}`}
+ {`${audioCurrentUploadRate}k ↑`}
+
+
+ {`${videoUploadLabel}`}
+ {`${videoCurrentUploadRate}k ↑`}
+
+
+ {`${intl.formatMessage(intlMessages.jitter)}`}
+ {`${jitter} ms`}
+
+
+ {`${intl.formatMessage(intlMessages.usingTurn)}`}
+ {`${isUsingTurn}`}
+
+
+
+
+
+ {`${audioDownloadLabel}`}
+ {`${audioCurrentDownloadRate}k ↓`}
+
+
+ {`${videoDownloadLabel}`}
+ {`${videoCurrentDownloadRate}k ↓`}
+
+
+ {`${intl.formatMessage(intlMessages.lostPackets)}`}
+ {`${packetsLost}`}
+
+
+ Content Hidden
+ 0
+
+
+
+
+ );
+ }
+
+ /**
+ * Renders the clipboard's copy button, for network stats.
+ * @return {Object} - The component to be renderized
+ */
+ renderCopyDataButton() {
+ const { enableCopyNetworkStatsButton } = Meteor.settings.public.app;
+
+ if (!enableCopyNetworkStatsButton) {
+ return null;
+ }
+
+ const { hasNetworkData, copyButtonText } = this.state;
+ return (
+
+
+ {copyButtonText}
+
+
+ );
+ }
+
+ render() {
+ const {
+ setModalIsOpen,
+ intl,
+ isModalOpen,
+ } = this.props;
+
+ const { selectedTab } = this.state;
+
+ return (
+ setModalIsOpen(false)}
+ setIsOpen={setModalIsOpen}
+ hideBorder
+ isOpen={isModalOpen}
+ contentLabel={intl.formatMessage(intlMessages.ariaTitle)}
+ data-test="connectionStatusModal"
+ >
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+ {intl.formatMessage(intlMessages.myLogs)}
+
+ {Service.isModerator()
+ && (
+
+ {intl.formatMessage(intlMessages.sessionLogs)}
+
+ )
+ }
+
+
+
+ {this.renderNetworkData()}
+ {this.renderCopyDataButton()}
+
+
+
+ {this.renderConnections()}
+
+ {Service.isModerator()
+ && (
+
+ {this.renderConnections()}
+
+ )
+ }
+
+
+
+ );
+ }
+}
+
+ConnectionStatusComponent.propTypes = propTypes;
+
+export default injectIntl(ConnectionStatusComponent);
diff --git a/src/2.7.12/imports/ui/components/connection-status/modal/container.jsx b/src/2.7.12/imports/ui/components/connection-status/modal/container.jsx
new file mode 100644
index 00000000..b4559ac7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/modal/container.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ConnectionStatusService from '../service';
+import ConnectionStatusComponent from './component';
+
+const connectionStatusContainer = props => ;
+
+export default withTracker(() => ({
+ connectionStatus: ConnectionStatusService.getConnectionStatus(),
+}))(connectionStatusContainer);
diff --git a/src/2.7.12/imports/ui/components/connection-status/modal/styles.js b/src/2.7.12/imports/ui/components/connection-status/modal/styles.js
new file mode 100644
index 00000000..b921df3d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/modal/styles.js
@@ -0,0 +1,417 @@
+import styled from 'styled-components';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import {
+ colorOffWhite,
+ colorGrayDark,
+ colorGrayLightest,
+ colorPrimary,
+ colorWhite,
+ btnPrimaryActiveBg,
+ colorDanger,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ smPaddingX,
+ smPaddingY,
+ mdPaddingY,
+ lgPaddingY,
+ titlePositionLeft,
+ mdPaddingX,
+ borderSizeLarge,
+ jumboPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ fontSizeSmall,
+ fontSizeXL,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ hasPhoneDimentions,
+ mediumDown,
+ hasPhoneWidth,
+ smallOnly,
+} from '/imports/ui/stylesheets/styled-components/breakpoints';
+import {
+ ScrollboxVertical,
+} from '/imports/ui/stylesheets/styled-components/scrollable';
+import {
+ Tab, Tabs, TabList, TabPanel,
+} from 'react-tabs';
+
+const Item = styled.li`
+ display: flex;
+ width: 100%;
+ height: 4rem;
+ border-bottom: 1px solid ${colorGrayLightest};
+
+ ${({ last }) => last && `
+ border: none;
+ `}
+`;
+
+const Left = styled.div`
+ display: flex;
+ width: 100%;
+ height: 100%;
+`;
+
+const Name = styled.div`
+ display: flex;
+ width: 27.5%;
+ height: 100%;
+ align-items: center;
+ justify-content: flex-start;
+
+ @media ${hasPhoneDimentions} {
+ width: 100%;
+ }
+`;
+
+const FullName = styled(Name)`
+ width: 100%;
+`;
+
+const ClientNotRespondingText = styled.div`
+ display: flex;
+ width: 27.5%;
+ height: 100%;
+ align-items: center;
+ justify-content: flex-start;
+ color: ${colorDanger};
+
+ @media ${hasPhoneDimentions} {
+ width: 100%;
+ }
+`;
+
+
+const Text = styled.div`
+ padding-left: .5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ ${({ offline }) => offline && `
+ font-style: italic;
+ `}
+
+ [dir="rtl"] & {
+ padding: 0;
+ padding-right: .5rem;
+ }
+`;
+
+const Avatar = styled.div`
+ display: flex;
+ height: 100%;
+ justify-content: center;
+ align-items: center;
+`;
+
+const Icon = styled.div`
+ width: 2.05rem;
+ height: 2.05rem;
+`;
+
+const Right = styled.div`
+ display: flex;
+ width: 5rem;
+ height: 100%;
+`;
+
+const Time = styled.div`
+ display: flex;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ justify-content: flex-end;
+`;
+
+const NetworkDataContainer = styled(ScrollboxVertical)`
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-wrap: nowrap;
+ overflow: auto;
+ scroll-snap-type: x mandatory;
+ padding-bottom: 1.25rem;
+
+ &:focus {
+ outline: none;
+
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0,0,0,.5);
+ }
+ }
+
+ @media ${mediumDown} {
+ justify-content: space-between;
+ }
+`;
+
+const NetworkData = styled.div`
+ font-size: ${fontSizeSmall};
+
+ ${({ invisible }) => invisible && `
+ visibility: hidden;
+ `}
+
+ & :first-child {
+ font-weight: 600;
+ }
+`;
+
+const CopyContainer = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ border: none;
+ border-top: 1px solid ${colorOffWhite};
+ padding: ${jumboPaddingY} 0 0;
+`;
+
+const ConnectionStatusModal = styled(ModalSimple)`
+ padding: 1rem;
+ height: 28rem;
+
+`;
+
+const Container = styled.div`
+ padding: 0 calc(${mdPaddingX} / 2 + ${borderSizeLarge});
+`;
+
+const Header = styled.div`
+ margin: 0;
+ padding: 0;
+ border: none;
+ line-height: ${titlePositionLeft};
+ margin-bottom: ${lgPaddingY};
+`;
+
+const Title = styled.h2`
+ color: ${colorGrayDark};
+ font-weight: 500;
+ font-size: ${fontSizeXL};
+ text-align: left;
+ margin: 0;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0;
+`;
+
+const Wrapper = styled.div`
+ display: block;
+ width: 100%;
+ max-height: 16rem;
+`;
+
+const Status = styled.div`
+ display: flex;
+ width: 6rem;
+ height: 100%;
+ justify-content: center;
+ align-items: center;
+`;
+
+const Copy = styled.span`
+ cursor: pointer;
+ color: ${colorPrimary};
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ ${({ disabled }) => disabled && `
+ cursor: not-allowed !important;
+ `}
+`;
+
+const HelperWrapper = styled.div`
+ min-width: 12.5rem;
+ height: 100%;
+
+ @media ${mediumDown} {
+ flex: none;
+ width: 100%;
+ scroll-snap-align: start;
+ display: flex;
+ justify-content: center;
+ }
+`;
+
+const Helper = styled.div`
+ width: 12.5rem;
+ height: 100%;
+ border-radius: .5rem;
+ background-color: ${colorOffWhite};
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: .5rem;
+`;
+
+const NetworkDataContent = styled.div`
+ margin: 0;
+ display: flex;
+ justify-content: space-around;
+ flex-grow: 1;
+
+ @media ${mediumDown} {
+ flex: none;
+ width: 100%;
+ scroll-snap-align: start;
+ }
+`;
+
+const DataColumn = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+
+ @media ${hasPhoneWidth} {
+ flex-grow: 1;
+ }
+`;
+
+const ConnectionTabs = styled(Tabs)`
+ display: flex;
+ flex-flow: column;
+ justify-content: flex-start;
+
+ @media ${smallOnly} {
+ width: 100%;
+ flex-flow: column;
+ }
+`;
+
+const ConnectionTabList = styled(TabList)`
+ display: flex;
+ flex-flow: row;
+ margin: 0;
+ margin-bottom: .5rem;
+ border: none;
+ padding: 0;
+ width: calc(100% / 3);
+
+ @media ${smallOnly} {
+ width: 100%;
+ flex-flow: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+`;
+
+const ConnectionTabPanel = styled(TabPanel)`
+ display: none;
+ margin: 0 0 0 1rem;
+ height: 13rem;
+
+ [dir="rtl"] & {
+ margin: 0 1rem 0 0;
+ }
+
+ &.is-selected {
+ display: flex;
+ flex-flow: column;
+ }
+
+ & ul {
+ padding: 0;
+ }
+
+ @media ${smallOnly} {
+ width: 100%;
+ margin: 0;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+`;
+
+const ConnectionTabSelector = styled(Tab)`
+ display: flex;
+ flex-flow: row;
+ font-size: 0.9rem;
+ flex: 0 0 auto;
+ justify-content: flex-start;
+ border: none !important;
+ padding: ${mdPaddingY} ${mdPaddingX};
+
+ border-radius: .2rem;
+ cursor: pointer;
+ margin-bottom: ${smPaddingY};
+ align-items: center;
+ flex-grow: 0;
+ min-width: 0;
+
+ & > span {
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ @media ${smallOnly} {
+ max-width: 100%;
+ margin: 0 ${smPaddingX} 0 0;
+ & > i {
+ display: none;
+ }
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+ }
+
+ span {
+ border-bottom: 2px solid ${colorWhite};
+ }
+
+ &.is-selected {
+ border: none;
+ color: ${colorPrimary};
+
+ span {
+ border-bottom: 2px solid ${btnPrimaryActiveBg};
+ }
+ }
+`;
+
+export default {
+ Item,
+ Left,
+ Name,
+ Text,
+ Avatar,
+ Icon,
+ Right,
+ Time,
+ NetworkDataContainer,
+ NetworkData,
+ CopyContainer,
+ ConnectionStatusModal,
+ ClientNotRespondingText,
+ Container,
+ Header,
+ Title,
+ Content,
+ Wrapper,
+ Status,
+ Copy,
+ Helper,
+ NetworkDataContent,
+ FullName,
+ DataColumn,
+ HelperWrapper,
+ ConnectionTabs,
+ ConnectionTabList,
+ ConnectionTabSelector,
+ ConnectionTabPanel,
+};
diff --git a/src/2.7.12/imports/ui/components/connection-status/service.js b/src/2.7.12/imports/ui/components/connection-status/service.js
new file mode 100644
index 00000000..bd6ffa4d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/service.js
@@ -0,0 +1,561 @@
+import { defineMessages } from 'react-intl';
+import ConnectionStatus from '/imports/api/connection-status';
+import Users from '/imports/api/users';
+import UsersPersistentData from '/imports/api/users-persistent-data';
+import Auth from '/imports/ui/services/auth';
+import { Session } from 'meteor/session';
+import { notify } from '/imports/ui/services/notification';
+import { makeCall } from '/imports/ui/services/api';
+import AudioService from '/imports/ui/components/audio/service';
+import VideoService from '/imports/ui/components/video-provider/service';
+import ScreenshareService from '/imports/ui/components/screenshare/service';
+
+const STATS = Meteor.settings.public.stats;
+const NOTIFICATION = STATS.notification;
+const STATS_INTERVAL = STATS.interval;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const intlMessages = defineMessages({
+ saved: {
+ id: 'app.settings.save-notification.label',
+ description: 'Label shown in toast when data savings are saved',
+ },
+ notification: {
+ id: 'app.connection-status.notification',
+ description: 'Label shown in toast when connection loss is detected',
+ },
+});
+
+let lastLevel = -1;
+let lastRtt = null;
+const levelDep = new Tracker.Dependency();
+
+let statsTimeout = null;
+
+const URL_REGEX = new RegExp(/^(http|https):\/\/[^ "]+$/);
+const getHelp = () => {
+ if (URL_REGEX.test(STATS.help)) return STATS.help;
+
+ return null;
+};
+
+const getStats = () => {
+ levelDep.depend();
+ return STATS.level[lastLevel];
+};
+
+const setStats = (level = -1, type = 'recovery', value = {}) => {
+ if (lastLevel !== level) {
+ lastLevel = level;
+ levelDep.changed();
+ }
+ addConnectionStatus(level, type, value);
+};
+
+const handleAudioStatsEvent = (event) => {
+ const { detail } = event;
+ if (detail) {
+ const { loss, jitter } = detail;
+ let active = false;
+ // From higher to lower
+ for (let i = STATS.level.length - 1; i >= 0; i--) {
+ if (loss >= STATS.loss[i] || jitter >= STATS.jitter[i]) {
+ active = true;
+ setStats(i, 'audio', { loss, jitter });
+ break;
+ }
+ }
+
+ if (active) startStatsTimeout();
+ }
+};
+
+const handleSocketStatsEvent = (event) => {
+ const { detail } = event;
+ if (detail) {
+ const { rtt } = detail;
+ let active = false;
+ let level = -1;
+ // From higher to lower
+ for (let i = STATS.level.length - 1; i >= 0; i--) {
+ if (rtt >= STATS.rtt[i]) {
+ active = true;
+ level = i;
+ break;
+ }
+ }
+
+ setStats(level, 'socket', { rtt });
+
+ if (active) startStatsTimeout();
+ }
+};
+
+const startStatsTimeout = () => {
+ if (statsTimeout !== null) clearTimeout(statsTimeout);
+
+ statsTimeout = setTimeout(() => {
+ setStats(-1, 'recovery', {});
+ }, STATS.timeout);
+};
+
+const addConnectionStatus = (level, type, value) => {
+ const status = level !== -1 ? STATS.level[level] : 'normal';
+
+ makeCall('addConnectionStatus', status, type, value);
+};
+
+let rttCalcStartedAt = 0;
+
+const fetchRoundTripTime = () => {
+ // if client didn't receive response from last "voidConnection"
+ // calculate the rtt from last call time and notify user of connection loss
+ if (rttCalcStartedAt !== 0) {
+ const tf = Date.now();
+ const rtt = tf - rttCalcStartedAt;
+
+ if (rtt > STATS.rtt[STATS.rtt.length - 1]) {
+ const event = new CustomEvent('socketstats', { detail: { rtt } });
+ window.dispatchEvent(event);
+ }
+ }
+
+ const t0 = Date.now();
+ rttCalcStartedAt = t0;
+
+ makeCall('voidConnection', lastRtt).then(() => {
+ const tf = Date.now();
+ const rtt = tf - t0;
+ const event = new CustomEvent('socketstats', { detail: { rtt } });
+ window.dispatchEvent(event);
+ lastRtt = rtt;
+
+ rttCalcStartedAt = 0;
+ });
+};
+
+const sortLevel = (a, b) => {
+ const indexOfA = STATS.level.indexOf(a.level);
+ const indexOfB = STATS.level.indexOf(b.level);
+
+ if (indexOfA < indexOfB) return 1;
+ if (indexOfA === indexOfB) return 0;
+ if (indexOfA > indexOfB) return -1;
+};
+
+const sortOffline = (a, b) => {
+ if (a.offline && !b.offline) return 1;
+ if (a.offline === b.offline) return 0;
+ if (!a.offline && b.offline) return -1;
+};
+
+const getConnectionStatus = () => {
+ const selector = {
+ meetingId: Auth.meetingID,
+ $or: [
+ { status: { $exists: true } },
+ { clientNotResponding: true },
+ ],
+ };
+
+ if (!isModerator()) {
+ selector.userId = Auth.userID;
+ }
+
+ const connectionStatus = ConnectionStatus.find(selector).fetch().map((userStatus) => {
+ const {
+ userId,
+ status,
+ statusUpdatedAt,
+ clientNotResponding,
+ } = userStatus;
+
+ return {
+ userId,
+ status,
+ statusUpdatedAt,
+ clientNotResponding,
+ };
+ });
+
+ return UsersPersistentData.find(
+ { meetingId: Auth.meetingID },
+ {
+ fields:
+ {
+ userId: 1,
+ name: 1,
+ role: 1,
+ avatar: 1,
+ color: 1,
+ loggedOut: 1,
+ },
+ },
+ ).fetch().reduce((result, user) => {
+ const {
+ userId,
+ name,
+ role,
+ avatar,
+ color,
+ loggedOut,
+ } = user;
+
+ const userStatus = connectionStatus.find((userConnStatus) => userConnStatus.userId === userId);
+
+ if (userStatus) {
+ if (userStatus.status || (!loggedOut && userStatus.clientNotResponding)) {
+ result.push({
+ userId,
+ name,
+ avatar,
+ offline: loggedOut,
+ notResponding: userStatus.clientNotResponding,
+ you: Auth.userID === userId,
+ moderator: role === ROLE_MODERATOR,
+ color,
+ status: userStatus.clientNotResponding ? 'critical' : userStatus.status,
+ timestamp: userStatus.statusUpdatedAt,
+ });
+ }
+ }
+
+ return result;
+ }, []).sort(sortLevel).sort(sortOffline);
+};
+
+const isEnabled = () => STATS.enabled;
+
+let roundTripTimeInterval = null;
+
+const startRoundTripTime = () => {
+ if (!isEnabled()) return;
+
+ stopRoundTripTime();
+
+ roundTripTimeInterval = setInterval(fetchRoundTripTime, STATS_INTERVAL);
+};
+
+const stopRoundTripTime = () => {
+ if (roundTripTimeInterval) {
+ clearInterval(roundTripTimeInterval);
+ }
+};
+
+const isModerator = () => {
+ const user = Users.findOne(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ },
+ { fields: { role: 1 } },
+ );
+
+ if (user && user.role === ROLE_MODERATOR) {
+ return true;
+ }
+
+ return false;
+};
+
+if (STATS.enabled) {
+ window.addEventListener('audiostats', handleAudioStatsEvent);
+ window.addEventListener('socketstats', handleSocketStatsEvent);
+}
+
+const getNotified = () => {
+ const notified = Session.get('connectionStatusNotified');
+
+ // Since notified can be undefined we need a boolean verification
+ return notified === true;
+};
+
+const notification = (level, intl) => {
+ if (!NOTIFICATION[level]) return null;
+
+ // Avoid toast spamming
+ const notified = getNotified();
+ if (notified) {
+ return null;
+ }
+ Session.set('connectionStatusNotified', true);
+
+
+ if (intl) notify(intl.formatMessage(intlMessages.notification), level, 'warning');
+};
+
+/**
+ * Calculates the jitter buffer average.
+ * For more information see:
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats-jitterbufferdelay
+ * @param {Object} inboundRtpData The RTCInboundRtpStreamStats object retrieved
+ * in getStats() call.
+ * @returns The jitter buffer average in ms
+ */
+const calculateJitterBufferAverage = (inboundRtpData) => {
+ if (!inboundRtpData) return 0;
+
+ const {
+ jitterBufferDelay,
+ jitterBufferEmittedCount,
+ } = inboundRtpData;
+
+ if (!jitterBufferDelay || !jitterBufferEmittedCount) return '--';
+
+ return Math.round((jitterBufferDelay / jitterBufferEmittedCount) * 1000);
+};
+
+/**
+ * Given the data returned from getStats(), returns an array containing all the
+ * the stats of the given type.
+ * For more information see:
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
+ * and
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsType
+ * @param {Object} data - RTCStatsReport object returned from getStats() API
+ * @param {String} type - The string type corresponding to RTCStatsType object
+ * @returns {Array[Object]} An array containing all occurrences of the given
+ * type in the data Object.
+ */
+const getDataType = (data, type) => {
+ if (!data || typeof data !== 'object' || !type) return [];
+
+ return Object.values(data).filter((stat) => stat.type === type);
+};
+
+/**
+ * Returns a new Object containing extra parameters calculated from inbound
+ * data. The input data is also appended in the returned Object.
+ * @param {Object} currentData - The object returned from getStats / service's
+ * getNetworkData()
+ * @returns {Object} the currentData object with the extra inbound network
+ * added to it.
+ */
+const addExtraInboundNetworkParameters = (data) => {
+ if (!data) return data;
+
+ const inboundRtpData = getDataType(data, 'inbound-rtp')[0];
+
+ if (!inboundRtpData) return data;
+
+ const extraParameters = {
+ jitterBufferAverage: calculateJitterBufferAverage(inboundRtpData),
+ packetsLost: inboundRtpData.packetsLost,
+ };
+
+ return Object.assign(inboundRtpData, extraParameters);
+};
+
+/**
+ * Retrieves the inbound and outbound data using WebRTC getStats API, for audio.
+ * @returns An Object with format (property:type) :
+ * {
+ * transportStats: Object,
+ * inbound-rtp: RTCInboundRtpStreamStats,
+ * outbound-rtp: RTCOutboundRtpStreamStats,
+ * }
+ * For more information see:
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats
+ * and
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcoutboundrtpstreamstats
+ */
+const getAudioData = async () => {
+ const data = await AudioService.getStats();
+
+ if (!data) return {};
+
+ addExtraInboundNetworkParameters(data);
+
+ return data;
+};
+
+/**
+ * Retrieves the inbound and outbound data using WebRTC getStats API, for video.
+ * The video stats contains the stats about all video peers (cameras) and
+ * for screenshare peer appended into one single object, containing the id
+ * of the peers with it's stats information.
+ * @returns An Object containing video data for all video peers and screenshare
+ * peer
+ */
+const getVideoData = async () => {
+ const camerasData = await VideoService.getStats() || {};
+
+ const screenshareData = await ScreenshareService.getStats() || {};
+
+ return {
+ ...camerasData,
+ ...screenshareData,
+ };
+};
+
+/**
+ * Get the user, audio and video data from current active streams.
+ * For audio, this will get information about the mic/listen-only stream.
+ * @returns An Object containing all this data.
+ */
+const getNetworkData = async () => {
+ const audio = await getAudioData();
+
+ const video = await getVideoData();
+
+ const user = {
+ time: new Date(),
+ username: Auth.username,
+ meeting_name: Auth.confname,
+ meeting_id: Auth.meetingID,
+ connection_id: Auth.connectionID,
+ user_id: Auth.userID,
+ extern_user_id: Auth.externUserID,
+ };
+
+ const fullData = {
+ user,
+ audio,
+ video,
+ };
+
+ return fullData;
+};
+
+/**
+ * Calculates both upload and download rates using data retreived from getStats
+ * API. For upload (outbound-rtp) we use both bytesSent and timestamp fields.
+ * byteSent field contains the number of octets sent at the given timestamp,
+ * more information can be found in:
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcsentrtpstreamstats-bytessent
+ *
+ * timestamp is given in millisseconds, more information can be found in:
+ * https://www.w3.org/TR/webrtc-stats/#webidl-1049090475
+ * @param {Object} currentData - The object returned from getStats / service's
+ * getNetworkData()
+ * @param {Object} previousData - The same object as above, but representing
+ * a data collected in past (previous call of
+ * service's getNetworkData())
+ * @returns An object of numbers, containing both outbound (upload) and inbound
+ * (download) rates (kbps).
+ */
+const calculateBitsPerSecond = (currentData, previousData) => {
+ const result = {
+ outbound: 0,
+ inbound: 0,
+ };
+
+ if (!currentData || !previousData) return result;
+
+ const currentOutboundData = getDataType(currentData, 'outbound-rtp')[0];
+ const currentInboundData = getDataType(currentData, 'inbound-rtp')[0];
+ const previousOutboundData = getDataType(previousData, 'outbound-rtp')[0];
+ const previousInboundData = getDataType(previousData, 'inbound-rtp')[0];
+
+ if (currentOutboundData && previousOutboundData) {
+ const {
+ bytesSent: outboundBytesSent,
+ timestamp: outboundTimestamp,
+ } = currentOutboundData;
+
+ let {
+ headerBytesSent: outboundHeaderBytesSent,
+ } = currentOutboundData;
+
+ if (!outboundHeaderBytesSent) outboundHeaderBytesSent = 0;
+
+ const {
+ bytesSent: previousOutboundBytesSent,
+ timestamp: previousOutboundTimestamp,
+ } = previousOutboundData;
+
+ let {
+ headerBytesSent: previousOutboundHeaderBytesSent,
+ } = previousOutboundData;
+
+ if (!previousOutboundHeaderBytesSent) previousOutboundHeaderBytesSent = 0;
+
+ const outboundBytesPerSecond = (outboundBytesSent + outboundHeaderBytesSent
+ - previousOutboundBytesSent - previousOutboundHeaderBytesSent)
+ / (outboundTimestamp - previousOutboundTimestamp);
+
+ result.outbound = Math.round((outboundBytesPerSecond * 8 * 1000) / 1024);
+ }
+
+ if (currentInboundData && previousInboundData) {
+ const {
+ bytesReceived: inboundBytesReceived,
+ timestamp: inboundTimestamp,
+ } = currentInboundData;
+
+ let {
+ headerBytesReceived: inboundHeaderBytesReceived,
+ } = currentInboundData;
+
+ if (!inboundHeaderBytesReceived) inboundHeaderBytesReceived = 0;
+
+ const {
+ bytesReceived: previousInboundBytesReceived,
+ timestamp: previousInboundTimestamp,
+ } = previousInboundData;
+
+ let {
+ headerBytesReceived: previousInboundHeaderBytesReceived,
+ } = previousInboundData;
+
+ if (!previousInboundHeaderBytesReceived) {
+ previousInboundHeaderBytesReceived = 0;
+ }
+
+ const inboundBytesPerSecond = (inboundBytesReceived
+ + inboundHeaderBytesReceived - previousInboundBytesReceived
+ - previousInboundHeaderBytesReceived) / (inboundTimestamp
+ - previousInboundTimestamp);
+
+ result.inbound = Math.round((inboundBytesPerSecond * 8 * 1000) / 1024);
+ }
+
+ return result;
+};
+
+/**
+ * Similar to calculateBitsPerSecond, but it receives stats from multiple
+ * peers. The total inbound/outbound is the sum of all peers.
+ * @param {Object} currentData - The Object returned from
+ * getStats / service's getNetworkData()
+ * @param {Object} previousData - The same object as above, but
+ * representing a data collected in past
+ * (previous call of service's getNetworkData())
+ */
+const calculateBitsPerSecondFromMultipleData = (currentData, previousData) => {
+ const result = {
+ outbound: 0,
+ inbound: 0,
+ };
+
+ if (!currentData || !previousData) return result;
+
+ Object.keys(currentData).forEach((peerId) => {
+ if (previousData[peerId]) {
+ const {
+ outbound: peerOutbound,
+ inbound: peerInbound,
+ } = calculateBitsPerSecond(currentData[peerId], previousData[peerId]);
+
+ result.outbound += peerOutbound;
+ result.inbound += peerInbound;
+ }
+ });
+
+ return result;
+};
+
+export default {
+ isModerator,
+ getConnectionStatus,
+ getStats,
+ getHelp,
+ isEnabled,
+ notification,
+ startRoundTripTime,
+ stopRoundTripTime,
+ getNetworkData,
+ calculateBitsPerSecond,
+ calculateBitsPerSecondFromMultipleData,
+ getDataType,
+};
diff --git a/src/2.7.12/imports/ui/components/connection-status/status-helper/component.jsx b/src/2.7.12/imports/ui/components/connection-status/status-helper/component.jsx
new file mode 100644
index 00000000..45cd711e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/status-helper/component.jsx
@@ -0,0 +1,113 @@
+import React, { Fragment, PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import Icon from '/imports/ui/components/connection-status/icon/component';
+import SettingsMenuContainer from '/imports/ui/components/settings/container';
+
+const intlMessages = defineMessages({
+ label: {
+ id: 'app.connection-status.label',
+ description: 'Connection status label',
+ },
+ settings: {
+ id: 'app.connection-status.settings',
+ description: 'Connection settings label',
+ },
+});
+
+class ConnectionStatusIcon extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = { isSettingsMenuModalOpen: false };
+
+ this.setSettingsMenuModalIsOpen = this.setSettingsMenuModalIsOpen.bind(this);
+ }
+
+ renderIcon(level = 'normal') {
+ return(
+
+
+
+ );
+ }
+
+ openAdjustSettings() {
+ this.setSettingsMenuModalIsOpen(true);
+ }
+
+ setSettingsMenuModalIsOpen(value) {
+ const {closeModal} = this.props;
+
+ this.setState({isSettingsMenuModalOpen: value})
+ if (!value) {
+ closeModal();
+ }
+ }
+
+ render() {
+ const {
+ intl,
+ stats,
+ } = this.props;
+
+ let color;
+ switch (stats) {
+ case 'warning':
+ color = 'success';
+ break;
+ case 'danger':
+ color = 'warning';
+ break;
+ case 'critical':
+ color = 'danger';
+ break;
+ default:
+ color = 'success';
+ }
+
+ const level = stats ? stats : 'normal';
+
+ const { isSettingsMenuModalOpen } = this.state;
+
+ return (
+
+
+ {this.renderIcon(level)}
+
+
+ {intl.formatMessage(intlMessages.label)}
+
+ {(level === 'critical' || level === 'danger') || isSettingsMenuModalOpen
+ ? (
+
+
+ {intl.formatMessage(intlMessages.settings)}
+
+ {isSettingsMenuModalOpen ? this.setSettingsMenuModalIsOpen(false),
+ priority: "medium",
+ setIsOpen: this.setSettingsMenuModalIsOpen,
+ isOpen: isSettingsMenuModalOpen,
+ }}
+ /> : null}
+
+ )
+ : (
+
+ )
+ }
+
+ );
+ }
+}
+
+export default injectIntl(ConnectionStatusIcon);
diff --git a/src/2.7.12/imports/ui/components/connection-status/status-helper/container.jsx b/src/2.7.12/imports/ui/components/connection-status/status-helper/container.jsx
new file mode 100644
index 00000000..c35110cb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/status-helper/container.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ConnectionStatusService from '../service';
+import ConnectionStatusIconComponent from './component';
+
+const connectionStatusIconContainer = props => ;
+
+export default withTracker(() => {
+ return {
+ stats: ConnectionStatusService.getStats(),
+ };
+})(connectionStatusIconContainer);
diff --git a/src/2.7.12/imports/ui/components/connection-status/status-helper/styles.js b/src/2.7.12/imports/ui/components/connection-status/status-helper/styles.js
new file mode 100644
index 00000000..cca6cb4f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/connection-status/status-helper/styles.js
@@ -0,0 +1,45 @@
+import styled from 'styled-components';
+import {
+ colorPrimary,
+ colorSuccess,
+ colorWarning,
+ colorDanger,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const StatusIconWrapper = styled.div`
+ border-radius: 50%;
+ padding: 1.5rem;
+
+ ${({ color }) => {
+ let bgColor = colorSuccess;
+ bgColor = color === 'warning' ? colorWarning : bgColor;
+ bgColor = color === 'danger' ? colorDanger : bgColor;
+ return `background-color: ${bgColor};`
+ }}
+`;
+
+const IconWrapper = styled.div`
+ width: 2.25rem;
+ height: 2.25rem;
+`;
+
+const Label = styled.div`
+ font-weight: 600;
+ margin: .25rem 0 .5rem;
+`;
+
+const Settings = styled.span`
+ color: ${colorPrimary};
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+export default {
+ StatusIconWrapper,
+ IconWrapper,
+ Label,
+ Settings,
+};
diff --git a/src/2.7.12/imports/ui/components/context-providers/component.jsx b/src/2.7.12/imports/ui/components/context-providers/component.jsx
new file mode 100644
index 00000000..a5b869b0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/context-providers/component.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { ChatContextProvider } from '/imports/ui/components/components-data/chat-context/context';
+import { UsersContextProvider } from '/imports/ui/components/components-data/users-context/context';
+import { GroupChatContextProvider } from '/imports/ui/components/components-data/group-chat-context/context';
+import { LayoutContextProvider } from '/imports/ui/components/layout/context';
+import { CustomBackgroundsProvider } from '/imports/ui/components/video-preview/virtual-background/context';
+
+const providersList = [
+ ChatContextProvider,
+ GroupChatContextProvider,
+ UsersContextProvider,
+ LayoutContextProvider,
+ CustomBackgroundsProvider,
+];
+
+const ContextProvidersComponent = props => providersList.reduce((acc, Component) => (
+
+ {acc}
+ ), props.children);
+
+export default ContextProvidersComponent;
diff --git a/src/2.7.12/imports/ui/components/debug-window/component.jsx b/src/2.7.12/imports/ui/components/debug-window/component.jsx
new file mode 100644
index 00000000..6243a51b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/debug-window/component.jsx
@@ -0,0 +1,203 @@
+import React, { Component } from 'react';
+import Draggable from 'react-draggable';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import ChatLogger from '/imports/ui/components/chat/chat-logger/ChatLogger';
+
+const intlMessages = defineMessages({
+ modalClose: {
+ id: 'app.modal.close',
+ description: 'Close',
+ },
+ modalCloseDescription: {
+ id: 'app.modal.close.description',
+ description: 'Disregards changes and closes the modal',
+ },
+ debugWindowTitle: {
+ id: 'app.debugWindow.windowTitle',
+ description: 'Debug window title',
+ },
+ userAgentLabel: {
+ id: 'app.debugWindow.form.userAgentLabel',
+ description: 'User agent form label',
+ },
+ copyButtonLabel: {
+ id: 'app.debugWindow.form.button.copy',
+ description: 'User agent form copy button',
+ },
+ chatLoggerLabel: {
+ id: 'app.debugWindow.form.chatLoggerLabel',
+ description: 'Chat logger level form label',
+ },
+ applyButtonLabel: {
+ id: 'app.debugWindow.form.button.apply',
+ description: 'Chat logger level form apply button',
+ },
+ on: {
+ id: 'app.switch.onLabel',
+ description: 'label for toggle switch on state',
+ },
+ off: {
+ id: 'app.switch.offLabel',
+ description: 'label for toggle switch off state',
+ },
+});
+
+const DEBUG_WINDOW_ENABLED = Meteor.settings.public.app.enableDebugWindow;
+const SHOW_DEBUG_WINDOW_ACCESSKEY = Meteor.settings.public.app.shortcuts.openDebugWindow.accesskey;
+
+class DebugWindow extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ showDebugWindow: false,
+ logLevel: ChatLogger.getLogLevel(),
+ };
+ }
+
+ componentDidMount() {
+ document.addEventListener('keyup', (event) => {
+ const { key, code } = event;
+ const eventKey = key?.toUpperCase();
+ const eventCode = code;
+ if (DEBUG_WINDOW_ENABLED && event?.altKey && (eventKey === SHOW_DEBUG_WINDOW_ACCESSKEY || eventCode === `Key${SHOW_DEBUG_WINDOW_ACCESSKEY}`)) {
+ this.debugWindowToggle();
+ }
+ });
+ }
+
+ setShowDebugWindow(showDebugWindow) {
+ this.setState({ showDebugWindow });
+ }
+
+ debugWindowToggle() {
+ const { showDebugWindow } = this.state;
+ if (showDebugWindow) {
+ this.setShowDebugWindow(false);
+ } else {
+ this.setShowDebugWindow(true);
+ }
+ }
+
+ displaySettingsStatus(status) {
+ const { intl } = this.props;
+
+ return (
+
+ {status ? intl.formatMessage(intlMessages.on)
+ : intl.formatMessage(intlMessages.off)}
+
+ );
+ }
+
+ render() {
+ const { showDebugWindow, logLevel } = this.state;
+ const chatLoggerLevelsNames = Object.keys(ChatLogger.levels);
+
+ if (!DEBUG_WINDOW_ENABLED || !showDebugWindow) return false;
+
+ const { intl } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+ {`${intl.formatMessage(intlMessages.userAgentLabel)}:`}
+
+
+
+ navigator.clipboard.writeText(window.navigator.userAgent)}
+ >
+ {`${intl.formatMessage(intlMessages.copyButtonLabel)}`}
+
+
+
+
+
+ {`${intl.formatMessage(intlMessages.chatLoggerLabel)}:`}
+
+
+
+ {
+ this.setState({
+ logLevel: ev.target.value,
+ });
+ }}
+ >
+ {
+ chatLoggerLevelsNames.map((i, index) => {
+ const idx = index;
+ return ({i} );
+ })
+ }
+
+ {
+ ChatLogger.setLogLevel(logLevel);
+ this.setState({
+ logLevel: ChatLogger.getLogLevel(),
+ });
+ }}
+ >
+ {`${intl.formatMessage(intlMessages.applyButtonLabel)}`}
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(DebugWindow);
diff --git a/src/2.7.12/imports/ui/components/debug-window/styles.js b/src/2.7.12/imports/ui/components/debug-window/styles.js
new file mode 100644
index 00000000..a1be2e7c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/debug-window/styles.js
@@ -0,0 +1,132 @@
+import styled from 'styled-components';
+import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+import Resizable from 're-resizable';
+import Icon from '/imports/ui/components/common/icon/component';
+import Button from '/imports/ui/components/common/button/component';
+
+const ToggleLabel = styled.span`
+ margin-right: ${smPaddingX};
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+`;
+
+const DebugWindowWrapper = styled(Resizable)`
+ position: absolute !important;
+ z-index: 9;
+`;
+
+const DebugWindow = styled.div`
+ min-width: 20vw;
+ min-height: 20vh;
+ width: 100%;
+ height: 100%;
+ background-color: white;
+ border: 2px solid #06172A;
+
+ &::after {
+ content: "";
+ -webkit-transform: rotate(-45deg);
+ position: absolute;
+ right: 2px;
+ bottom: 8px;
+ pointer-events: none;
+ width: 14px;
+ height: 1px;
+ background: rgba(0,0,0,.5);
+ }
+
+ &::before {
+ content: "";
+ -webkit-transform: rotate(-45deg);
+ position: absolute;
+ right: 2px;
+ bottom: 5px;
+ pointer-events: none;
+ width: 8px;
+ height: 1px;
+ background: rgba(0,0,0,.5);
+ }
+`;
+
+const Header = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid lightgray;
+ cursor: move;
+
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+`;
+
+const MoveIcon = styled(Icon)`
+ margin: 5px;
+ color: rgba(0,0,0,.5);
+`;
+
+const Title = styled.div`
+ font-size: 1.2rem;
+ font-weight: bold;
+`;
+
+const CloseButton = styled(Button)`
+ & > span > i {
+ font-size: 115%;
+ }
+`;
+
+const DebugWindowContent = styled.div`
+ padding: 10px;
+`;
+
+const Table = styled.div`
+ display: table;
+ width: 100%;
+`;
+
+const TableRow = styled.div`
+ display: table-row;
+`;
+
+const TableCell = styled.div`
+ display: table-cell;
+ padding: 5px;
+ vertical-align: middle;
+`;
+
+const UserAgentInput = styled.input`
+ margin-right: 5px;
+`;
+
+const AutoArrangeToggle = styled.input`
+ margin-right: 5px;
+`;
+
+const CellContent = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+export default {
+ ToggleLabel,
+ DebugWindowWrapper,
+ DebugWindow,
+ Header,
+ MoveIcon,
+ Title,
+ CloseButton,
+ DebugWindowContent,
+ Table,
+ TableRow,
+ TableCell,
+ UserAgentInput,
+ AutoArrangeToggle,
+ CellContent,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/component.jsx b/src/2.7.12/imports/ui/components/dropdown/component.jsx
new file mode 100644
index 00000000..3c4ffa93
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/component.jsx
@@ -0,0 +1,330 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { findDOMNode } from 'react-dom';
+import TetherComponent from 'react-tether';
+import { defineMessages, injectIntl } from 'react-intl';
+import deviceInfo from '/imports/utils/deviceInfo';
+import screenreaderTrap from 'makeup-screenreader-trap';
+import { Session } from 'meteor/session';
+import Styled from './styles';
+
+import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component';
+import DropdownContent from '/imports/ui/components/dropdown/content/component';
+import DropdownList from '/imports/ui/components/dropdown/list/component';
+import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component';
+import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
+import DropdownListTitle from '/imports/ui/components/dropdown/list/title/component';
+
+const intlMessages = defineMessages({
+ close: {
+ id: 'app.dropdown.close',
+ description: 'Close button label',
+ },
+});
+
+const noop = () => { };
+
+const propTypes = {
+ /**
+ * The dropdown needs a trigger and a content component as children
+ */
+ children: (props, propName, componentName) => {
+ const children = props[propName];
+
+ if (!children || children.length < 2) {
+ return new Error(`Invalid prop \`${propName}\` supplied to`
+ + ` \`${componentName}\`. Validation failed.`);
+ }
+
+ const trigger = children.find((x) => x.type === DropdownTrigger);
+ const content = children.find((x) => x.type === DropdownContent);
+
+ if (!trigger) {
+ return new Error(`Invalid prop \`${propName}\` supplied to`
+ + ` \`${componentName}\`. Missing \`DropdownTrigger\`. Validation failed.`);
+ }
+
+ if (!content) {
+ return new Error(`Invalid prop \`${propName}\` supplied to`
+ + ` \`${componentName}\`. Missing \`DropdownContent\`. Validation failed.`);
+ }
+
+ return null;
+ },
+ isOpen: PropTypes.bool,
+ keepOpen: PropTypes.bool,
+ onHide: PropTypes.func,
+ onShow: PropTypes.func,
+ autoFocus: PropTypes.bool,
+ tethered: PropTypes.bool,
+ getContent: PropTypes.func,
+};
+
+const defaultProps = {
+ tethered: false,
+ children: null,
+ onShow: noop,
+ onHide: noop,
+ autoFocus: false,
+ isOpen: false,
+ keepOpen: null,
+ getContent: () => {},
+};
+
+const attachments = {
+ 'right-bottom': 'bottom left',
+ 'right-top': 'bottom left',
+};
+const targetAttachments = {
+ 'right-bottom': 'bottom right',
+ 'right-top': 'top right',
+};
+
+class Dropdown extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isOpen: false,
+ isPortrait: deviceInfo.isPortrait(),
+ };
+ this.handleShow = this.handleShow.bind(this);
+ this.handleHide = this.handleHide.bind(this);
+ this.handleToggle = this.handleToggle.bind(this);
+ this.handleWindowClick = this.handleWindowClick.bind(this);
+ this.updateOrientation = this.updateOrientation.bind(this);
+ }
+
+ componentDidMount() {
+ window.addEventListener('resize', this.updateOrientation);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const {
+ onShow,
+ onHide,
+ keepOpen,
+ tethered,
+ } = this.props;
+
+ const { isOpen } = this.state;
+
+ const enableSRTrap = isOpen && !tethered;
+
+ if (enableSRTrap) {
+ screenreaderTrap.trap(this.dropdown);
+ } else {
+ screenreaderTrap.untrap();
+ }
+
+ if (isOpen && !prevState.isOpen) { onShow(); }
+
+ if (!isOpen && prevState.isOpen) { onHide(); }
+
+ if (prevProps.keepOpen && !keepOpen) onHide();
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.updateOrientation);
+ }
+
+ handleHide() {
+ Session.set('dropdownOpen', false);
+ const { onHide } = this.props;
+ this.setState({ isOpen: false }, () => {
+ const { removeEventListener } = window;
+ onHide();
+ removeEventListener('click', this.handleWindowClick, true);
+ });
+ }
+
+ handleShow() {
+ Session.set('dropdownOpen', true);
+ const {
+ onShow,
+ } = this.props;
+ this.setState({ isOpen: true }, () => {
+ const { addEventListener } = window;
+ onShow();
+ addEventListener('click', this.handleWindowClick, true);
+ });
+ }
+
+ handleWindowClick(event) {
+ const { keepOpen, onHide } = this.props;
+ const { isOpen } = this.state;
+ const triggerElement = findDOMNode(this.trigger);
+ const contentElement = findDOMNode(this.content);
+ if (!(triggerElement && contentElement)) return;
+ if (triggerElement && triggerElement.contains(event.target)) {
+ if (keepOpen) {
+ onHide();
+ return;
+ }
+ if (isOpen) {
+ this.handleHide();
+ return;
+ }
+ }
+
+ if (keepOpen && isOpen && !contentElement.contains(event.target)) {
+ if (triggerElement) {
+ const { parentElement } = triggerElement;
+ if (parentElement) parentElement.focus();
+ }
+ onHide();
+ this.handleHide();
+ return;
+ }
+
+ if (keepOpen && triggerElement) {
+ const { parentElement } = triggerElement;
+ if (parentElement) parentElement.focus();
+ }
+
+ if (keepOpen === true) return;
+ this.handleHide();
+ }
+
+ handleToggle() {
+ const { isOpen } = this.state;
+ return isOpen ? this.handleHide() : this.handleShow();
+ }
+
+ updateOrientation() {
+ this.setState({ isPortrait: deviceInfo.isPortrait() });
+ }
+
+ render() {
+ const {
+ children,
+ intl,
+ keepOpen,
+ tethered,
+ placement,
+ getContent,
+ ...otherProps
+ } = this.props;
+ const { isOpen, isPortrait } = this.state;
+ const { isPhone } = deviceInfo;
+ const placements = placement && placement.replace(' ', '-');
+ const test = isPhone && isPortrait ? {
+ width: '100%',
+ height: '100%',
+ transform: 'translateY(0)',
+ } : {
+ width: '',
+ height: '',
+ transform: '',
+ };
+
+ let trigger = children.find((x) => x.type === DropdownTrigger);
+ let content = children.find((x) => x.type === DropdownContent);
+
+ trigger = React.cloneElement(trigger, {
+ ref: (ref) => { this.trigger = ref; },
+ dropdownIsOpen: isOpen,
+ dropdownToggle: this.handleToggle,
+ dropdownShow: this.handleShow,
+ dropdownHide: this.handleHide,
+ keepopen: `${keepOpen}`,
+ });
+
+ content = React.cloneElement(content, {
+ ref: (ref) => {
+ getContent(ref);
+ this.content = ref;
+ },
+ 'aria-expanded': isOpen,
+ dropdownIsOpen: isOpen,
+ dropdownToggle: this.handleToggle,
+ dropdownShow: this.handleShow,
+ dropdownHide: this.handleHide,
+ keepopen: `${keepOpen}`,
+ });
+
+ const showCloseBtn = (isOpen && keepOpen) || (isOpen && keepOpen === null);
+ return (
+ { this.dropdown = node; }}
+ tabIndex={-1}
+ >
+ {
+ tethered
+ ? (
+ (
+
+ {trigger}
+
+ )}
+ renderElement={(ref) => (
+
+ {content}
+ {showCloseBtn
+ ? (
+
+ ) : null}
+
+ )}
+ />
+ )
+ : (
+ // Fix eslint rule https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-fragments.md
+ <>
+ {trigger}
+ {content}
+ {showCloseBtn
+ ? (
+
+ ) : null}
+ >
+ )
+ }
+
+ );
+ }
+}
+
+Dropdown.propTypes = propTypes;
+Dropdown.defaultProps = defaultProps;
+
+Dropdown.DropdownTrigger = DropdownTrigger;
+Dropdown.DropdownContent = DropdownContent;
+Dropdown.DropdownList = DropdownList;
+Dropdown.DropdownListSeparator = DropdownListSeparator;
+Dropdown.DropdownListItem = DropdownListItem;
+Dropdown.DropdownListTitle = DropdownListTitle;
+export default injectIntl(Dropdown, { forwardRef: true });
diff --git a/src/2.7.12/imports/ui/components/dropdown/content/component.jsx b/src/2.7.12/imports/ui/components/dropdown/content/component.jsx
new file mode 100644
index 00000000..32d3225b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/content/component.jsx
@@ -0,0 +1,41 @@
+import React, { Component, Children, cloneElement } from 'react';
+import Styled from './styles';
+
+const defaultProps = {
+ 'aria-expanded': false,
+};
+
+export default class DropdownContent extends Component {
+ render() {
+ const {
+ children,
+ dropdownToggle,
+ dropdownShow,
+ dropdownHide,
+ dropdownIsOpen,
+ keepOpen,
+ ...restProps
+ } = this.props;
+
+ const boundChildren = Children.map(children, child => cloneElement(child, {
+ dropdownIsOpen,
+ dropdownToggle,
+ dropdownShow,
+ dropdownHide,
+ keepOpen,
+ }));
+
+ return (
+
+
+ {boundChildren}
+
+
+ );
+ }
+}
+
+DropdownContent.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/dropdown/content/styles.js b/src/2.7.12/imports/ui/components/dropdown/content/styles.js
new file mode 100644
index 00000000..ca962f40
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/content/styles.js
@@ -0,0 +1,154 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import {
+ borderSize,
+ borderRadius,
+ dropdownCaretHeight,
+ dropdownCaretWidth,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { colorWhite, colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+
+const Content = styled.div`
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ z-index: 9999;
+ position: absolute;
+ background: ${colorWhite};
+ border-radius: ${borderRadius};
+ box-shadow: 0 6px 12px rgba(0, 0, 0, .175);
+ border: 0;
+ padding: calc(${lineHeightComputed} / 2);
+
+ [dir="rtl"] & {
+ right: 10.75rem;
+ }
+
+ &:after,
+ &:before {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 0;
+ }
+
+ &[aria-expanded="false"] {
+ display: none;
+ }
+
+ &[aria-expanded="true"] {
+ display: block;
+ }
+
+ @media ${smallOnly} {
+ z-index: 1015;
+ border-radius: 0;
+ background-color: #fff;
+ box-shadow: none;
+ position: fixed;
+ top: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ bottom: 0 !important;
+ border: 0 !important;
+ padding: 0 !important;
+ margin: 0 0 calc(${lineHeightComputed} * 5.25) 0 !important;
+ transform: translateX(0) translateY(0) !important;
+
+ &:after,
+ &:before {
+ display: none !important;
+ }
+ }
+
+ //top-left
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ margin-bottom: calc(${dropdownCaretHeight} * 1.25);
+
+ &:before,
+ &:after {
+ border-left: ${dropdownCaretWidth} solid transparent;
+ border-right: ${dropdownCaretWidth} solid transparent;
+ border-top: ${dropdownCaretHeight} solid ${colorWhite};
+ bottom: 0;
+ margin-bottom: calc(${dropdownCaretHeight} * -1);
+ }
+
+ &:before {
+ border-top: ${dropdownCaretHeight} solid ${colorGray};
+ }
+
+ transform: translateX(100%);
+ right: 100%;
+ left: auto;
+
+ &:after,
+ &:before {
+ left: ${dropdownCaretWidth};
+ }
+
+ min-width: 18rem;
+
+ @media ${smallOnly} {
+ width: auto;
+ }
+
+ [dir="rtl"] {
+ transform: translateX(25%);
+ }
+`;
+
+const Scrollable = styled.div`
+ @media ${smallOnly} {
+ overflow-y: auto;
+ background: linear-gradient(white 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), white 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ background-repeat: no-repeat;
+ background-color: transparent;
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
+ background-attachment: local, local, scroll, scroll;
+
+ // Fancy scroll
+ &::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+ &::-webkit-scrollbar-button {
+ width: 0;
+ height: 0;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.5); }
+ &::-webkit-scrollbar-thumb:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-track:hover { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-corner { background: 0 0; }
+
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+`;
+
+export default {
+ Content,
+ Scrollable,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/component.jsx
new file mode 100644
index 00000000..1497b410
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/component.jsx
@@ -0,0 +1,206 @@
+import React, { Component, Children, cloneElement } from 'react';
+import PropTypes from 'prop-types';
+import KEY_CODES from '/imports/utils/keyCodes';
+import Styled from './styles';
+import ListItem from './item/component';
+import ListSeparator from './separator/component';
+import ListTitle from './title/component';
+
+const propTypes = {
+ /* We should recheck this proptype, sometimes we need to create an container and send to
+ dropdown, but with this proptype, is not possible. */
+ children: PropTypes.arrayOf((propValue, key, componentName, propFullName) => {
+ if (propValue[key].type !== ListItem
+ && propValue[key].type !== ListSeparator
+ && propValue[key].type !== ListTitle) {
+ return new Error(`Invalid prop \`${propFullName}\` supplied to`
+ + ` \`${componentName}\`. Validation failed.`);
+ }
+ return true;
+ }).isRequired,
+
+ horizontal: PropTypes.bool,
+};
+
+const defaultProps = {
+ horizontal: false,
+};
+
+export default class DropdownList extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ focusedIndex: false,
+ };
+
+ this.childrenRefs = [];
+ this.menuRefs = [];
+ this.handleItemKeyDown = this.handleItemKeyDown.bind(this);
+ this.handleItemClick = this.handleItemClick.bind(this);
+ }
+
+ componentDidMount() {
+ this._menu.addEventListener('keydown', (event) => this.handleItemKeyDown(event));
+ }
+
+ componentDidUpdate() {
+ const { focusedIndex } = this.state;
+ const children = [].slice.call(this._menu.children);
+ this.menuRefs = children.filter((child) => child.getAttribute('role') === 'menuitem');
+
+ const activeRef = this.menuRefs[focusedIndex];
+
+ if (activeRef) {
+ activeRef.focus();
+ }
+ }
+
+ handleItemKeyDown(event, callback) {
+ const {
+ getDropdownMenuParent,
+ horizontal,
+ } = this.props;
+ const { focusedIndex } = this.state;
+
+ let nextFocusedIndex = focusedIndex > 0 ? focusedIndex : 0;
+
+ if (focusedIndex === false) {
+ nextFocusedIndex = this.menuRefs.indexOf(document.activeElement);
+ }
+
+ const isHorizontal = horizontal;
+ const navigationKeys = {
+ previous: KEY_CODES[`ARROW_${isHorizontal ? 'LEFT' : 'UP'}`],
+ next: KEY_CODES[`ARROW_${isHorizontal ? 'RIGHT' : 'DOWN'}`],
+ click: isHorizontal ? [KEY_CODES.ENTER] : [KEY_CODES.ENTER, KEY_CODES.ARROW_RIGHT],
+ close: [KEY_CODES.ESCAPE,
+ KEY_CODES.TAB,
+ KEY_CODES[`ARROW_${isHorizontal ? 'DOWN' : 'LEFT'}`]],
+ };
+
+ if (navigationKeys.previous === event.which) {
+ event.stopPropagation();
+
+ nextFocusedIndex -= 1;
+
+ if (nextFocusedIndex < 0) {
+ nextFocusedIndex = this.menuRefs.length - 1;
+ } else if (nextFocusedIndex > this.menuRefs.length - 1) {
+ nextFocusedIndex = 0;
+ }
+ }
+
+ if ([navigationKeys.next].includes(event.keyCode)) {
+ event.stopPropagation();
+
+ nextFocusedIndex += 1;
+
+ if (nextFocusedIndex > this.menuRefs.length - 1) {
+ nextFocusedIndex = 0;
+ }
+ }
+
+ if (navigationKeys.click.includes(event.keyCode)) {
+ nextFocusedIndex = false;
+ event.stopPropagation();
+ document.activeElement.firstChild.click();
+ }
+
+ if (navigationKeys.close.includes(event.keyCode)) {
+ nextFocusedIndex = false;
+ const { dropdownHide } = this.props;
+
+ event.stopPropagation();
+ event.preventDefault();
+
+ dropdownHide();
+ if (getDropdownMenuParent) {
+ getDropdownMenuParent().focus();
+ }
+ }
+
+ this.setState({ focusedIndex: nextFocusedIndex });
+
+ if (typeof callback === 'function') {
+ callback(event);
+ }
+ }
+
+ handleItemClick(event, callback) {
+ const {
+ getDropdownMenuParent,
+ onActionsHide,
+ dropdownHide,
+ keepOpen,
+ } = this.props;
+
+ if (!keepOpen) {
+ if (getDropdownMenuParent) {
+ onActionsHide();
+ } else {
+ this.setState({ focusedIndex: null });
+ dropdownHide();
+ }
+ }
+
+ if (typeof callback === 'function') {
+ callback(event);
+ }
+ }
+
+ render() {
+ const {
+ children,
+ style,
+ horizontal,
+ } = this.props;
+
+ const boundChildren = Children.map(
+ children,
+ (item) => {
+ if (item.type === ListSeparator) {
+ return item;
+ }
+
+ return cloneElement(item, {
+ tabIndex: 0,
+ injectRef: (ref) => {
+ if (ref && !this.childrenRefs.includes(ref)) { this.childrenRefs.push(ref); }
+ },
+
+ onClick: (event) => {
+ let { onClick } = item.props;
+ onClick = onClick ? onClick.bind(item) : null;
+ this.handleItemClick(event, onClick);
+ },
+
+ onKeyDown: (event) => {
+ let { onKeyDown } = item.props;
+ onKeyDown = onKeyDown ? onKeyDown.bind(item) : null;
+
+ this.handleItemKeyDown(event, onKeyDown);
+ },
+ });
+ },
+ );
+
+ const listDirection = horizontal ? 'horizontal' : 'vertical';
+
+ return (
+ {
+ this._menu = menu;
+ return menu;
+ }}
+ >
+ {boundChildren}
+
+ );
+ }
+}
+
+DropdownList.propTypes = propTypes;
+DropdownList.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx
new file mode 100644
index 00000000..92446dbf
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/item/component.jsx
@@ -0,0 +1,103 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const propTypes = {
+ icon: PropTypes.string,
+ label: PropTypes.string,
+ description: PropTypes.string,
+ accessKey: PropTypes.string,
+ tabIndex: PropTypes.number,
+ disabled: PropTypes.bool,
+};
+
+const defaultProps = {
+ icon: '',
+ label: '',
+ description: '',
+ tabIndex: 0,
+ accessKey: null,
+ disabled: false,
+};
+
+const messages = defineMessages({
+ activeAriaLabel: {
+ id: 'app.dropdown.list.item.activeLabel',
+ },
+});
+
+class DropdownListItem extends Component {
+ constructor(props) {
+ super(props);
+ this.labelID = uniqueId('dropdown-item-label-');
+ this.descID = uniqueId('dropdown-item-desc-');
+ }
+
+ renderDefault() {
+ const {
+ icon, label, iconRight, accessKey,
+ } = this.props;
+
+ return [
+ (icon ? : null),
+ (
+
+ {label}
+
+ ),
+ (iconRight ? : null),
+ ];
+ }
+
+ render() {
+ const {
+ id,
+ label,
+ description,
+ children,
+ injectRef,
+ tabIndex,
+ onClick,
+ onKeyDown,
+ className,
+ style,
+ intl,
+ disabled,
+ 'data-test': dataTest,
+ } = this.props;
+
+ const isSelected = className && className.includes('emojiSelected');
+ const _label = isSelected ? `${label} (${intl.formatMessage(messages.activeAriaLabel)})` : label;
+ return (
+ {} : onClick}
+ onKeyDown={disabled ? () => {} : onKeyDown}
+ tabIndex={tabIndex}
+ aria-labelledby={this.labelID}
+ aria-describedby={this.descID}
+ style={style}
+ role="menuitem"
+ data-test={dataTest}
+ >
+ {
+ children || this.renderDefault()
+ }
+ {
+ label
+ ? ({_label} )
+ : null
+ }
+ {description}
+
+ );
+ }
+}
+
+export default injectIntl(DropdownListItem);
+
+DropdownListItem.propTypes = propTypes;
+DropdownListItem.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/item/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/item/styles.js
new file mode 100644
index 00000000..f5b483bb
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/item/styles.js
@@ -0,0 +1,99 @@
+import styled from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorGrayDark,
+ colorPrimary,
+ colorWhite,
+ colorText,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const ItemIcon = styled(Icon)`
+ margin: 0 calc(${lineHeightComputed} / 2) 0 0;
+ color: ${colorText};
+ flex: 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 calc(${lineHeightComputed} / 2);
+ }
+`;
+
+const ItemLabel = styled.span`
+ color: ${colorGrayDark};
+ font-size: 90%;
+ flex: 1;
+`;
+
+const IconRight = styled(ItemIcon)`
+ margin-right: 0;
+ margin-left: 1rem;
+ font-size: 12px;
+ line-height: 16px;
+
+ [dir="rtl"] & {
+ margin-left: 0;
+ margin-right: 1rem;
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+`;
+
+const Item = styled.li`
+ display: flex;
+ flex: 1 1 100%;
+ align-items: center;
+ padding: calc(${lineHeightComputed} / 3) 0;
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+
+ cursor: pointer;
+ background-color: ${colorPrimary};
+ color: ${colorWhite};
+
+ & > span {
+ color: ${colorWhite} !important;
+ }
+
+ margin-left: -.25rem;
+ margin-right: -.25rem;
+ padding-left: .25rem;
+ padding-right: .25rem;
+
+ [dir="rtl"] & {
+ margin-right: -.25rem;
+ margin-left: -.25rem;
+ padding-right: .25rem;
+ padding-left: .25rem;
+ }
+
+
+ @media ${smallOnly} {
+ border-radius: 0.2rem;
+ }
+
+ & i {
+ color: inherit;
+ }
+ }
+
+ &:focus {
+ box-shadow: 0 0 0 2px ${colorWhite}, 0 0 2px 4px rgba(${colorPrimary}, .4);
+ outline-style: solid;
+ }
+`;
+
+export default {
+ ItemIcon,
+ ItemLabel,
+ IconRight,
+ Item,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx
new file mode 100644
index 00000000..31957b03
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/separator/component.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const DropdownListSeparator = ({ style }) => (
+
+);
+
+DropdownListSeparator.propTypes = {
+ style: PropTypes.shape({}),
+};
+
+DropdownListSeparator.defaultProps = {
+ style: null,
+};
+
+export default DropdownListSeparator;
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js
new file mode 100644
index 00000000..26279a0d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/separator/styles.js
@@ -0,0 +1,18 @@
+import styled from 'styled-components';
+import { colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+
+const Separator = styled.li`
+ display: flex;
+ flex: 1 1 100%;
+ height: 1px;
+ min-height: 1px;
+ background-color: ${colorGrayLighter};
+ padding: 0;
+ margin-top: calc(${lineHeightComputed} * .5);
+ margin-bottom: calc(${lineHeightComputed} * .5);
+`;
+
+export default {
+ Separator,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/styles.js
new file mode 100644
index 00000000..e7a5f63f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/styles.js
@@ -0,0 +1,47 @@
+import styled from 'styled-components';
+import { fontSizeBase, fontSizeLarge, lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+import { colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
+
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const List = styled.ul`
+ list-style: none;
+ font-size: ${fontSizeBase};
+ margin: 0;
+ padding: 0;
+ text-align: left;
+ color: ${colorGrayDark};
+ display: flex;
+ overflow-wrap: break-word;
+ white-space: pre-line;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+
+ @media ${smallOnly} {
+ font-size: calc(${fontSizeLarge} * 1.1);
+ padding: ${lineHeightComputed};
+ }
+
+ ${({ direction }) => direction === 'horizontal' && `
+ padding: 0;
+ flex-direction: row;
+
+ @media ${smallOnly} {
+ flex-direction: column;
+ padding: calc(${lineHeightComputed} / 1.5) 0;
+ }
+
+ padding: 0 calc(${lineHeightComputed} / 3);
+ `}
+
+ ${({ direction }) => direction === 'vertical' && `
+ flex-direction: column;
+ width: 100%;
+ `}
+`;
+
+export default {
+ List,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx b/src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx
new file mode 100644
index 00000000..7b3947a0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/title/component.jsx
@@ -0,0 +1,22 @@
+import React, { Component } from 'react';
+import Styled from './styles';
+import { uniqueId } from '/imports/utils/string-utils';
+
+export default class DropdownListTitle extends Component {
+ constructor(props) {
+ super(props);
+ this.labelID = uniqueId('labelContext-');
+ }
+
+ render() {
+ const {
+ children,
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/dropdown/list/title/styles.js b/src/2.7.12/imports/ui/components/dropdown/list/title/styles.js
new file mode 100644
index 00000000..a0227152
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/list/title/styles.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+import { colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+
+const Title = styled.li`
+ color: ${colorGray};
+ font-weight: 600;
+ width: 100%;
+`;
+
+export default {
+ Title,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/styles.js b/src/2.7.12/imports/ui/components/dropdown/styles.js
new file mode 100644
index 00000000..b7fcdd77
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/styles.js
@@ -0,0 +1,37 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const Dropdown = styled.div`
+ position: relative;
+ z-index: 3;
+
+ &:focus {
+ outline: none;
+ }
+`;
+
+const CloseButton = styled(Button)`
+ display: none;
+ position: fixed;
+ bottom: 0.8rem;
+ border-radius: 0;
+ z-index: 1011;
+ font-size: calc(${fontSizeLarge} * 1.1);
+ width: calc(100% - (${lineHeightComputed} * 2));
+ left: ${lineHeightComputed};
+ box-shadow: 0 0 0 2rem ${colorWhite} !important;
+ border: ${colorWhite} !important;
+
+ @media ${smallOnly} {
+ display: block;
+ }
+`;
+
+export default {
+ Dropdown,
+ CloseButton,
+};
diff --git a/src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx b/src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx
new file mode 100644
index 00000000..904e1437
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/dropdown/trigger/component.jsx
@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import Button from '/imports/ui/components/common/button/component';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import KEY_CODES from '/imports/utils/keyCodes';
+
+const propTypes = {
+ children: PropTypes.element.isRequired,
+};
+
+export default class DropdownTrigger extends Component {
+ static isButtonTriggerOnEmoji(buttonComponent) {
+ return (
+ buttonComponent
+ && (buttonComponent.type === Button)
+ && (buttonComponent.props)
+ && (buttonComponent.props.children)
+ );
+ }
+
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ this.handleKeyDown = this.handleKeyDown.bind(this);
+ this.trigger = null;
+ }
+
+ handleClick(e) {
+ e.stopPropagation();
+ const { dropdownToggle, onClick } = this.props;
+ if (onClick) onClick();
+ return dropdownToggle();
+ }
+
+ handleKeyDown(event) {
+ event.stopPropagation();
+ const { dropdownShow, dropdownHide } = this.props;
+
+ if ([KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event.which)) {
+ event.preventDefault();
+ event.stopPropagation();
+ } else if ([KEY_CODES.ARROW_UP, KEY_CODES.ARROW_DOWN].includes(event.which)) {
+ dropdownShow();
+ } else if (KEY_CODES.ESCAPE === event.which) {
+ dropdownHide();
+ }
+ }
+
+ render() {
+ const { dropdownIsOpen } = this.props;
+ const remainingProps = { ...this.props };
+ delete remainingProps.dropdownToggle;
+ delete remainingProps.dropdownShow;
+ delete remainingProps.dropdownHide;
+ delete remainingProps.dropdownIsOpen;
+
+ const {
+ children,
+ className,
+ ...restProps
+ } = remainingProps;
+
+ let TriggerComponent;
+ let TriggerComponentBounded;
+
+ const buttonComponentProps = {
+ ...restProps,
+ 'aria-expanded': dropdownIsOpen,
+ };
+
+ const triggerComponentProps = {
+ onClick: this.handleClick,
+ onKeyDown: this.handleKeyDown,
+ };
+
+ if (DropdownTrigger.isButtonTriggerOnEmoji(children)) {
+ const { children: grandChildren } = children.props;
+
+ triggerComponentProps.className = cx(children.props.className, className);
+
+ TriggerComponent = React.Children.only(grandChildren);
+ TriggerComponentBounded = React.cloneElement(TriggerComponent,
+ triggerComponentProps);
+
+ const ButtonComponent = React.Children.only(children);
+ return React.cloneElement(ButtonComponent,
+ buttonComponentProps, TriggerComponentBounded);
+ }
+
+ buttonComponentProps.className = cx(children.props.className, className);
+ TriggerComponent = React.Children.only(children);
+
+ TriggerComponentBounded = React.cloneElement(TriggerComponent,
+ { ...buttonComponentProps, ...triggerComponentProps });
+
+ return TriggerComponentBounded;
+ }
+}
+
+DropdownTrigger.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/emoji-picker/component.jsx b/src/2.7.12/imports/ui/components/emoji-picker/component.jsx
new file mode 100644
index 00000000..0b3b015d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/emoji-picker/component.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+import data from '@emoji-mart/data';
+import Picker from '@emoji-mart/react';
+
+const DISABLE_EMOJIS = Meteor.settings.public.chat.disableEmojis;
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ onEmojiSelect: PropTypes.func.isRequired,
+};
+
+const emojisToExclude = [
+ ...DISABLE_EMOJIS,
+];
+
+const EmojiPicker = (props) => {
+ const {
+ intl,
+ onEmojiSelect,
+ } = props;
+
+ const i18n = {
+ search: intl.formatMessage({ id: 'app.emojiPicker.search' }),
+ notfound: intl.formatMessage({ id: 'app.emojiPicker.notFound' }),
+ clear: intl.formatMessage({ id: 'app.emojiPicker.clear' }),
+ skintext: intl.formatMessage({ id: 'app.emojiPicker.skintext' }),
+ categories: {
+ people: intl.formatMessage({ id: 'app.emojiPicker.categories.people' }),
+ nature: intl.formatMessage({ id: 'app.emojiPicker.categories.nature' }),
+ foods: intl.formatMessage({ id: 'app.emojiPicker.categories.foods' }),
+ places: intl.formatMessage({ id: 'app.emojiPicker.categories.places' }),
+ activity: intl.formatMessage({ id: 'app.emojiPicker.categories.activity' }),
+ objects: intl.formatMessage({ id: 'app.emojiPicker.categories.objects' }),
+ symbols: intl.formatMessage({ id: 'app.emojiPicker.categories.symbols' }),
+ flags: intl.formatMessage({ id: 'app.emojiPicker.categories.flags' }),
+ recent: intl.formatMessage({ id: 'app.emojiPicker.categories.recent' }),
+ search: intl.formatMessage({ id: 'app.emojiPicker.categories.search' }),
+ },
+ categorieslabel: intl.formatMessage({ id: 'app.emojiPicker.categories.label' }),
+ skintones: {
+ 1: intl.formatMessage({ id: 'app.emojiPicker.skintones.1' }),
+ 2: intl.formatMessage({ id: 'app.emojiPicker.skintones.2' }),
+ 3: intl.formatMessage({ id: 'app.emojiPicker.skintones.3' }),
+ 4: intl.formatMessage({ id: 'app.emojiPicker.skintones.4' }),
+ 5: intl.formatMessage({ id: 'app.emojiPicker.skintones.5' }),
+ 6: intl.formatMessage({ id: 'app.emojiPicker.skintones.6' }),
+ },
+ };
+
+ return (
+ onEmojiSelect(emojiObject, event)}
+ emojiSize={24}
+ i18n={i18n}
+ previewPosition="none"
+ skinTonePosition="none"
+ theme="light"
+ dynamicWidth
+ exceptEmojis={emojisToExclude}
+ />
+ );
+};
+
+EmojiPicker.propTypes = propTypes;
+
+export default injectIntl(EmojiPicker);
diff --git a/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js
new file mode 100644
index 00000000..fda0dd7d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.js
@@ -0,0 +1,35 @@
+import styled, { css } from 'styled-components';
+
+const EmojiWrapper = styled.span`
+ padding-top: 0.9em;
+ padding-bottom: 0.1em;
+ ${({ selected }) => !selected && css`
+ :hover {
+ border-radius:100%;
+ outline-color: transparent;
+ outline-style:solid;
+ box-shadow: 0 0 0 0.25em #eee;
+ background-color: #eee;
+ opacity: 0.75;
+ }
+ `}
+ ${({ selected }) => selected && css`
+ em-emoji {
+ cursor: not-allowed;
+ }
+ `}
+ ${({ selected, emoji }) => selected && selected !== emoji && css`
+ opacity: 0.75;
+ `}
+ ${({ selected, emoji }) => selected && selected === emoji && css`
+ border-radius:100%;
+ outline-color: transparent;
+ outline-style:solid;
+ box-shadow: 0 0 0 0.25em #eee;
+ background-color: #eee;
+ `}
+`;
+
+export default {
+ EmojiWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss
new file mode 100644
index 00000000..4b33f6de
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/emoji-picker/reactions-picker/styles.scss
@@ -0,0 +1,27 @@
+.dropdownContent {
+ section[class^="emoji-mart-search"] {
+ display: none !important;
+ }
+ section[class^="emoji-mart emoji-mart-light"] {
+ display: unset;
+ border: unset;
+ }
+ div[class^="emoji-mart-bar"] {
+ display: none !important;
+ }
+ section[aria-label^="Frequently Used"] {
+ display: none !important;
+ }
+ div[class^="emoji-mart-category-label"] {
+ display: none !important;
+ }
+ div[class^="emoji-mart-scroll"] {
+ overflow-y: unset;
+ height: unset;
+ }
+ ul[class^="emoji-mart-category-list"] {
+ span {
+ cursor: pointer !important;
+ }
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/emoji-rain/component.jsx b/src/2.7.12/imports/ui/components/emoji-rain/component.jsx
new file mode 100644
index 00000000..fed1c286
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/emoji-rain/component.jsx
@@ -0,0 +1,121 @@
+import React, { useRef, useState, useEffect } from 'react';
+import Settings from '/imports/ui/services/settings';
+import Service from './service';
+import logger from '/imports/startup/client/logger';
+
+const EmojiRain = ({ reactions }) => {
+ const containerRef = useRef(null);
+ const [isAnimating, setIsAnimating] = useState(false);
+ const EMOJI_SIZE = Meteor.settings.public.app.emojiRain.emojiSize;
+ const NUMBER_OF_EMOJIS = Meteor.settings.public.app.emojiRain.numberOfEmojis;
+ const EMOJI_RAIN_ENABLED = Meteor.settings.public.app.emojiRain.enabled;
+
+ const { animations } = Settings.application;
+
+ function createEmojiRain(emoji) {
+ const coord = Service.getInteractionsButtonCoordinates();
+ const flyingEmojis = [];
+
+ if (coord == null) {
+ logger.warn({
+ logCode: 'interactions_emoji_rain_no_coord',
+ }, 'No coordinates for interactions button, skipping emoji rain');
+ return;
+ }
+
+ for (i = 0; i < NUMBER_OF_EMOJIS; i++) {
+ const initialPosition = {
+ x: coord.x + coord.width / 8,
+ y: coord.y + coord.height / 5,
+ };
+ const endPosition = {
+ x: Math.floor(Math.random() * 100) + coord.x - 100 / 2,
+ y: Math.floor(Math.random() * 300) + coord.y / 2,
+ };
+ const scale = Math.floor(Math.random() * (8 - 4 + 1)) - 40;
+ const sec = Math.floor(Math.random() * 1700) + 2000;
+
+ const shapeElement = document.createElement('svg');
+ const emojiElement = document.createElement('text');
+ emojiElement.setAttribute('x', '50%');
+ emojiElement.setAttribute('y', '50%');
+ emojiElement.innerHTML = emoji;
+
+ shapeElement.style.position = 'absolute';
+ shapeElement.style.left = `${initialPosition.x}px`;
+ shapeElement.style.top = `${initialPosition.y}px`;
+ shapeElement.style.transform = `scaleX(0.${scale}) scaleY(0.${scale})`;
+ shapeElement.style.transition = `${sec}ms`;
+ shapeElement.style.fontSize = `${EMOJI_SIZE}em`;
+ shapeElement.style.pointerEvents = 'none';
+
+ shapeElement.appendChild(emojiElement);
+ containerRef.current.appendChild(shapeElement);
+
+ flyingEmojis.push({ shapeElement, endPosition });
+ }
+
+ requestAnimationFrame(() => setTimeout(() => flyingEmojis.forEach((emoji) => {
+ const { shapeElement, endPosition } = emoji;
+ shapeElement.style.left = `${endPosition.x}px`;
+ shapeElement.style.top = `${endPosition.y}px`;
+ shapeElement.style.transform = 'scaleX(0) scaleY(0)';
+ }), 0));
+
+ setTimeout(() => {
+ flyingEmojis.forEach((emoji) => emoji.shapeElement.remove());
+ flyingEmojis.length = 0;
+ }, 2000);
+ }
+
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ setIsAnimating(false);
+ } else if (EMOJI_RAIN_ENABLED && animations) {
+ setIsAnimating(true);
+ }
+ };
+
+ useEffect(() => {
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (EMOJI_RAIN_ENABLED && animations && !isAnimating && !document.hidden) {
+ setIsAnimating(true);
+ } else if (!animations) {
+ setIsAnimating(false);
+ }
+ }, [EMOJI_RAIN_ENABLED, animations, isAnimating]);
+
+ useEffect(() => {
+ if (isAnimating && animations !== false) {
+ reactions.forEach((reaction) => {
+ const currentTime = new Date().getTime();
+ const secondsSinceCreated = (currentTime - reaction.creationDate.getTime()) / 1000;
+ if (secondsSinceCreated <= 1 && (reaction.reaction !== 'none')) {
+ createEmojiRain(reaction.reaction);
+ }
+ });
+ }
+ }, [isAnimating, reactions]);
+
+ const containerStyle = {
+ width: '100vw',
+ height: '100vh',
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ overflow: 'hidden',
+ pointerEvents: 'none',
+ zIndex: 2,
+ };
+
+ return
;
+};
+
+export default EmojiRain;
diff --git a/src/2.7.12/imports/ui/components/emoji-rain/container.jsx b/src/2.7.12/imports/ui/components/emoji-rain/container.jsx
new file mode 100644
index 00000000..d6f24ef7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/emoji-rain/container.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import EmojiRain from './component';
+import UserReaction from '/imports/api/user-reaction';
+import Auth from '/imports/ui/services/auth';
+
+const EmojiRainContainer = (props) => ;
+
+export default withTracker(() => ({
+ reactions: UserReaction.find({ meetingId: Auth.meetingID }).fetch(),
+}))(EmojiRainContainer);
diff --git a/src/2.7.12/imports/ui/components/emoji-rain/service.js b/src/2.7.12/imports/ui/components/emoji-rain/service.js
new file mode 100644
index 00000000..b172559c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/emoji-rain/service.js
@@ -0,0 +1,13 @@
+const getInteractionsButtonCoordinates = () => {
+ const el = document.getElementById('interactionsButton');
+
+ if (!el) return null;
+
+ const coordinate = el.getBoundingClientRect();
+
+ return coordinate;
+};
+
+export default {
+ getInteractionsButtonCoordinates,
+};
diff --git a/src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx b/src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx
new file mode 100644
index 00000000..47c8ce5b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/end-meeting-confirmation/component.jsx
@@ -0,0 +1,80 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component';
+
+const intlMessages = defineMessages({
+ endMeetingTitle: {
+ id: 'app.endMeeting.title',
+ description: 'end meeting title',
+ },
+ endMeetingDescription: {
+ id: 'app.endMeeting.description',
+ description: 'end meeting description with affected users information',
+ },
+ endMeetingNoUserDescription: {
+ id: 'app.endMeeting.noUserDescription',
+ description: 'end meeting description',
+ },
+ contentWarning: {
+ id: 'app.endMeeting.contentWarning',
+ description: 'end meeting content warning',
+ },
+ confirmButtonLabel: {
+ id: 'app.endMeeting.yesLabel',
+ description: 'end meeting confirm button label',
+ },
+});
+
+const { warnAboutUnsavedContentOnMeetingEnd } = Meteor.settings.public.app;
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ endMeeting: PropTypes.func.isRequired,
+ meetingTitle: PropTypes.string.isRequired,
+ users: PropTypes.number.isRequired,
+};
+
+class EndMeetingComponent extends PureComponent {
+ render() {
+ const {
+ users, intl, endMeeting, meetingTitle,
+ isOpen, onRequestClose, priority, setIsOpen,
+ } = this.props;
+
+ const title = intl.formatMessage(intlMessages.endMeetingTitle, { 0: meetingTitle });
+
+ let description = users > 0
+ ? intl.formatMessage(intlMessages.endMeetingDescription, { 0: users })
+ : intl.formatMessage(intlMessages.endMeetingNoUserDescription);
+
+ if (warnAboutUnsavedContentOnMeetingEnd) {
+ // the double breakline it to put one empty line between the descriptions
+ description += `\n\n${intl.formatMessage(intlMessages.contentWarning)}`;
+ }
+
+ return (
+
+ );
+ }
+}
+
+EndMeetingComponent.propTypes = propTypes;
+
+export default injectIntl(EndMeetingComponent);
diff --git a/src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx b/src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx
new file mode 100644
index 00000000..51e8b98f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/end-meeting-confirmation/container.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { makeCall } from '/imports/ui/services/api';
+import EndMeetingComponent from './component';
+import Service from './service';
+import logger from '/imports/startup/client/logger';
+
+const EndMeetingContainer = (props) => ;
+
+export default withTracker((props) => ({
+ endMeeting: () => {
+ logger.warn({
+ logCode: 'moderator_forcing_end_meeting',
+ extraInfo: { logType: 'user_action' },
+ }, 'this user clicked on EndMeeting and confirmed, removing everybody from the meeting');
+ makeCall('endMeeting');
+ props.setIsOpen(false);
+ },
+ meetingTitle: Service.getMeetingTitle(),
+ users: Service.getUsers(),
+}))(EndMeetingContainer);
diff --git a/src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js b/src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js
new file mode 100644
index 00000000..5817cdab
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/end-meeting-confirmation/service.js
@@ -0,0 +1,25 @@
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+
+const getMeetingTitle = () => {
+ const meeting = Meetings.findOne({
+ meetingId: Auth.meetingID,
+ }, { fields: { 'meetingProp.name': 1, 'breakoutProps.sequence': 1 } });
+
+ return meeting.meetingProp.name;
+};
+
+const getUsers = () => {
+ const numUsers = Users.find({
+ meetingId: Auth.meetingID,
+ userId: { $ne: Auth.userID },
+ }, { fields: { _id: 1 } }).count();
+
+ return numUsers;
+};
+
+export default {
+ getUsers,
+ getMeetingTitle,
+};
diff --git a/src/2.7.12/imports/ui/components/error-screen/component.jsx b/src/2.7.12/imports/ui/components/error-screen/component.jsx
new file mode 100644
index 00000000..15cb0f37
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/error-screen/component.jsx
@@ -0,0 +1,149 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Meteor } from 'meteor/meteor';
+import { Session } from 'meteor/session';
+import AudioManager from '/imports/ui/services/audio-manager';
+import logger from '/imports/startup/client/logger';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ 503: {
+ id: 'app.error.503',
+ },
+ 500: {
+ id: 'app.error.500',
+ defaultMessage: 'Oops, something went wrong',
+ },
+ 410: {
+ id: 'app.error.410',
+ },
+ 409: {
+ id: 'app.error.409',
+ },
+ 408: {
+ id: 'app.error.408',
+ },
+ 404: {
+ id: 'app.error.404',
+ defaultMessage: 'Not found',
+ },
+ 403: {
+ id: 'app.error.403',
+ },
+ 401: {
+ id: 'app.error.401',
+ },
+ 400: {
+ id: 'app.error.400',
+ },
+ meeting_ended: {
+ id: 'app.meeting.endedMessage',
+ },
+ user_logged_out_reason: {
+ id: 'app.error.userLoggedOut',
+ },
+ validate_token_failed_eject_reason: {
+ id: 'app.error.ejectedUser',
+ },
+ banned_user_rejoining_reason: {
+ id: 'app.error.userBanned',
+ },
+ joined_another_window_reason: {
+ id: 'app.error.joinedAnotherWindow',
+ },
+ user_inactivity_eject_reason: {
+ id: 'app.meeting.logout.userInactivityEjectReason',
+ },
+ user_requested_eject_reason: {
+ id: 'app.meeting.logout.ejectedFromMeeting',
+ },
+ max_participants_reason: {
+ id: 'app.meeting.logout.maxParticipantsReached',
+ },
+ guest_deny: {
+ id: 'app.guest.guestDeny',
+ },
+ duplicate_user_in_meeting_eject_reason: {
+ id: 'app.meeting.logout.duplicateUserEjectReason',
+ },
+ not_enough_permission_eject_reason: {
+ id: 'app.meeting.logout.permissionEjectReason',
+ },
+ able_to_rejoin_user_disconnected_reason: {
+ id: 'app.error.disconnected.rejoin',
+ },
+});
+
+const propTypes = {
+ code: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]),
+};
+
+const defaultProps = {
+ code: '500',
+ callback: async () => {},
+};
+
+class ErrorScreen extends PureComponent {
+ componentDidMount() {
+ const { code, callback } = this.props;
+ const log = code === '403' ? 'warn' : 'error';
+ AudioManager.exitAudio();
+ callback().finally(() => {
+ Meteor.disconnect();
+ });
+ logger[log]({ logCode: 'startup_client_usercouldnotlogin_error' }, `User could not log in HTML5, hit ${code}`);
+ }
+
+ render() {
+ const {
+ intl,
+ code,
+ children,
+ } = this.props;
+
+ let formatedMessage = intl.formatMessage(intlMessages[defaultProps.code]);
+
+ if (code in intlMessages) {
+ formatedMessage = intl.formatMessage(intlMessages[code]);
+ }
+
+ let errorMessageDescription = Session.get('errorMessageDescription');
+
+ if (errorMessageDescription in intlMessages) {
+ errorMessageDescription = intl.formatMessage(intlMessages[errorMessageDescription]);
+ }
+
+ return (
+
+
+ {formatedMessage}
+
+ {
+ !errorMessageDescription
+ || formatedMessage === errorMessageDescription
+ || (
+
+ {errorMessageDescription}
+
+ )
+ }
+
+
+ {code}
+
+
+ {children}
+
+
+ );
+ }
+}
+
+export default injectIntl(ErrorScreen);
+
+ErrorScreen.propTypes = propTypes;
+ErrorScreen.defaultProps = defaultProps;
diff --git a/src/2.7.12/imports/ui/components/error-screen/styles.js b/src/2.7.12/imports/ui/components/error-screen/styles.js
new file mode 100644
index 00000000..7554699a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/error-screen/styles.js
@@ -0,0 +1,57 @@
+import styled from 'styled-components';
+import {
+ colorWhite,
+ colorGrayDark,
+ colorGrayLighter,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const Background = styled.div`
+ position: fixed;
+ display: flex;
+ flex-flow: column;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ background-color: ${colorGrayDark};
+ color: ${colorWhite};
+ text-align: center;
+`;
+
+const Message = styled.h1`
+ margin: 0;
+ color: ${colorWhite};
+ font-size: 1.75rem;
+ font-weight: 400;
+ margin-bottom: 1rem;
+`;
+
+const SessionMessage = styled.div`
+ margin: 0;
+ color: ${colorWhite};
+ font-weight: 400;
+ margin-bottom: 1rem;
+ font-size: 1.25rem;
+`;
+
+const Separator = styled.div`
+ height: 0;
+ width: 5rem;
+ border: 1px solid ${colorGrayLighter};
+ margin: 1.5rem 0 1.5rem 0;
+ align-self: center;
+ opacity: .75;
+`;
+
+const CodeError = styled.h1`
+ margin: 0;
+ font-size: 1.5rem;
+ color: ${colorWhite};
+`;
+
+export default {
+ Background,
+ Message,
+ SessionMessage,
+ Separator,
+ CodeError,
+};
diff --git a/src/2.7.12/imports/ui/components/external-video-player/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/component.jsx
new file mode 100644
index 00000000..d1cd24fa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/component.jsx
@@ -0,0 +1,728 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import {
+ sendMessage,
+ onMessage,
+ removeAllListeners,
+ getPlayingState,
+} from './service';
+import deviceInfo from '/imports/utils/deviceInfo';
+
+import logger from '/imports/startup/client/logger';
+
+import Subtitles from './subtitles/component';
+import VolumeSlider from './volume-slider/component';
+import ReloadButton from '/imports/ui/components/reload-button/component';
+import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container';
+
+import ArcPlayer from '/imports/ui/components/external-video-player/custom-players/arc-player';
+import PeerTubePlayer from '/imports/ui/components/external-video-player/custom-players/peertube';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+import { uniqueId } from '/imports/utils/string-utils';
+import { throttle } from 'radash';
+
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ autoPlayWarning: {
+ id: 'app.externalVideo.autoPlayWarning',
+ description: 'Shown when user needs to interact with player to make it work',
+ },
+ refreshLabel: {
+ id: 'app.externalVideo.refreshLabel',
+ },
+ fullscreenLabel: {
+ id: 'app.externalVideo.fullscreenLabel',
+ },
+ subtitlesOn: {
+ id: 'app.externalVideo.subtitlesOn',
+ },
+ subtitlesOff: {
+ id: 'app.externalVideo.subtitlesOff',
+ },
+});
+
+const SYNC_INTERVAL_SECONDS = 5;
+const THROTTLE_INTERVAL_SECONDS = 0.5;
+const AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS = 5;
+const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
+const THROTTLE_RELOAD_INTERVAL = 5000;
+
+Styled.VideoPlayer.addCustomPlayer(PeerTubePlayer);
+Styled.VideoPlayer.addCustomPlayer(ArcPlayer);
+
+class VideoPlayer extends Component {
+ static clearVideoListeners() {
+ removeAllListeners('play');
+ removeAllListeners('stop');
+ removeAllListeners('playerUpdate');
+ removeAllListeners('presenterReady');
+ }
+
+ constructor(props) {
+ super(props);
+
+ const { isPresenter } = props;
+
+ this.player = null;
+ this.syncInterval = null;
+ this.autoPlayTimeout = null;
+ this.hasPlayedBefore = false;
+ this.playerIsReady = false;
+
+ this.lastMessage = null;
+ this.lastMessageTimestamp = Date.now();
+
+ this.throttleTimeout = null;
+
+ this.state = {
+ subtitlesOn: false,
+ muted: false,
+ playing: false,
+ autoPlayBlocked: false,
+ volume: 1,
+ playbackRate: 1,
+ key: 0,
+ played:0,
+ loaded:0,
+ };
+
+ this.hideVolume = {
+ Vimeo: true,
+ Facebook: true,
+ ArcPlayer: true,
+ //YouTube: true,
+ };
+
+ this.opts = {
+ // default option for all players, can be overwritten
+ playerOptions: {
+ autoplay: true,
+ playsinline: true,
+ controls: isPresenter,
+ },
+ file: {
+ attributes: {
+ controls: isPresenter ? 'controls' : '',
+ autoplay: 'autoplay',
+ playsinline: 'playsinline',
+ },
+ },
+ facebook: {
+ controls: isPresenter,
+ },
+ dailymotion: {
+ params: {
+ controls: isPresenter,
+ },
+ },
+ youtube: {
+ playerVars: {
+ autoplay: 1,
+ modestbranding: 1,
+ autohide: 1,
+ rel: 0,
+ ecver: 2,
+ controls: isPresenter ? 1 : 0,
+ cc_lang_pref: document.getElementsByTagName('html')[0].lang.substring(0, 2),
+ },
+ },
+ peertube: {
+ isPresenter,
+ },
+ twitch: {
+ options: {
+ controls: isPresenter,
+ },
+ playerId: 'externalVideoPlayerTwitch',
+ },
+ preload: true,
+ showHoverToolBar: false,
+ };
+
+ this.registerVideoListeners = this.registerVideoListeners.bind(this);
+ this.autoPlayBlockDetected = this.autoPlayBlockDetected.bind(this);
+ this.handleFirstPlay = this.handleFirstPlay.bind(this);
+ this.handleReload = throttle({ interval: THROTTLE_RELOAD_INTERVAL }, this.handleReload.bind(this));
+ this.handleOnProgress = this.handleOnProgress.bind(this);
+ this.handleOnReady = this.handleOnReady.bind(this);
+ this.handleOnPlay = this.handleOnPlay.bind(this);
+ this.handleOnPause = this.handleOnPause.bind(this);
+ this.handleVolumeChanged = this.handleVolumeChanged.bind(this);
+ this.handleOnMuted = this.handleOnMuted.bind(this);
+ this.sendSyncMessage = this.sendSyncMessage.bind(this);
+ this.getCurrentPlaybackRate = this.getCurrentPlaybackRate.bind(this);
+ this.getCurrentTime = this.getCurrentTime.bind(this);
+ this.getCurrentVolume = this.getCurrentVolume.bind(this);
+ this.getMuted = this.getMuted.bind(this);
+ this.setPlaybackRate = this.setPlaybackRate.bind(this);
+ this.onBeforeUnload = this.onBeforeUnload.bind(this);
+ this.toggleSubtitle = this.toggleSubtitle.bind(this);
+
+ this.mobileHoverSetTimeout = null;
+ }
+
+ componentDidMount() {
+ const {
+ layoutContextDispatch,
+ } = this.props;
+
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+
+ clearInterval(this.syncInterval);
+ clearTimeout(this.autoPlayTimeout);
+
+ VideoPlayer.clearVideoListeners();
+ this.registerVideoListeners();
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_HAS_EXTERNAL_VIDEO,
+ value: true,
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: true,
+ });
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const { isPresenter } = this.props;
+ const { playing } = this.state;
+
+ // If user is presenter we don't re-render playing state changes
+ // Because he's in control of the play/pause status
+ if (nextProps.isPresenter && isPresenter && nextState.playing !== playing) {
+ return false;
+ }
+
+ return true;
+ }
+
+ componentDidUpdate(prevProp) {
+ // Detect presenter change and redo the sync and listeners to reassign video to the new one
+ const { isPresenter } = this.props;
+ if (isPresenter !== prevProp.isPresenter) {
+ VideoPlayer.clearVideoListeners();
+
+ clearInterval(this.syncInterval);
+ clearTimeout(this.autoPlayTimeout);
+
+ this.registerVideoListeners();
+ }
+ }
+
+ componentWillUnmount() {
+ const {
+ layoutContextDispatch,
+ hidePresentationOnJoin,
+ } = this.props;
+
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+
+ VideoPlayer.clearVideoListeners();
+
+ clearInterval(this.syncInterval);
+ clearTimeout(this.autoPlayTimeout);
+
+ this.player = null;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_HAS_EXTERNAL_VIDEO,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: Session.get('presentationLastState'),
+ });
+
+ if (hidePresentationOnJoin) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: false,
+ });
+ }
+ }
+
+ toggleSubtitle() {
+ this.setState((state) => {
+ return { subtitlesOn: !state.subtitlesOn };
+ }, () => {
+ const { subtitlesOn } = this.state;
+ const { isPresenter } = this.props;
+ const internalPlayer = this?.player?.getInternalPlayer();
+ if (!internalPlayer) return;
+ if (!isPresenter && subtitlesOn) {
+ if (typeof internalPlayer.setOption === 'function') {
+ internalPlayer.setOption('captions', 'reload', true);
+ }
+ } else if (typeof internalPlayer.unloadModule === 'function') {
+ internalPlayer.unloadModule('captions');
+ }
+ });
+ }
+
+ handleOnReady() {
+ const { hasPlayedBefore, playerIsReady } = this;
+
+ if (hasPlayedBefore || playerIsReady) {
+ return;
+ }
+
+ this.playerIsReady = true;
+
+ this.autoPlayTimeout = setTimeout(
+ this.autoPlayBlockDetected,
+ AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS * 1000,
+ );
+ }
+
+ handleFirstPlay() {
+ const { isPresenter } = this.props;
+ const { hasPlayedBefore } = this;
+
+ if (!hasPlayedBefore) {
+ this.hasPlayedBefore = true;
+ this.setState({ autoPlayBlocked: false });
+
+ clearTimeout(this.autoPlayTimeout);
+
+ if (isPresenter) {
+ this.sendSyncMessage('presenterReady');
+ }
+ }
+ }
+
+ handleOnPlay() {
+ const { isPresenter } = this.props;
+ const { playing } = this.state;
+
+ const curTime = this.getCurrentTime();
+
+ if (isPresenter && !playing) {
+ this.sendSyncMessage('play', { time: curTime });
+ }
+ this.setState({ playing: true });
+
+ this.handleFirstPlay();
+
+ if (!isPresenter && !playing) {
+ this.setState({ playing: false });
+ }
+ }
+
+ handleOnPause() {
+ const { isPresenter } = this.props;
+ const { playing } = this.state;
+
+ const curTime = this.getCurrentTime();
+
+ if (isPresenter && playing) {
+ this.sendSyncMessage('stop', { time: curTime });
+ }
+ this.setState({ playing: false });
+
+ this.handleFirstPlay();
+
+ if (!isPresenter && playing) {
+ this.setState({ playing: true });
+ }
+ }
+
+ handleOnProgress(data) {
+ const { mutedByEchoTest } = this.state;
+
+ const volume = this.getCurrentVolume();
+ const muted = this.getMuted();
+
+ const { played, loaded } = data;
+
+ this.setState({played, loaded});
+
+ if (!mutedByEchoTest) {
+ this.setState({ volume, muted });
+ }
+ }
+
+ handleVolumeChanged(volume) {
+ this.setState({ volume });
+ }
+
+ handleOnMuted(muted) {
+ const { mutedByEchoTest } = this.state;
+
+ if (!mutedByEchoTest) {
+ this.setState({ muted });
+ }
+ }
+
+ handleReload() {
+ const { key } = this.state;
+ // increment key and force a re-render of the video component
+ this.setState({ key: key + 1 });
+ }
+
+ onBeforeUnload() {
+ const { isPresenter } = this.props;
+
+ if (isPresenter) {
+ this.sendSyncMessage('stop');
+ }
+ }
+
+ static getDerivedStateFromProps(props) {
+ const { inEchoTest } = props;
+
+ return { mutedByEchoTest: inEchoTest };
+ }
+
+ getCurrentTime() {
+ if (this.player && this.player.getCurrentTime) {
+ return Math.round(this.player.getCurrentTime());
+ }
+ return 0;
+ }
+
+ getCurrentPlaybackRate() {
+ const intPlayer = this.player && this.player.getInternalPlayer();
+ const rate = (intPlayer && intPlayer.getPlaybackRate && intPlayer.getPlaybackRate());
+
+ return typeof (rate) === 'number' ? rate : 1;
+ }
+
+ setPlaybackRate(rate) {
+ const intPlayer = this.player && this.player.getInternalPlayer();
+ const currentRate = this.getCurrentPlaybackRate();
+
+ if (currentRate === rate) {
+ return;
+ }
+
+ this.setState({ playbackRate: rate });
+ if (intPlayer && intPlayer.setPlaybackRate) {
+ intPlayer.setPlaybackRate(rate);
+ }
+ }
+
+ getCurrentVolume() {
+ const { volume } = this.state;
+ const intPlayer = this.player && this.player.getInternalPlayer();
+
+ return (intPlayer && intPlayer.getVolume && intPlayer.getVolume() / 100.0) || volume;
+ }
+
+ getMuted() {
+ const { isPresenter } = this.props;
+ const { muted } = this.state;
+ const intPlayer = this?.player?.getInternalPlayer?.();
+
+ return isPresenter ? intPlayer?.isMuted?.() : muted;
+ }
+
+ autoPlayBlockDetected() {
+ this.setState({ autoPlayBlocked: true });
+ }
+
+ sendSyncMessage(msg, params) {
+ const timestamp = Date.now();
+
+ // If message is just a quick pause/un-pause just send nothing
+ const sinceLastMessage = (timestamp - this.lastMessageTimestamp) / 1000;
+ if ((
+ (msg === 'play' && this.lastMessage === 'stop')
+ || (msg === 'stop' && this.lastMessage === 'play'))
+ && sinceLastMessage < THROTTLE_INTERVAL_SECONDS) {
+ return clearTimeout(this.throttleTimeout);
+ }
+
+ // Ignore repeat presenter ready messages
+ if (this.lastMessage === msg && msg === 'presenterReady') {
+ logger.debug('Ignoring a repeated presenterReady message');
+ } else {
+ // Play/pause messages are sent with a delay, to permit cancelling it in case of
+ // quick sucessive play/pauses
+ const messageDelay = (msg === 'play' || msg === 'stop') ? THROTTLE_INTERVAL_SECONDS : 0;
+
+ this.throttleTimeout = setTimeout(() => {
+ sendMessage(msg, { ...params });
+ }, messageDelay * 1000);
+
+ this.lastMessage = msg;
+ this.lastMessageTimestamp = timestamp;
+ }
+ return true;
+ }
+
+ registerVideoListeners() {
+ const { isPresenter } = this.props;
+
+ if (isPresenter) {
+ this.syncInterval = setInterval(() => {
+ const { playing } = this.state;
+ const curTime = this.getCurrentTime();
+ const rate = this.getCurrentPlaybackRate();
+
+ // Always pause video if presenter is has not started sharing, e.g., blocked by autoplay
+ const playingState = this.hasPlayedBefore ? playing : false;
+
+ this.sendSyncMessage('playerUpdate', { rate, time: curTime, state: playingState });
+ }, SYNC_INTERVAL_SECONDS * 1000);
+ } else {
+ onMessage('play', ({ time }) => {
+ const { hasPlayedBefore, player } = this;
+
+ if (!player || !hasPlayedBefore) {
+ return;
+ }
+ this.seekTo(time);
+ this.setState({ playing: true });
+
+ logger.debug({ logCode: 'external_video_client_play' }, 'Play external video');
+ });
+
+ onMessage('stop', ({ time }) => {
+ const { hasPlayedBefore, player } = this;
+
+ if (!player || !hasPlayedBefore) {
+ return;
+ }
+ this.seekTo(time);
+ this.setState({ playing: false });
+
+ logger.debug({ logCode: 'external_video_client_stop' }, 'Stop external video');
+ });
+
+ onMessage('presenterReady', () => {
+ const { hasPlayedBefore } = this;
+
+ logger.debug({ logCode: 'external_video_presenter_ready' }, 'Presenter is ready to sync');
+
+ if (!hasPlayedBefore) {
+ this.setState({ playing: true });
+ }
+ });
+
+ onMessage('playerUpdate', (data) => {
+ const { hasPlayedBefore, player } = this;
+ const { playing } = this.state;
+ const { time, rate, state } = data;
+
+ if (!player || !hasPlayedBefore) {
+ return;
+ }
+
+ if (rate !== this.getCurrentPlaybackRate()) {
+ this.setPlaybackRate(rate);
+ logger.debug({
+ logCode: 'external_video_client_update_rate',
+ extraInfo: {
+ newRate: rate,
+ },
+ }, 'Change external video playback rate.');
+ }
+
+ this.seekTo(time);
+
+ const playingState = getPlayingState(state);
+ if (playing !== playingState) {
+ this.setState({ playing: playingState });
+ }
+ });
+ }
+ }
+
+ seekTo(time) {
+ const { player } = this;
+
+ if (!player) {
+ return logger.error('No player on seek');
+ }
+
+ // Seek if viewer has drifted too far away from presenter
+ if (Math.abs(this.getCurrentTime() - time) > SYNC_INTERVAL_SECONDS * 0.75) {
+ player.seekTo(time, true);
+ logger.debug({
+ logCode: 'external_video_client_update_seek',
+ extraInfo: { time },
+ }, `Seek external video to: ${time}`);
+ }
+ return true;
+ }
+
+ renderFullscreenButton() {
+ const { intl, fullscreenElementId, fullscreenContext } = this.props;
+
+ if (!ALLOW_FULLSCREEN) return null;
+
+ return (
+
+ );
+ }
+
+ render() {
+ const {
+ videoUrl,
+ isPresenter,
+ intl,
+ top,
+ left,
+ right,
+ height,
+ width,
+ fullscreenContext,
+ isResizing,
+ zIndex,
+ } = this.props;
+
+ const {
+ playing, playbackRate, mutedByEchoTest, autoPlayBlocked,
+ volume, muted, key, showHoverToolBar, played, loaded, subtitlesOn
+ } = this.state;
+
+ // This looks weird, but I need to get this nested player
+ const playerName = this.player && this.player.player
+ && this.player.player.player && this.player.player.player.constructor.name;
+
+ let toolbarStyle = 'hoverToolbar';
+
+ if (deviceInfo.isMobile && !showHoverToolBar) {
+ toolbarStyle = 'dontShowMobileHoverToolbar';
+ }
+
+ if (deviceInfo.isMobile && showHoverToolBar) {
+ toolbarStyle = 'showMobileHoverToolbar';
+ }
+ const isMinimized = width === 0 && height === 0;
+
+ return (
+
+ { this.playerParent = ref; }}
+ >
+ {
+ autoPlayBlocked
+ ? (
+
+ {intl.formatMessage(intlMessages.autoPlayWarning)}
+
+ )
+ : ''
+ }
+
+ { this.player = ref; }}
+ height="100%"
+ width="100%"
+ />
+ {
+ !isPresenter
+ ? [
+ (
+
+
+
+
+ {playerName === 'YouTube' && (
+
+ )}
+
+ {this.renderFullscreenButton()}
+
+
+
+
+
+
+
+ ),
+ (deviceInfo.isMobile && playing) && (
+ { this.overlay = ref; }}
+ onTouchStart={() => {
+ clearTimeout(this.mobileHoverSetTimeout);
+ this.setState({ showHoverToolBar: true });
+ }}
+ onTouchEnd={() => {
+ this.mobileHoverSetTimeout = setTimeout(
+ () => this.setState({ showHoverToolBar: false }),
+ 5000,
+ );
+ }}
+ />
+ ),
+ ]
+ : null
+ }
+
+
+ );
+ }
+}
+
+VideoPlayer.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ fullscreenElementId: PropTypes.string.isRequired,
+ fullscreenContext: PropTypes.bool.isRequired,
+};
+
+export default injectIntl(injectWbResizeEvent(VideoPlayer));
diff --git a/src/2.7.12/imports/ui/components/external-video-player/container.jsx b/src/2.7.12/imports/ui/components/external-video-player/container.jsx
new file mode 100644
index 00000000..a97d6f0d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/container.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { Session } from 'meteor/session';
+import { getVideoUrl } from './service';
+import ExternalVideoComponent from './component';
+import {
+ layoutSelect,
+ layoutSelectInput,
+ layoutSelectOutput,
+ layoutDispatch,
+} from '../layout/context';
+
+const ExternalVideoContainer = (props) => {
+ const fullscreenElementId = 'ExternalVideo';
+ const externalVideo = layoutSelectOutput((i) => i.externalVideo);
+ const cameraDock = layoutSelectInput((i) => i.cameraDock);
+ const { isResizing } = cameraDock;
+ const layoutContextDispatch = layoutDispatch();
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const { element } = fullscreen;
+ const fullscreenContext = (element === fullscreenElementId);
+
+ return (
+
+ );
+};
+
+const LAYOUT_CONFIG = Meteor.settings.public.layout;
+
+export default withTracker(({ isPresenter }) => {
+ const inEchoTest = Session.get('inEchoTest');
+ return {
+ inEchoTest,
+ isPresenter,
+ videoUrl: getVideoUrl(),
+ };
+})(ExternalVideoContainer);
diff --git a/src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx b/src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx
new file mode 100644
index 00000000..e87a24da
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/custom-players/arc-player.jsx
@@ -0,0 +1,208 @@
+import loadScript from 'load-script';
+import React, { Component } from 'react'
+
+const MATCH_URL = new RegExp("https?:\/\/(.*)(instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
+
+const SDK_URL = 'https://files.instructuremedia.com/instructure-media-script/instructure-media-1.1.0.js';
+
+const EMBED_PATH = "/embed/";
+
+// Util function to load an external SDK or return the SDK if it is already loaded
+// From https://github.com/CookPete/react-player/blob/master/src/utils.js
+const resolves = {};
+export function getSDK (url, sdkGlobal, sdkReady = null, isLoaded = () => true, fetchScript = loadScript) {
+ if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) {
+ return Promise.resolve(window[sdkGlobal])
+ }
+ return new Promise((resolve, reject) => {
+ // If we are already loading the SDK, add the resolve
+ // function to the existing array of resolve functions
+ if (resolves[url]) {
+ resolves[url].push(resolve);
+ return
+ }
+ resolves[url] = [resolve];
+ const onLoaded = sdk => {
+ // When loaded, resolve all pending promises
+ resolves[url].forEach(resolve => resolve(sdk))
+ };
+ if (sdkReady) {
+ const previousOnReady = window[sdkReady];
+ window[sdkReady] = function () {
+ if (previousOnReady) previousOnReady();
+ onLoaded(window[sdkGlobal])
+ }
+ }
+ fetchScript(url, err => {
+ if (err) {
+ reject(err);
+ }
+ window[sdkGlobal] = url;
+ if (!sdkReady) {
+ onLoaded(window[sdkGlobal])
+ }
+ })
+ })
+}
+
+export class ArcPlayer extends Component {
+ static displayName = 'ArcPlayer';
+
+ static canPlay = url => {
+ return MATCH_URL.test(url)
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.currentTime = 0;
+ this.updateCurrentTime = this.updateCurrentTime.bind(this);
+ this.getCurrentTime = this.getCurrentTime.bind(this);
+ this.getEmbedUrl = this.getEmbedUrl.bind(this);
+ this.onStateChange = this.onStateChange.bind(this);
+ }
+
+ componentDidMount () {
+ this.props.onMount && this.props.onMount(this)
+ }
+
+ load() {
+ new Promise((resolve, reject) => {
+ this.render();
+ resolve();
+ })
+ .then(() => { return getSDK(SDK_URL, 'ArcPlayer') })
+ .then(() => {
+ this.player = new InstructureMedia.Player('arcPlayerContainer', {
+ height: '100%',
+ width: '100%',
+ embedUrl: this.getEmbedUrl(),
+ events: {
+ onStateChange: this.onStateChange,
+ }
+ });
+ this.player.playVideo();
+ });
+ }
+
+ onStateChange(event) {
+ if (!this.player) {
+ return;
+ }
+
+ this.player.getCurrentTime().then((t) => {
+ this.updateCurrentTime(t.data);
+ });
+
+ if (event.data === "CUED") {
+ this.props.onReady();
+ } else if (event.data === "PLAYING") {
+ this.props.onPlay && this.props.onPlay();
+ } else if (event.data === "PAUSED") {
+ this.props.onPause && this.props.onPause();
+ } else if (event.data === "SEEKED") {
+ // TODO
+ } else if (event.data === "SEEKING") {
+ // TODO
+ }
+ }
+
+ updateCurrentTime(e) {
+ this.currentTime = e;
+ }
+
+ getVideoId() {
+ const { url } = this.props;
+ const m = url.match(MATCH_URL);
+ return m && m[4];
+ }
+
+ getHostUrl() {
+ const { url } = this.props;
+ const m = url.match(MATCH_URL);
+ return m && 'https://' + m[1] + m[2];
+ }
+
+ getEmbedUrl() {
+ let url = this.getHostUrl() + EMBED_PATH + this.getVideoId();
+ return url;
+ }
+
+ play() {
+ if (this.player) {
+ this.player.playVideo();
+ }
+ }
+
+ pause() {
+ if (this.player) {
+ this.player.pauseVideo();
+ }
+ }
+
+ stop() {
+ // TODO: STOP
+ }
+
+ seekTo(seconds) {
+ if (this.player) {
+ this.player.seekTo(seconds);
+ }
+ }
+
+ setVolume(fraction) {
+ // console.log("SET VOLUME");
+ }
+
+ setLoop(loop) {
+ // console.log("SET LOOP");
+ }
+
+ mute() {
+ // console.log("SET MUTE");
+ }
+
+ unmute() {
+ // console.log("SET UNMUTE");
+ }
+
+ getDuration() {
+ //console.log("GET DURATION");
+ }
+
+ getCurrentTime () {
+ if (this.player) {
+ this.player.getCurrentTime().then((t) => {
+ this.updateCurrentTime(t.data);
+ });
+ }
+
+ return this.currentTime;
+ }
+
+ getSecondsLoaded () {
+ }
+
+ render () {
+ const style = {
+ width: '100%',
+ height: '100%',
+ overflow: 'hidden',
+ backgroundColor: 'black'
+ };
+ return (
+ {
+ this.container = container;
+ }}
+ >
+
+ )
+ }
+}
+
+export default ArcPlayer;
+
diff --git a/src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx b/src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx
new file mode 100644
index 00000000..d7a88663
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/custom-players/panopto.jsx
@@ -0,0 +1,16 @@
+const MATCH_URL = /https?\:\/\/([^\/]+\/Panopto)(\/Pages\/Viewer\.aspx\?id=)([-a-zA-Z0-9]+)/;
+
+export class Panopto {
+
+ static canPlay = url => {
+ return MATCH_URL.test(url)
+ };
+
+ static getSocialUrl(url) {
+ const m = url.match(MATCH_URL);
+ return 'https://' + m[1] + '/Podcast/Social/' + m[3] + '.mp4';
+ }
+}
+
+export default Panopto;
+
diff --git a/src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx b/src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx
new file mode 100644
index 00000000..13f5cb3a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/custom-players/peertube.jsx
@@ -0,0 +1,216 @@
+import loadScript from 'load-script';
+import React, { Component } from 'react'
+
+//To work with PeerTube >=v3.3 URL patterns
+const MATCH_URL = new RegExp("(https?)://(.*)(/videos/watch/|/w/)(.*)");
+
+const SDK_URL = 'https://unpkg.com/@peertube/embed-api@0.0.4/build/player.min.js';
+
+// Util function to load an external SDK or return the SDK if it is already loaded
+// From https://github.com/CookPete/react-player/blob/master/src/utils.js
+const resolves = {};
+export function getSDK (url, sdkGlobal, sdkReady = null, isLoaded = () => true, fetchScript = loadScript) {
+ if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) {
+ return Promise.resolve(window[sdkGlobal])
+ }
+ return new Promise((resolve, reject) => {
+ // If we are already loading the SDK, add the resolve
+ // function to the existing array of resolve functions
+ if (resolves[url]) {
+ resolves[url].push(resolve);
+ return
+ }
+ resolves[url] = [resolve];
+ const onLoaded = sdk => {
+ // When loaded, resolve all pending promises
+ resolves[url].forEach(resolve => resolve(sdk))
+ };
+ if (sdkReady) {
+ const previousOnReady = window[sdkReady];
+ window[sdkReady] = function () {
+ if (previousOnReady) previousOnReady();
+ onLoaded(window[sdkGlobal])
+ }
+ }
+ fetchScript(url, err => {
+ if (err) {
+ reject(err);
+ }
+ window[sdkGlobal] = url;
+ if (!sdkReady) {
+ onLoaded(window[sdkGlobal])
+ }
+ })
+ })
+}
+
+export class PeerTubePlayer extends Component {
+ static displayName = 'PeerTubePlayer';
+
+ static canPlay = url => {
+ return MATCH_URL.test(url)
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.player = this;
+ this._player = null;
+
+ this.currentTime = 0;
+ this.playbackRate = 1;
+ this.getCurrentTime = this.getCurrentTime.bind(this);
+ this.getEmbedUrl = this.getEmbedUrl.bind(this);
+ this.setupEvents = this.setupEvents.bind(this);
+ }
+
+ componentDidMount () {
+ this.props.onMount && this.props.onMount(this)
+ }
+
+ getEmbedUrl = () => {
+ const { url, config } = this.props;
+ const m = MATCH_URL.exec(url);
+
+ const isPresenter = config && config.peertube && config.peertube.isPresenter;
+
+ return `${m[1]}://${m[2]}/videos/embed/${m[4]}?api=1&controls=${isPresenter}`;
+ };
+
+ load() {
+ new Promise((resolve, reject) => {
+ this.render();
+ resolve();
+ })
+ .then(() => { return getSDK(SDK_URL, 'PeerTube') })
+ .then(() => {
+ this._player = new window['PeerTubePlayer'](this.container);
+
+ this.setupEvents();
+
+ return this._player.ready.then(() => {
+ return this.props.onReady();
+ });
+ });
+ }
+
+ setupEvents(event) {
+ const player = this._player;
+
+ if (!player) {
+ return;
+ }
+
+ player.addEventListener("playbackStatusUpdate", (data) => {
+ this.currentTime = data.position;
+ });
+ player.addEventListener("playbackStatusChange", (data) => {
+ if (data === 'playing') {
+ this.props.onPlay();
+ } else {
+ this.props.onPause();
+ }
+ });
+
+ }
+
+ play() {
+ if (this._player) {
+ this._player.play();
+ }
+ }
+
+ pause() {
+ if (this._player) {
+ this._player.pause();
+ }
+ }
+
+ stop() {
+ }
+
+ seekTo(seconds) {
+ if (this._player) {
+ this._player.seek(seconds);
+ }
+ }
+
+ setVolume(fraction) {
+ this._player.setVolume(fraction);
+ }
+
+ getVolume() {
+ return this._player.getVolume();
+ }
+
+ setLoop(loop) {
+ // console.log("SET LOOP");
+ }
+
+ mute() {
+ // console.log("SET MUTE");
+ }
+
+ unmute() {
+ // console.log("SET UNMUTE");
+ }
+
+ getDuration() {
+ //console.log("GET DURATION");
+ }
+
+ getCurrentTime () {
+ return this.currentTime;
+ }
+
+ getSecondsLoaded () {
+ }
+
+ getPlaybackRate () {
+
+ if (this._player) {
+ this._player.getPlaybackRate().then((rate) => {
+ this.playbackRate = rate;
+ });
+ }
+
+ return this.playbackRate;
+ }
+
+ setPlaybackRate (rate) {
+
+ if (this._player) {
+ this._player.setPlaybackRate(rate);
+ }
+ }
+
+ render () {
+ const style = {
+ width: '100%',
+ height: '100%',
+ margin: 0,
+ padding: 0,
+ border: 0,
+ overflow: 'hidden',
+ };
+ const { url } = this.props;
+
+ return (
+
+ )
+ }
+}
+
+export default PeerTubePlayer;
+
diff --git a/src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx
new file mode 100644
index 00000000..4601fd54
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/modal/component.jsx
@@ -0,0 +1,147 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { isUrlValid } from '../service';
+import Settings from '/imports/ui/services/settings';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ start: {
+ id: 'app.externalVideo.start',
+ description: 'Share external video',
+ },
+ urlError: {
+ id: 'app.externalVideo.urlError',
+ description: 'Not a video URL error',
+ },
+ input: {
+ id: 'app.externalVideo.input',
+ description: 'Video URL',
+ },
+ urlInput: {
+ id: 'app.externalVideo.urlInput',
+ description: 'URL input field placeholder',
+ },
+ title: {
+ id: 'app.externalVideo.title',
+ description: 'Modal title',
+ },
+ close: {
+ id: 'app.externalVideo.close',
+ description: 'Close',
+ },
+ note: {
+ id: 'app.externalVideo.noteLabel',
+ description: 'provides hint about Shared External videos',
+ },
+});
+
+class ExternalVideoModal extends Component {
+ constructor(props) {
+ super(props);
+
+ const { videoUrl } = props;
+
+ this.state = {
+ url: videoUrl,
+ sharing: videoUrl,
+ };
+
+ this.startWatchingHandler = this.startWatchingHandler.bind(this);
+ this.updateVideoUrlHandler = this.updateVideoUrlHandler.bind(this);
+ this.renderUrlError = this.renderUrlError.bind(this);
+ this.updateVideoUrlHandler = this.updateVideoUrlHandler.bind(this);
+ }
+
+ startWatchingHandler() {
+ const {
+ startWatching,
+ setIsOpen,
+ } = this.props;
+
+ const { url } = this.state;
+
+ startWatching(url.trim());
+ setIsOpen(false);
+ }
+
+ updateVideoUrlHandler(ev) {
+ this.setState({ url: ev.target.value });
+ }
+
+ renderUrlError() {
+ const { intl } = this.props;
+ const { url } = this.state;
+ const { animations } = Settings.application;
+
+ const valid = (!url || url.length <= 3) || isUrlValid(url);
+
+ return (
+ !valid
+ ? (
+
+ {intl.formatMessage(intlMessages.urlError)}
+
+ )
+ : null
+ );
+ }
+
+ render() {
+ const { intl, setIsOpen, isOpen, onRequestClose, priority, } = this.props;
+ const { url, sharing } = this.state;
+ const { animations } = Settings.application;
+
+ const startDisabled = !isUrlValid(url);
+
+ return (
+ setIsOpen(false)}
+ contentLabel={intl.formatMessage(intlMessages.title)}
+ title={intl.formatMessage(intlMessages.title)}
+ {...{
+ setIsOpen,
+ isOpen,
+ onRequestClose,
+ priority,
+ }}
+ >
+
+
+
+ {intl.formatMessage(intlMessages.input)}
+ { e.stopPropagation(); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ />
+
+
+ {intl.formatMessage(intlMessages.note)}
+
+
+
+
+ {this.renderUrlError()}
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(ExternalVideoModal);
diff --git a/src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx b/src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx
new file mode 100644
index 00000000..9cff24d5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/modal/container.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ExternalVideoModal from './component';
+import { startWatching, getVideoUrl } from '../service';
+
+const ExternalVideoModalContainer = props => ;
+
+export default withTracker(() => ({
+ startWatching,
+ videoUrl: getVideoUrl(),
+}))(ExternalVideoModalContainer);
diff --git a/src/2.7.12/imports/ui/components/external-video-player/modal/styles.js b/src/2.7.12/imports/ui/components/external-video-player/modal/styles.js
new file mode 100644
index 00000000..872ecf35
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/modal/styles.js
@@ -0,0 +1,110 @@
+import styled from 'styled-components';
+import {
+ borderSize,
+ borderRadius,
+ smPaddingY,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorText,
+ colorGrayLighter,
+ colorGray,
+ colorBlueLight,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import Button from '/imports/ui/components/common/button/component';
+
+const UrlError = styled.div`
+ color: red;
+ padding: 1em 0 2.5em 0;
+
+ ${({ animations }) => animations && `
+ transition: 1s;
+ `}
+`;
+
+const ExternalVideoModal = styled(ModalSimple)`
+ padding: 1rem;
+ min-height: 23rem;
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 0;
+ margin-right: auto;
+ margin-left: auto;
+ width: 100%;
+`;
+
+const VideoUrl = styled.div`
+ margin: 0 ${borderSize} 0 ${borderSize};
+
+ & > label {
+ display: block;
+ }
+
+ & > label input {
+ display: block;
+ margin: 10px 0 10px 0;
+ padding: 0.4em;
+ color: ${colorText};
+ line-height: 2rem;
+ width: 100%;
+ font-family: inherit;
+ font-weight: inherit;
+ border: 1px solid ${colorGrayLighter};
+ border-radius: ${borderRadius};
+
+ ${({ animations }) => animations && `
+ transition: box-shadow .2s;
+ `}
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+ }
+
+ & > span {
+ font-weight: 600;
+ }
+`;
+
+const ExternalVideoNote = styled.div`
+ color: ${colorGray};
+ font-size: ${fontSizeSmall};
+ font-style: italic;
+ padding-top: ${smPaddingY};
+`;
+
+const StartButton = styled(Button)`
+ display: flex;
+ align-self: center;
+
+ &:focus {
+ outline: none !important;
+ }
+
+ & > i {
+ color: #3c5764;
+ }
+
+ margin: 0;
+ display: block;
+ position: absolute;
+ bottom: ${mdPaddingX};
+`;
+
+export default {
+ UrlError,
+ ExternalVideoModal,
+ Content,
+ VideoUrl,
+ ExternalVideoNote,
+ StartButton,
+};
diff --git a/src/2.7.12/imports/ui/components/external-video-player/service.js b/src/2.7.12/imports/ui/components/external-video-player/service.js
new file mode 100644
index 00000000..dd758449
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/service.js
@@ -0,0 +1,103 @@
+import { ExternalVideoMeetings } from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+
+import { getStreamer } from '/imports/api/external-videos';
+import { makeCall } from '/imports/ui/services/api';
+import NotesService from '/imports/ui/components/notes/service';
+
+import ReactPlayer from 'react-player';
+
+import Panopto from './custom-players/panopto';
+
+const YOUTUBE_SHORTS_REGEX = new RegExp(/^(?:https?:\/\/)?(?:www\.)?(youtube\.com\/shorts)\/.+$/);
+
+const isUrlValid = (url) => {
+ if (YOUTUBE_SHORTS_REGEX.test(url)) {
+ const shortsUrl = url.replace('shorts/', 'watch?v=');
+
+ return /^https.*$/.test(shortsUrl) && (ReactPlayer.canPlay(shortsUrl) || Panopto.canPlay(shortsUrl));
+ }
+
+ return /^https.*$/.test(url) && (ReactPlayer.canPlay(url) || Panopto.canPlay(url));
+};
+
+const startWatching = (url) => {
+ let externalVideoUrl = url;
+
+ if (YOUTUBE_SHORTS_REGEX.test(url)) {
+ const shortsUrl = url.replace('shorts/', 'watch?v=');
+ externalVideoUrl = shortsUrl;
+ } else if (Panopto.canPlay(url)) {
+ externalVideoUrl = Panopto.getSocialUrl(url);
+ }
+
+ // Close Shared Notes if open.
+ NotesService.pinSharedNotes(false);
+
+ makeCall('startWatchingExternalVideo', externalVideoUrl);
+};
+
+const stopWatching = () => {
+ makeCall('stopWatchingExternalVideo');
+};
+
+let lastMessage = null;
+
+const sendMessage = (event, data) => {
+
+ // don't re-send repeated update messages
+ if (lastMessage && lastMessage.event === event
+ && event === 'playerUpdate' && lastMessage.time === data.time) {
+ return;
+ }
+
+ // don't register to redis a viewer joined message
+ if (event === 'viewerJoined') {
+ return;
+ }
+
+ lastMessage = { ...data, event };
+
+ // Use an integer for playing state
+ // 0: stopped 1: playing
+ // We might use more states in the future
+ data.state = data.state ? 1 : 0;
+
+ makeCall('emitExternalVideoEvent', { status: event, playerStatus: data });
+};
+
+const onMessage = (message, func) => {
+ const streamer = getStreamer(Auth.meetingID);
+ streamer.on(message, func);
+};
+
+const removeAllListeners = (eventType) => {
+ const streamer = getStreamer(Auth.meetingID);
+ streamer.removeAllListeners(eventType);
+};
+
+const getVideoUrl = () => {
+ const meetingId = Auth.meetingID;
+ const externalVideo = ExternalVideoMeetings
+ .findOne({ meetingId }, { fields: { externalVideoUrl: 1 } });
+
+ return externalVideo && externalVideo.externalVideoUrl;
+};
+
+// Convert state (Number) to playing (Boolean)
+const getPlayingState = (state) => {
+ if (state === 1) return true;
+
+ return false;
+};
+
+export {
+ sendMessage,
+ onMessage,
+ removeAllListeners,
+ getVideoUrl,
+ isUrlValid,
+ startWatching,
+ stopWatching,
+ getPlayingState,
+};
diff --git a/src/2.7.12/imports/ui/components/external-video-player/styles.js b/src/2.7.12/imports/ui/components/external-video-player/styles.js
new file mode 100644
index 00000000..fd056d09
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/styles.js
@@ -0,0 +1,119 @@
+import styled from 'styled-components';
+import ReactPlayer from 'react-player';
+
+const VideoPlayerWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ ${({ fullscreen }) => fullscreen && `
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 99;
+ `}
+`;
+
+const AutoPlayWarning = styled.p`
+ position: absolute;
+ z-index: 100;
+ font-size: x-large;
+ color: white;
+ width: 100%;
+ background-color: rgba(6,23,42,0.5);
+ bottom: 20%;
+ vertical-align: middle;
+ text-align: center;
+ pointer-events: none;
+`;
+
+const VideoPlayer = styled(ReactPlayer)`
+ width: 100%;
+ height: 100%;
+ & > iframe {
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+ flex-shrink: 1;
+ position: relative;
+ overflow-x: hidden;
+ overflow-y: auto;
+ border-style: none;
+ border-bottom: none;
+ }
+`;
+
+const MobileControlsOverlay = styled.span`
+ position: absolute;
+ top:0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: transparent;
+`;
+
+const HoverToolbar = styled.div`
+ ${({ toolbarStyle }) => toolbarStyle === 'hoverToolbar' && `
+ display: none;
+
+ :hover > & {
+ display: flex;
+ }
+ `}
+
+ ${({ toolbarStyle }) => toolbarStyle === 'dontShowMobileHoverToolbar' && `
+ display: none;
+ `}
+
+ ${({ toolbarStyle }) => toolbarStyle === 'showMobileHoverToolbar' && `
+ display: flex;
+ z-index: 2;
+ `}
+`;
+
+const ProgressBar = styled.div`
+ position: absolute;
+ bottom: 0;
+ height: 5px;
+ width: 100%;
+
+ background-color: transparent;
+`;
+
+const Loaded = styled.div`
+ height: 100%;
+ background-color: gray;
+`;
+
+const Played = styled.div`
+ height: 100%;
+ background-color: #DF2721;
+`;
+
+const ButtonsWrapper = styled.div`
+ position: absolute;
+ right: auto;
+ left: 0;
+ bottom: 0;
+ top: 0;
+ display: flex;
+
+ [dir="rtl"] & {
+ right: 0;
+ left : auto;
+ }
+`;
+
+export default {
+ VideoPlayerWrapper,
+ AutoPlayWarning,
+ VideoPlayer,
+ MobileControlsOverlay,
+ HoverToolbar,
+ ProgressBar,
+ Loaded,
+ Played,
+ ButtonsWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx
new file mode 100644
index 00000000..e61329fa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/subtitles/component.jsx
@@ -0,0 +1,25 @@
+import React, { Component } from 'react';
+import Styled from './styles';
+
+class Subtitles extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ render() {
+ const { toggleSubtitle, label } = this.props;
+ return (
+
+ toggleSubtitle()}
+ label={label}
+ hideLabel
+ />
+
+ );
+ }
+}
+
+export default Subtitles;
diff --git a/src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js b/src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js
new file mode 100644
index 00000000..e9f491f1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/subtitles/styles.js
@@ -0,0 +1,46 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+
+import { colorTransparent } from '/imports/ui/stylesheets/styled-components/palette';
+
+const SubtitlesWrapper = styled.div`
+ background-color: ${colorTransparent};
+ cursor: pointer;
+ border: 0;
+ z-index: 2;
+ margin: 2px;
+
+ [dir="rtl"] & {
+ right: 0;
+ left : auto;
+ }
+`;
+
+const SubtitlesButton = styled(Button)`
+ &,
+ &:active,
+ &:hover,
+ &:focus {
+ border: none !important;
+
+ i {
+ border: none !important;
+ background-color: ${colorTransparent} !important;
+ }
+ }
+
+ padding: 5px;
+
+ &:hover {
+ border: 0;
+ }
+
+ i {
+ font-size: 1rem;
+ }
+`;
+
+export default {
+ SubtitlesWrapper,
+ SubtitlesButton,
+};
diff --git a/src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx
new file mode 100644
index 00000000..9c5e874c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/component.jsx
@@ -0,0 +1,91 @@
+import React, { Component } from 'react';
+import Styled from './styles';
+
+class VolumeSlider extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ volume: props.volume,
+ muted: props.muted,
+ };
+
+ this.handleOnChange = this.handleOnChange.bind(this);
+ this.getVolumeIcon = this.getVolumeIcon.bind(this);
+ this.setMuted = this.setMuted.bind(this);
+ }
+
+ componentDidUpdate(prevProp) {
+ const { volume, muted } = this.props;
+ const { volume: prevVolume, muted: prevMuted } = prevProp;
+
+ if (prevVolume !== volume) {
+ this.handleOnChange(volume);
+ }
+
+ if (prevMuted !== muted) {
+ this.setMuted(muted);
+ }
+ }
+
+ handleOnChange(volume) {
+ const { onVolumeChanged } = this.props;
+ onVolumeChanged(volume);
+
+ this.setState({ volume }, () => {
+ const { volume: stateVolume, muted } = this.state;
+ if (muted && stateVolume > 0) { // unmute if volume was raised during mute
+ this.setMuted(false);
+ } else if (stateVolume <= 0) { // mute if volume is turned to 0
+ this.setMuted(true);
+ }
+ });
+ }
+
+ setMuted(muted) {
+ const { onMuted } = this.props;
+ this.setState({ muted }, () => onMuted(muted));
+ }
+
+ getVolumeIcon() {
+ const { muted, volume } = this.state;
+
+ if (muted || volume <= 0) return 'volume_off';
+
+ if (volume <= 0.25) return 'volume_mute';
+
+ if (volume <= 0.75) return 'volume_down';
+
+ return 'volume_up';
+ }
+
+ render() {
+ const { muted, volume } = this.state;
+ const { hideVolume } = this.props;
+
+ if (hideVolume) {
+ return null;
+ }
+
+ return (
+
+ this.setMuted(!muted)}>
+
+
+ this.handleOnChange(e.target.valueAsNumber)}
+ />
+
+ );
+ }
+}
+
+export default VolumeSlider;
diff --git a/src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js
new file mode 100644
index 00000000..0a70e8fd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/external-video-player/volume-slider/styles.js
@@ -0,0 +1,37 @@
+import styled from 'styled-components';
+
+const Slider = styled.div`
+ width: 0.9em;
+ display: flex;
+ position: absolute;
+ bottom: 2.5em;
+ left: 1em;
+ padding: 0.25rem 0.5rem;
+ min-width: 200px;
+ background-color: rgba(0,0,0,0.5);
+ border-radius: 32px;
+
+ i {
+ color: white;
+ transition: 0.5s;
+ font-size: 200%;
+ cursor: pointer;
+ }
+ z-index: 2;
+`;
+
+const Volume = styled.span`
+ margin-right: 0.5em;
+ cursor: pointer;
+`;
+
+const VolumeSlider = styled.input`
+ width: 100%;
+ cursor: pointer;
+`;
+
+export default {
+ Slider,
+ Volume,
+ VolumeSlider,
+};
diff --git a/src/2.7.12/imports/ui/components/join-handler/component.jsx b/src/2.7.12/imports/ui/components/join-handler/component.jsx
new file mode 100644
index 00000000..6345e6da
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/join-handler/component.jsx
@@ -0,0 +1,267 @@
+import React, { Component } from 'react';
+import { Session } from 'meteor/session';
+import PropTypes from 'prop-types';
+import SanitizeHTML from 'sanitize-html';
+import Auth from '/imports/ui/services/auth';
+import { setCustomLogoUrl, setModeratorOnlyMessage } from '/imports/ui/components/user-list/service';
+import { makeCall } from '/imports/ui/services/api';
+import logger from '/imports/startup/client/logger';
+import LoadingScreen from '/imports/ui/components/common/loading-screen/component';
+import { CurrentUser } from '/imports/api/users';
+import Settings from '/imports/ui/services/settings';
+import { updateSettings } from '/imports/ui/components/settings/service';
+import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums';
+
+const propTypes = {
+ children: PropTypes.element.isRequired,
+};
+
+class JoinHandler extends Component {
+ static setError(codeError) {
+ if (codeError) Session.set('codeError', codeError);
+ }
+
+ constructor(props) {
+ super(props);
+ this.fetchToken = this.fetchToken.bind(this);
+
+ this.state = {
+ joined: false,
+ hasAlreadyJoined: false,
+ };
+ }
+
+ componentDidMount() {
+ this._isMounted = true;
+
+ if (!this.firstJoinTime) {
+ this.firstJoinTime = new Date();
+ }
+ Tracker.autorun((c) => {
+ const {
+ connected,
+ status,
+ } = Meteor.status();
+ const { hasAlreadyJoined } = this.state;
+ if (status === 'connecting' && !hasAlreadyJoined) {
+ this.setState({ joined: false });
+ }
+
+ logger.debug(`Initial connection status change. status: ${status}, connected: ${connected}`);
+ if (connected) {
+ const msToConnect = (new Date() - this.firstJoinTime) / 1000;
+ const secondsToConnect = parseFloat(msToConnect).toFixed(2);
+
+ logger.info({
+ logCode: 'joinhandler_component_initial_connection_time',
+ extraInfo: {
+ attemptForUserInfo: Auth.fullInfo,
+ timeToConnect: secondsToConnect,
+ },
+ }, `Connection to Meteor took ${secondsToConnect}s`);
+
+ this.firstJoinTime = undefined;
+ this.fetchToken();
+ } else if (status === 'failed') {
+ c.stop();
+
+ const msToConnect = (new Date() - this.firstJoinTime) / 1000;
+ const secondsToConnect = parseFloat(msToConnect).toFixed(2);
+ logger.info({
+ logCode: 'joinhandler_component_initial_connection_failed',
+ extraInfo: {
+ attemptForUserInfo: Auth.fullInfo,
+ timeToConnect: secondsToConnect,
+ },
+ }, `Connection to Meteor failed, took ${secondsToConnect}s`);
+
+ JoinHandler.setError('400');
+ Session.set('errorMessageDescription', 'Failed to connect to server');
+ this.firstJoinTime = undefined;
+ }
+ });
+ }
+
+ componentWillUnmount() {
+ this._isMounted = false;
+ }
+
+ async fetchToken() {
+ const { hasAlreadyJoined } = this.state;
+ const APP = Meteor.settings.public.app;
+ if (!this._isMounted) return;
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const sessionToken = urlParams.get('sessionToken');
+
+ if (!sessionToken) {
+ JoinHandler.setError('400');
+ Session.set('errorMessageDescription', 'Session token was not provided');
+ }
+
+ // Old credentials stored in memory were being used when joining a new meeting
+ if (!hasAlreadyJoined) {
+ Auth.clearCredentials();
+ }
+ const logUserInfo = () => {
+ const userInfo = window.navigator;
+
+ // Browser information is sent once on startup
+ // Sent here instead of Meteor.startup, as the
+ // user might not be validated by then, thus user's data
+ // would not be sent with this information
+ const clientInfo = {
+ language: userInfo.language,
+ userAgent: userInfo.userAgent,
+ screenSize: { width: window.screen.width, height: window.screen.height },
+ windowSize: { width: window.innerWidth, height: window.innerHeight },
+ bbbVersion: Meteor.settings.public.app.bbbServerVersion,
+ location: window.location.href,
+ };
+
+ logger.info({
+ logCode: 'joinhandler_component_clientinfo',
+ extraInfo: { clientInfo },
+ },
+ 'Log information about the client');
+ };
+
+ const setAuth = (resp) => {
+ const {
+ meetingID, internalUserID, authToken, logoutUrl,
+ fullname, externUserID, confname,
+ } = resp;
+ return new Promise((resolve) => {
+ Auth.set(
+ meetingID, internalUserID, authToken, logoutUrl,
+ sessionToken, fullname, externUserID, confname,
+ );
+ resolve(resp);
+ });
+ };
+
+ const setLogoutURL = (url) => {
+ Auth.logoutURL = url;
+ return true;
+ };
+
+ const setLogoURL = (resp) => {
+ setCustomLogoUrl(resp.customLogoURL);
+ return resp;
+ };
+
+ const setModOnlyMessage = (resp) => {
+ if (resp && resp.modOnlyMessage) {
+ const sanitizedModOnlyText = SanitizeHTML(resp.modOnlyMessage, {
+ allowedTags: ['a', 'b', 'br', 'i', 'img', 'li', 'small', 'span', 'strong', 'u', 'ul'],
+ allowedAttributes: {
+ a: ['href', 'name', 'target'],
+ img: ['src', 'width', 'height'],
+ },
+ allowedSchemes: ['https'],
+ });
+ setModeratorOnlyMessage(sanitizedModOnlyText);
+ }
+ return resp;
+ };
+
+ const setCustomData = (resp) => {
+ const { customdata } = resp;
+
+ return new Promise((resolve) => {
+ if (customdata.length) {
+ makeCall('addUserSettings', customdata).then((r) => resolve(r));
+ }
+ resolve(true);
+ });
+ };
+
+ const setBannerProps = (resp) => {
+ Session.set('bannerText', resp.bannerText);
+ Session.set('bannerColor', resp.bannerColor);
+ };
+
+ const setUserDefaultLayout = (response) => {
+ const settings = {
+ application: {
+ ...Settings.application,
+ selectedLayout: LAYOUT_TYPE[response.defaultLayout] || 'custom',
+ },
+ };
+ updateSettings(settings);
+ };
+
+ // use enter api to get params for the client
+ const url = `${APP.bbbWebBase}/api/enter?sessionToken=${sessionToken}`;
+ const fetchContent = await fetch(url, { credentials: 'include' });
+ const parseToJson = await fetchContent.json();
+ const { response } = parseToJson;
+
+ setLogoutURL(response.logoutUrl);
+ logUserInfo();
+
+ if (response.returncode !== 'FAILED') {
+ await setAuth(response);
+
+ setBannerProps(response);
+ setLogoURL(response);
+ setModOnlyMessage(response);
+
+ Tracker.autorun(async (cd) => {
+ const user = CurrentUser
+ .findOne({ userId: Auth.userID, approved: true }, { fields: { _id: 1 } });
+ if (user) {
+ await setCustomData(response);
+ setUserDefaultLayout(response);
+ cd.stop();
+ }
+ });
+
+ logger.info({
+ logCode: 'joinhandler_component_joinroutehandler_success',
+ extraInfo: {
+ response,
+ },
+ }, 'User successfully went through main.joinRouteHandler');
+ } else {
+
+ if(['missingSession','meetingForciblyEnded','notFound'].includes(response.messageKey)) {
+ JoinHandler.setError('410');
+ Session.set('errorMessageDescription', 'meeting_ended');
+ } else if(response.messageKey == "guestDeny") {
+ JoinHandler.setError('401');
+ Session.set('errorMessageDescription', 'guest_deny');
+ } else if(response.messageKey == "maxParticipantsReached") {
+ JoinHandler.setError('401');
+ Session.set('errorMessageDescription', 'max_participants_reason');
+ } else {
+ JoinHandler.setError('401');
+ Session.set('errorMessageDescription', response.message);
+ }
+
+ logger.error({
+ logCode: 'joinhandler_component_joinroutehandler_error',
+ extraInfo: {
+ response,
+ error: new Error(response.message),
+ },
+ }, 'User faced an error on main.joinRouteHandler.');
+ }
+ this.setState({
+ joined: true,
+ hasAlreadyJoined: true,
+ });
+ }
+
+ render() {
+ const { children } = this.props;
+ const { joined } = this.state;
+ return joined
+ ? children
+ : ( );
+ }
+}
+
+export default JoinHandler;
+
+JoinHandler.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/layout/context.jsx b/src/2.7.12/imports/ui/components/layout/context.jsx
new file mode 100644
index 00000000..d02693ed
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/context.jsx
@@ -0,0 +1,1243 @@
+import React, { useReducer } from 'react';
+import { createContext, useContextSelector } from 'use-context-selector';
+import PropTypes from 'prop-types';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+import { INITIAL_INPUT_STATE, INITIAL_OUTPUT_STATE } from './initState';
+
+// variable to debug in console log
+const debug = false;
+
+const debugActions = (action, value) => {
+ const baseStyles = [
+ 'color: #fff',
+ 'background-color: #d64541',
+ 'padding: 2px 4px',
+ 'border-radius: 2px',
+ ].join(';');
+ return debug && console.log(`%c${action}`, baseStyles, value);
+};
+
+const providerPropTypes = {
+ children: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.node),
+ PropTypes.node,
+ ]).isRequired,
+};
+
+const LayoutContextSelector = createContext();
+
+const initState = {
+ deviceType: null,
+ isRTL: false,
+ layoutType: DEFAULT_VALUES.layoutType,
+ fontSize: DEFAULT_VALUES.fontSize,
+ idChatOpen: DEFAULT_VALUES.idChatOpen,
+ fullscreen: {
+ element: '',
+ group: '',
+ },
+ input: INITIAL_INPUT_STATE,
+ output: INITIAL_OUTPUT_STATE,
+};
+
+const reducer = (state, action) => {
+ debugActions(action.type, action.value);
+ switch (action.type) {
+
+ case ACTIONS.SET_FOCUSED_CAMERA_ID: {
+ const { cameraDock } = state.input;
+ const { focusedId } = cameraDock;
+
+ if (focusedId === action.value) return state;
+
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ focusedId: action.value,
+ },
+ },
+ };
+ }
+
+ case ACTIONS.SET_LAYOUT_INPUT: {
+ if (state.input === action.value) return state;
+ return {
+ ...state,
+ input: action.value,
+ };
+ }
+
+ case ACTIONS.SET_AUTO_ARRANGE_LAYOUT: {
+ const { autoarrAngeLayout } = state.input;
+ if (autoarrAngeLayout === action.value) return state;
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ autoarrAngeLayout: action.value,
+ },
+ };
+ }
+
+ case ACTIONS.SET_IS_RTL: {
+ const { isRTL } = state;
+ if (isRTL === action.value) return state;
+ return {
+ ...state,
+ isRTL: action.value,
+ };
+ }
+
+ // LAYOUT TYPE
+ // using to load a diferent layout manager
+ case ACTIONS.SET_LAYOUT_TYPE: {
+ const { layoutType } = state.input;
+ if (layoutType === action.value) return state;
+ return {
+ ...state,
+ layoutType: action.value,
+ };
+ }
+
+ // FONT SIZE
+ case ACTIONS.SET_FONT_SIZE: {
+ const { fontSize } = state;
+ if (fontSize === action.value) return state;
+ return {
+ ...state,
+ fontSize: action.value,
+ };
+ }
+
+ // ID CHAT open in sidebar content panel
+ case ACTIONS.SET_ID_CHAT_OPEN: {
+ if (state.idChatOpen === action.value) return state;
+ return {
+ ...state,
+ idChatOpen: action.value,
+ };
+ }
+
+ // DEVICE
+ case ACTIONS.SET_DEVICE_TYPE: {
+ const { deviceType } = state;
+ if (deviceType === action.value) return state;
+ return {
+ ...state,
+ deviceType: action.value,
+ };
+ }
+
+ // BROWSER
+ case ACTIONS.SET_BROWSER_SIZE: {
+ const { width, height } = action.value;
+ const { browser } = state.input;
+ if (browser.width === width
+ && browser.height === height) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ browser: {
+ width,
+ height,
+ },
+ },
+ };
+ }
+
+ // BANNER BAR
+ case ACTIONS.SET_HAS_BANNER_BAR: {
+ const { bannerBar } = state.input;
+ if (bannerBar.hasBanner === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ bannerBar: {
+ ...bannerBar,
+ hasBanner: action.value,
+ },
+ },
+ };
+ }
+
+ // NOTIFICATIONS BAR
+ case ACTIONS.SET_HAS_NOTIFICATIONS_BAR: {
+ const { notificationsBar } = state.input;
+ if (notificationsBar.hasNotification === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ notificationsBar: {
+ ...notificationsBar,
+ hasNotification: action.value,
+ },
+ },
+ };
+ }
+
+ // NAV BAR
+ case ACTIONS.SET_NAVBAR_OUTPUT: {
+ const {
+ display, width, height, top, left, tabOrder, zIndex,
+ } = action.value;
+ const { navBar } = state.output;
+ if (navBar.display === display
+ && navBar.width === width
+ && navBar.height === height
+ && navBar.top === top
+ && navBar.left === left
+ && navBar.zIndex === zIndex
+ && navBar.tabOrder === tabOrder) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ navBar: {
+ ...navBar,
+ display,
+ width,
+ height,
+ top,
+ left,
+ tabOrder,
+ zIndex,
+ },
+ },
+ };
+ }
+
+ // ACTION BAR
+ case ACTIONS.SET_ACTIONBAR_OUTPUT: {
+ const {
+ display, width, height, innerHeight, top, left, padding, tabOrder, zIndex,
+ } = action.value;
+ const { actionBar } = state.output;
+ if (actionBar.display === display
+ && actionBar.width === width
+ && actionBar.height === height
+ && actionBar.innerHeight === innerHeight
+ && actionBar.top === top
+ && actionBar.left === left
+ && actionBar.padding === padding
+ && actionBar.zIndex === zIndex
+ && actionBar.tabOrder === tabOrder) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ actionBar: {
+ ...actionBar,
+ display,
+ width,
+ height,
+ innerHeight,
+ top,
+ left,
+ padding,
+ tabOrder,
+ zIndex,
+ },
+ },
+ };
+ }
+
+ // CAPTIONS
+ case ACTIONS.SET_CAPTIONS_OUTPUT: {
+ const {
+ left, right, maxWidth,
+ } = action.value;
+ const { captions } = state.output;
+ if (captions.left === left
+ && captions.right === right
+ && captions.maxWidth === maxWidth) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ captions: {
+ ...captions,
+ left,
+ right,
+ maxWidth,
+ },
+ },
+ };
+ }
+
+ // SIDEBAR NAVIGATION
+ case ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN: {
+ const { sidebarNavigation } = state.input;
+ if (sidebarNavigation.isOpen === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sidebarNavigation: {
+ ...sidebarNavigation,
+ isOpen: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_NAVIGATION_PANEL: {
+ const { sidebarNavigation } = state.input;
+ if (sidebarNavigation.sidebarNavPanel === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sidebarNavigation: {
+ ...sidebarNavigation,
+ sidebarNavPanel: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_NAVIGATION_SIZE: {
+ const { width, browserWidth } = action.value;
+ const { sidebarNavigation } = state.input;
+ if (sidebarNavigation.width === width
+ && sidebarNavigation.browserWidth === browserWidth) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sidebarNavigation: {
+ ...sidebarNavigation,
+ width,
+ browserWidth,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT: {
+ const {
+ display,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ tabOrder,
+ isResizable,
+ zIndex,
+ } = action.value;
+ const { sidebarNavigation } = state.output;
+ if (sidebarNavigation.display === display
+ && sidebarNavigation.minWidth === minWidth
+ && sidebarNavigation.maxWidth === maxWidth
+ && sidebarNavigation.width === width
+ && sidebarNavigation.minHeight === minHeight
+ && sidebarNavigation.height === height
+ && sidebarNavigation.maxHeight === maxHeight
+ && sidebarNavigation.top === top
+ && sidebarNavigation.left === left
+ && sidebarNavigation.right === right
+ && sidebarNavigation.tabOrder === tabOrder
+ && sidebarNavigation.zIndex === zIndex
+ && sidebarNavigation.isResizable === isResizable) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ sidebarNavigation: {
+ ...sidebarNavigation,
+ display,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ tabOrder,
+ isResizable,
+ zIndex,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE: {
+ const {
+ top, right, bottom, left,
+ } = action.value;
+ const { sidebarNavigation } = state.output;
+ if (sidebarNavigation.resizableEdge.top === top
+ && sidebarNavigation.resizableEdge.right === right
+ && sidebarNavigation.resizableEdge.bottom === bottom
+ && sidebarNavigation.resizableEdge.left === left) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ sidebarNavigation: {
+ ...sidebarNavigation,
+ resizableEdge: {
+ top,
+ right,
+ bottom,
+ left,
+ },
+ },
+ },
+ };
+ }
+
+ // SIDEBAR CONTENT
+ case ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN: {
+ const { sidebarContent, sidebarNavigation } = state.input;
+ if (sidebarContent.isOpen === action.value) {
+ return state;
+ }
+ // When opening content sidebar, the navigation sidebar should be opened as well
+ if (action.value === true) sidebarNavigation.isOpen = true;
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sidebarNavigation,
+ sidebarContent: {
+ ...sidebarContent,
+ isOpen: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_CONTENT_PANEL: {
+ const { sidebarContent } = state.input;
+ if (sidebarContent.sidebarContentPanel === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sidebarContent: {
+ ...sidebarContent,
+ sidebarContentPanel: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_CONTENT_SIZE: {
+ const {
+ width,
+ browserWidth,
+ height,
+ browserHeight,
+ } = action.value;
+ const { sidebarContent } = state.input;
+ if (sidebarContent.width === width
+ && sidebarContent.browserWidth === browserWidth
+ && sidebarContent.height === height
+ && sidebarContent.browserHeight === browserHeight) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sidebarContent: {
+ ...sidebarContent,
+ width,
+ browserWidth,
+ height,
+ browserHeight,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT: {
+ const {
+ display,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ currentPanelType,
+ tabOrder,
+ isResizable,
+ zIndex,
+ } = action.value;
+ const { sidebarContent } = state.output;
+ if (sidebarContent.display === display
+ && sidebarContent.minWidth === minWidth
+ && sidebarContent.width === width
+ && sidebarContent.maxWidth === maxWidth
+ && sidebarContent.minHeight === minHeight
+ && sidebarContent.height === height
+ && sidebarContent.maxHeight === maxHeight
+ && sidebarContent.top === top
+ && sidebarContent.left === left
+ && sidebarContent.right === right
+ && sidebarContent.tabOrder === tabOrder
+ && sidebarContent.zIndex === zIndex
+ && sidebarContent.isResizable === isResizable) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ sidebarContent: {
+ ...sidebarContent,
+ display,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ currentPanelType,
+ tabOrder,
+ isResizable,
+ zIndex,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE: {
+ const {
+ top, right, bottom, left,
+ } = action.value;
+ const { sidebarContent } = state.output;
+ if (sidebarContent.resizableEdge.top === top
+ && sidebarContent.resizableEdge.right === right
+ && sidebarContent.resizableEdge.bottom === bottom
+ && sidebarContent.resizableEdge.left === left) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ sidebarContent: {
+ ...sidebarContent,
+ resizableEdge: {
+ top,
+ right,
+ bottom,
+ left,
+ },
+ },
+ },
+ };
+ }
+
+ // MEDIA
+ case ACTIONS.SET_MEDIA_AREA_SIZE: {
+ const { width, height } = action.value;
+ const { mediaArea } = state.output;
+ if (mediaArea.width === width
+ && mediaArea.height === height) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ mediaArea: {
+ ...mediaArea,
+ width,
+ height,
+ },
+ },
+ };
+ }
+
+ // WEBCAMS
+ case ACTIONS.SET_NUM_CAMERAS: {
+ const { cameraDock } = state.input;
+ if (cameraDock.numCameras === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ numCameras: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_IS_DRAGGING: {
+ const { cameraDock } = state.input;
+ if (cameraDock.isDragging === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ isDragging: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_IS_RESIZING: {
+ const { cameraDock } = state.input;
+ if (cameraDock.isResizing === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ isResizing: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_POSITION: {
+ const { cameraDock } = state.input;
+ if (cameraDock.position === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ position: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_SIZE: {
+ const {
+ width, height, browserWidth, browserHeight,
+ } = action.value;
+ const { cameraDock } = state.input;
+ if (cameraDock.width === width
+ && cameraDock.height === height
+ && cameraDock.browserWidth === browserWidth
+ && cameraDock.browserHeight === browserHeight) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ width,
+ height,
+ browserWidth,
+ browserHeight,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_OPTIMAL_GRID_SIZE: {
+ const { width, height } = action.value;
+ const { cameraDock } = state.input;
+ const { cameraOptimalGridSize } = cameraDock;
+ if (cameraOptimalGridSize.width === width
+ && cameraOptimalGridSize.height === height) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ cameraDock: {
+ ...cameraDock,
+ cameraOptimalGridSize: {
+ width,
+ height,
+ },
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_OUTPUT: {
+ const {
+ display,
+ position,
+ minWidth,
+ width,
+ maxWidth,
+ presenterMaxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ tabOrder,
+ isDraggable,
+ resizableEdge,
+ zIndex,
+ focusedId,
+ } = action.value;
+ const { cameraDock } = state.output;
+ if (cameraDock.display === display
+ && cameraDock.position === position
+ && cameraDock.width === width
+ && cameraDock.maxWidth === maxWidth
+ && cameraDock.presenterMaxWidth === presenterMaxWidth
+ && cameraDock.height === height
+ && cameraDock.maxHeight === maxHeight
+ && cameraDock.top === top
+ && cameraDock.left === left
+ && cameraDock.right === right
+ && cameraDock.tabOrder === tabOrder
+ && cameraDock.isDraggable === isDraggable
+ && cameraDock.zIndex === zIndex
+ && cameraDock.resizableEdge === resizableEdge
+ && cameraDock.focusedId === focusedId) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ cameraDock: {
+ ...cameraDock,
+ display,
+ position,
+ minWidth,
+ width,
+ maxWidth,
+ presenterMaxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ tabOrder,
+ isDraggable,
+ resizableEdge,
+ zIndex,
+ focusedId,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_CAMERA_DOCK_IS_DRAGGABLE: {
+ const { cameraDock } = state.output;
+ if (cameraDock.isDraggable === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ cameraDock: {
+ ...cameraDock,
+ isDraggable: action.value,
+ },
+ },
+ };
+ }
+
+ // WEBCAMS DROP AREAS
+ case ACTIONS.SET_DROP_AREAS: {
+ const { dropZoneAreas } = state.output;
+ if (dropZoneAreas === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ dropZoneAreas: action.value,
+ },
+ };
+ }
+
+ // PRESENTATION
+ case ACTIONS.SET_PRESENTATION_IS_OPEN: {
+ const { presentation } = state.input;
+ if (presentation.isOpen === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ presentation: {
+ ...presentation,
+ isOpen: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_PRESENTATION_SLIDES_LENGTH: {
+ const { presentation } = state.input;
+ if (presentation.slidesLength === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ presentation: {
+ ...presentation,
+ slidesLength: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_PRESENTATION_NUM_CURRENT_SLIDE: {
+ const { presentation } = state.input;
+ if (presentation.currentSlide.num === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ presentation: {
+ ...presentation,
+ currentSlide: {
+ ...presentation.currentSlide,
+ num: action.value,
+ },
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_PRESENTATION_CURRENT_SLIDE_SIZE: {
+ const { width, height } = action.value;
+ const { presentation } = state.input;
+ const { currentSlide } = presentation;
+ if (currentSlide.size.width === width
+ && currentSlide.size.height === height) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ presentation: {
+ ...presentation,
+ currentSlide: {
+ ...currentSlide,
+ size: {
+ width,
+ height,
+ },
+ },
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_PRESENTATION_RESIZABLE_EDGE: {
+ const {
+ top, right, bottom, left,
+ } = action.value;
+ const { presentation } = state.output;
+ const { resizableEdge } = presentation;
+ if (resizableEdge.top === top
+ && resizableEdge.right === right
+ && resizableEdge.bottom === bottom
+ && resizableEdge.left === left) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ presentation: {
+ ...presentation,
+ resizableEdge: {
+ top,
+ right,
+ bottom,
+ left,
+ },
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_PRESENTATION_SIZE: {
+ const {
+ width, height, browserWidth, browserHeight,
+ } = action.value;
+ const { presentation } = state.input;
+ if (presentation.width === width
+ && presentation.height === height
+ && presentation.browserWidth === browserWidth
+ && presentation.browserHeight === browserHeight) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ presentation: {
+ ...presentation,
+ width,
+ height,
+ browserWidth,
+ browserHeight,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_PRESENTATION_OUTPUT: {
+ const {
+ display,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ tabOrder,
+ isResizable,
+ zIndex,
+ } = action.value;
+ const { presentation } = state.output;
+ if (presentation.display === display
+ && presentation.minWidth === minWidth
+ && presentation.width === width
+ && presentation.maxWidth === maxWidth
+ && presentation.minHeight === minHeight
+ && presentation.height === height
+ && presentation.maxHeight === maxHeight
+ && presentation.top === top
+ && presentation.left === left
+ && presentation.right === right
+ && presentation.tabOrder === tabOrder
+ && presentation.zIndex === zIndex
+ && presentation.isResizable === isResizable) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ presentation: {
+ ...presentation,
+ display,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ top,
+ left,
+ right,
+ tabOrder,
+ isResizable,
+ zIndex,
+ },
+ },
+ };
+ }
+
+ // FULLSCREEN
+ case ACTIONS.SET_FULLSCREEN_ELEMENT: {
+ const { fullscreen } = state;
+ if (fullscreen.element === action.value.element
+ && fullscreen.group === action.value.group) {
+ return state;
+ }
+ return {
+ ...state,
+ fullscreen: {
+ element: action.value.element,
+ group: action.value.group,
+ },
+ };
+ }
+
+ // SCREEN SHARE
+ case ACTIONS.SET_HAS_SCREEN_SHARE: {
+ const { screenShare } = state.input;
+ if (screenShare.hasScreenShare === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ screenShare: {
+ ...screenShare,
+ hasScreenShare: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SCREEN_SHARE_SIZE: {
+ const {
+ width, height, browserWidth, browserHeight,
+ } = action.value;
+ const { screenShare } = state.input;
+ if (screenShare.width === width
+ && screenShare.height === height
+ && screenShare.browserWidth === browserWidth
+ && screenShare.browserHeight === browserHeight) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ screenShare: {
+ ...screenShare,
+ width,
+ height,
+ browserWidth,
+ browserHeight,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_SCREEN_SHARE_OUTPUT: {
+ const {
+ width,
+ height,
+ top,
+ left,
+ right,
+ zIndex,
+ } = action.value;
+ const { screenShare } = state.output;
+ if (screenShare.width === width
+ && screenShare.height === height
+ && screenShare.top === top
+ && screenShare.left === left
+ && screenShare.right === right
+ && screenShare.zIndex === zIndex) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ screenShare: {
+ ...screenShare,
+ width,
+ height,
+ top,
+ left,
+ right,
+ zIndex,
+ },
+ },
+ };
+ }
+
+ // EXTERNAL VIDEO
+ case ACTIONS.SET_HAS_EXTERNAL_VIDEO: {
+ const { externalVideo } = state.input;
+ if (externalVideo.hasExternalVideo === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ externalVideo: {
+ ...externalVideo,
+ hasExternalVideo: action.value,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_EXTERNAL_VIDEO_SIZE: {
+ const {
+ width, height, browserWidth, browserHeight,
+ } = action.value;
+ const { externalVideo } = state.input;
+ if (externalVideo.width === width
+ && externalVideo.height === height
+ && externalVideo.browserWidth === browserWidth
+ && externalVideo.browserHeight === browserHeight) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ externalVideo: {
+ ...externalVideo,
+ width,
+ height,
+ browserWidth,
+ browserHeight,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT: {
+ const {
+ width,
+ height,
+ top,
+ left,
+ right,
+ } = action.value;
+ const { externalVideo } = state.output;
+ if (externalVideo.width === width
+ && externalVideo.height === height
+ && externalVideo.top === top
+ && externalVideo.left === left
+ && externalVideo.right === right) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ externalVideo: {
+ ...externalVideo,
+ width,
+ height,
+ top,
+ left,
+ right,
+ },
+ },
+ };
+ }
+
+ // NOTES
+ case ACTIONS.SET_SHARED_NOTES_OUTPUT: {
+ const {
+ width,
+ height,
+ top,
+ left,
+ right,
+ } = action.value;
+ const { sharedNotes } = state.output;
+ if (sharedNotes.width === width
+ && sharedNotes.height === height
+ && sharedNotes.top === top
+ && sharedNotes.left === left
+ && sharedNotes.right === right) {
+ return state;
+ }
+ return {
+ ...state,
+ output: {
+ ...state.output,
+ sharedNotes: {
+ ...sharedNotes,
+ width,
+ height,
+ top,
+ left,
+ right,
+ },
+ },
+ };
+ }
+ case ACTIONS.SET_NOTES_IS_PINNED: {
+ const { sharedNotes } = state.input;
+ if (sharedNotes.isPinned === action.value) {
+ return state;
+ }
+ return {
+ ...state,
+ input: {
+ ...state.input,
+ sharedNotes: {
+ ...sharedNotes,
+ isPinned: action.value,
+ },
+ },
+ };
+ }
+ default: {
+ throw new Error('Unexpected action');
+ }
+ }
+};
+
+const LayoutContextProvider = (props) => {
+ const [layoutContextState, layoutContextDispatch] = useReducer(reducer, initState);
+ const { children } = props;
+ return (
+
+ {children}
+
+ );
+};
+LayoutContextProvider.propTypes = providerPropTypes;
+
+const layoutSelect = (selector) => {
+ return useContextSelector(LayoutContextSelector, layout => selector(layout[0]));
+};
+const layoutSelectInput = (selector) => {
+ return useContextSelector(LayoutContextSelector, layout => selector(layout[0].input));
+};
+const layoutSelectOutput = (selector) => {
+ return useContextSelector(LayoutContextSelector, layout => selector(layout[0].output));
+};
+const layoutDispatch = () => {
+ return useContextSelector(LayoutContextSelector, layout => layout[1]);
+};
+
+export {
+ LayoutContextProvider,
+ layoutSelect,
+ layoutSelectInput,
+ layoutSelectOutput,
+ layoutDispatch,
+}
diff --git a/src/2.7.12/imports/ui/components/layout/defaultValues.js b/src/2.7.12/imports/ui/components/layout/defaultValues.js
new file mode 100644
index 00000000..eb8743e0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/defaultValues.js
@@ -0,0 +1,55 @@
+import { Meteor } from 'meteor/meteor';
+import { LAYOUT_TYPE, CAMERADOCK_POSITION, PANELS } from './enums';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+
+const DEFAULT_VALUES = {
+ layoutType: LAYOUT_TYPE.CUSTOM_LAYOUT,
+ panelType: 'chat',
+ idChatOpen: PUBLIC_CHAT_ID,
+ fontSize: 16,
+
+ cameraPosition: CAMERADOCK_POSITION.CONTENT_TOP,
+ cameraDockTabOrder: 4,
+ cameraDockMinHeight: 120,
+ cameraDockMinWidth: 120,
+ camerasMargin: 10,
+ captionsMargin: 10,
+
+ presentationTabOrder: 5,
+ presentationMinHeight: 220,
+ presentationToolbarMinWidth: 430,
+
+ bannerHeight: 34,
+
+ navBarHeight: 85,
+ navBarTop: 0,
+ navBarTabOrder: 3,
+
+ actionBarHeight: 42,
+ actionBarPadding: 11.2,
+ actionBarTabOrder: 6,
+
+ sidebarNavMaxWidth: 240,
+ sidebarNavMinWidth: 70,
+ sidebarNavHeight: '100%',
+ sidebarNavTop: 0,
+ sidebarNavLeft: 0,
+ sidebarNavTabOrder: 1,
+ sidebarNavPanel: PANELS.USERLIST,
+
+ sidebarContentMaxWidth: 800,
+ sidebarContentMinWidth: 70,
+ sidebarContentMinHeight: 200,
+ sidebarContentHeight: '100%',
+ sidebarContentTop: 0,
+ sidebarContentTabOrder: 2,
+ sidebarContentPanel: PANELS.NONE,
+};
+
+export default DEFAULT_VALUES;
+export {
+ LAYOUT_TYPE,
+ CAMERADOCK_POSITION,
+};
diff --git a/src/2.7.12/imports/ui/components/layout/enums.js b/src/2.7.12/imports/ui/components/layout/enums.js
new file mode 100644
index 00000000..4f9f5c4a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/enums.js
@@ -0,0 +1,112 @@
+export const LAYOUT_TYPE = {
+ CUSTOM_LAYOUT: 'custom',
+ SMART_LAYOUT: 'smart',
+ PRESENTATION_FOCUS: 'presentationFocus',
+ VIDEO_FOCUS: 'videoFocus',
+};
+
+export const DEVICE_TYPE = {
+ MOBILE: 'mobile',
+ TABLET_PORTRAIT: 'tablet_portrait',
+ TABLET_LANDSCAPE: 'tablet_landscape',
+ TABLET: 'tablet',
+ DESKTOP: 'desktop',
+};
+
+export const SMALL_VIEWPORT_BREAKPOINT = 640;
+
+export const CAMERADOCK_POSITION = {
+ CONTENT_TOP: 'contentTop',
+ CONTENT_RIGHT: 'contentRight',
+ CONTENT_BOTTOM: 'contentBottom',
+ CONTENT_LEFT: 'contentLeft',
+ SIDEBAR_CONTENT_BOTTOM: 'sidebarContentBottom',
+};
+
+export const ACTIONS = {
+ SET_AUTO_ARRANGE_LAYOUT: 'setAutoArrangeLayout',
+ SET_IS_RTL: 'setIsRTL',
+ SET_LAYOUT_TYPE: 'setLayoutType',
+ SET_DEVICE_TYPE: 'setDeviceType',
+ SET_FONT_SIZE: 'setFontSize',
+
+ SET_FOCUSED_CAMERA_ID: 'focusedId',
+
+ SET_LAYOUT_INPUT: 'setLayoutInput',
+
+ SET_SIDEBAR_NAVIGATION_PANEL: 'setSidebarNavigationPanel',
+ SET_SIDEBAR_CONTENT_PANEL: 'setSidebarcontentPanel',
+
+ SET_ID_CHAT_OPEN: 'setIdChatOpen',
+
+ SET_BROWSER_SIZE: 'setBrowserSize',
+
+ SET_HAS_BANNER_BAR: 'setHasBannerBar',
+ SET_HAS_NOTIFICATIONS_BAR: 'setHasNotificationsBar',
+
+ SET_NAVBAR_OUTPUT: 'setNavBarOutput',
+
+ SET_ACTIONBAR_OUTPUT: 'setActionBarOutput',
+
+ SET_SIDEBAR_NAVIGATION_IS_OPEN: 'setSidebarNavigationIsOpen',
+ SET_SIDEBAR_NAVIGATION_SIZE: 'setSidebarNavigationSize',
+ SET_SIDEBAR_NAVIGATION_OUTPUT: 'setSidebarNavigationOutput',
+ SET_SIDEBAR_NAVIGATION_IS_RESIZABLE: 'setSidebarNavigationIsResizable',
+ SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE: 'setSidebarNavigationResizableEdge',
+
+ SET_SIDEBAR_CONTENT_IS_OPEN: 'setSidebarContentIsOpen',
+ SET_SIDEBAR_CONTENT_SIZE: 'setSidebarContentSize',
+ SET_SIDEBAR_CONTENT_PANEL_TYPE: 'setSidebarContentPanelType',
+ SET_SIDEBAR_CONTENT_OUTPUT: 'setSidebarContentOutput',
+ SET_SIDEBAR_CONTENT_IS_RESIZABLE: 'setSidebarContentIsResizable',
+ SET_SIDEBAR_CONTENT_RESIZABLE_EDGE: 'setSidebarContentResizableEdge',
+
+ SET_MEDIA_AREA_SIZE: 'setMediaAreaSize',
+
+ SET_NUM_CAMERAS: 'setNumCameras',
+ SET_CAMERA_DOCK_IS_DRAGGING: 'setCameraDockIsDragging',
+ SET_CAMERA_DOCK_IS_RESIZING: 'setCameraDockIsResizing',
+ SET_CAMERA_DOCK_POSITION: 'setCameraDockPosition',
+ SET_CAMERA_DOCK_SIZE: 'setCameraDockSize',
+ SET_CAMERA_DOCK_OPTIMAL_GRID_SIZE: 'setCameraDockOptimalGridSize',
+ SET_CAMERA_DOCK_OUTPUT: 'setCameraDockOutput',
+ SET_CAMERA_DOCK_IS_DRAGGABLE: 'setCameraDockIsDraggable',
+ SET_CAMERA_DOCK_IS_RESIZABLE: 'setCameraDockIsResizable',
+ SET_CAMERA_DOCK_RESIZABLE_EDGE: 'setCameraDockResizableEdge',
+
+ SET_DROP_AREAS: 'setDropAreas',
+
+ SET_PRESENTATION_IS_OPEN: 'setPresentationIsOpen',
+ SET_PRESENTATION_CURRENT_SLIDE_SIZE: 'setPresentationCurrentSlideSize',
+ SET_PRESENTATION_NUM_CURRENT_SLIDE: 'setPresentationNumCurrentSlide',
+ SET_PRESENTATION_SLIDES_LENGTH: 'setPresentationSlidesLength',
+ SET_PRESENTATION_SIZE: 'setPresentationSize',
+ SET_PRESENTATION_OUTPUT: 'setPresentationOutput',
+ SET_PRESENTATION_IS_RESIZABLE: 'setPresentationIsResizable',
+ SET_PRESENTATION_RESIZABLE_EDGE: 'setPresentationResizableEdge',
+
+ SET_FULLSCREEN_ELEMENT: 'setFullscreenElement',
+
+ SET_HAS_SCREEN_SHARE: 'setHasScreenShare',
+ SET_SCREEN_SHARE_SIZE: 'setScreenShareSize',
+ SET_SCREEN_SHARE_OUTPUT: 'setScreenShareOutput',
+
+ SET_HAS_EXTERNAL_VIDEO: 'setHasExternalVideo',
+ SET_EXTERNAL_VIDEO_SIZE: 'setExternalVideoSize',
+ SET_EXTERNAL_VIDEO_OUTPUT: 'setExternalVideoOutput',
+
+ SET_SHARED_NOTES_OUTPUT: 'setSharedNotesOutput',
+ SET_NOTES_IS_PINNED: 'setNotesIsPinned',
+};
+
+export const PANELS = {
+ USERLIST: 'userlist',
+ CHAT: 'chat',
+ POLL: 'poll',
+ CAPTIONS: 'captions',
+ BREAKOUT: 'breakoutroom',
+ SHARED_NOTES: 'shared-notes',
+ TIMER: 'timer',
+ WAITING_USERS: 'waiting-users',
+ NONE: 'none',
+};
diff --git a/src/2.7.12/imports/ui/components/layout/initState.js b/src/2.7.12/imports/ui/components/layout/initState.js
new file mode 100644
index 00000000..14a199c8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/initState.js
@@ -0,0 +1,246 @@
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+
+export const INITIAL_INPUT_STATE = {
+ autoarrAngeLayout: true,
+ customParameters: {
+
+ },
+ browser: {
+ width: window.document.documentElement.clientWidth,
+ height: window.document.documentElement.clientHeight,
+ },
+ bannerBar: {
+ hasBanner: false,
+ },
+ notificationsBar: {
+ hasNotification: false,
+ },
+ navBar: {
+ hasNavBar: true,
+ height: DEFAULT_VALUES.navBarHeight,
+ },
+ actionBar: {
+ hasActionBar: true,
+ height: DEFAULT_VALUES.actionBarHeight,
+ },
+ sidebarNavigation: {
+ isOpen: true,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ sidebarNavPanel: DEFAULT_VALUES.sidebarNavPanel,
+ },
+ sidebarContent: {
+ isOpen: true,
+ currentPanelType: DEFAULT_VALUES.panelType,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ sidebarContentPanel: DEFAULT_VALUES.sidebarContentPanel,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ },
+ sidebarContentHorizontalResizer: {
+ isOpen: true,
+ currentPanelType: DEFAULT_VALUES.panelType,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ },
+ cameraDock: {
+ numCameras: 0,
+ position: DEFAULT_VALUES.cameraPosition,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ browserHeight: 0,
+ isDragging: false,
+ isResizing: false,
+ cameraOptimalGridSize: {
+ width: 0,
+ height: 0,
+ },
+ focusedId: 'none',
+ },
+ presentation: {
+ isOpen: true,
+ slidesLength: 0,
+ currentSlide: {
+ num: 0,
+ size: {
+ width: 0,
+ height: 0,
+ },
+ },
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ browserHeight: 0,
+ },
+ screenShare: {
+ hasScreenShare: false,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ browserHeight: 0,
+ },
+ externalVideo: {
+ hasExternalVideo: false,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ browserHeight: 0,
+ },
+ sharedNotes: {
+ isPinned: false,
+ width: 0,
+ height: 0,
+ browserWidth: 0,
+ browserHeight: 0,
+ },
+};
+
+export const INITIAL_OUTPUT_STATE = {
+ navBar: {
+ display: false,
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ zIndex: 1,
+ },
+ actionBar: {
+ display: false,
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ zIndex: 1,
+ },
+ captions: {
+ left: 0,
+ right: 0,
+ },
+ sidebarNavigation: {
+ display: true,
+ minWidth: 0,
+ width: 0,
+ maxWidth: 0,
+ minHeight: 0,
+ height: 0,
+ maxHeight: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ isResizable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ zIndex: 1,
+ },
+ sidebarContent: {
+ display: true,
+ minWidth: 0,
+ width: 0,
+ maxWidth: 0,
+ minHeight: 0,
+ height: 0,
+ maxHeight: 0,
+ top: 0,
+ left: 0,
+ currentPanelType: '',
+ tabOrder: 0,
+ isResizable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ zIndex: 1,
+ },
+ mediaArea: {
+ width: 0,
+ height: 0,
+ },
+ cameraDock: {
+ display: false,
+ position: null,
+ minWidth: 0,
+ width: 0,
+ maxWidth: 0,
+ minHeight: 0,
+ height: 0,
+ maxHeight: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ isDraggable: false,
+ isResizable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ zIndex: 1,
+ focusedId: 'none',
+ },
+ dropZoneAreas: {},
+ presentation: {
+ display: true,
+ minWidth: 0,
+ width: 0,
+ maxWidth: 0,
+ minHeight: 0,
+ height: 0,
+ maxHeight: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ isResizable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ presentationOrientation: 'horizontal',
+ zIndex: 1,
+ },
+ screenShare: {
+ display: false,
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+ zIndex: 1,
+ },
+ externalVideo: {
+ display: false,
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ zIndex: 1,
+ },
+ sharedNotes: {
+ display: false,
+ width: 0,
+ height: 0,
+ top: 0,
+ left: 0,
+ tabOrder: 0,
+ zIndex: 1,
+ },
+};
diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx
new file mode 100644
index 00000000..1e98edd4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/layout-manager/customLayout.jsx
@@ -0,0 +1,754 @@
+import { useEffect, useRef } from 'react';
+import { throttle } from '/imports/utils/throttle';
+import { layoutSelect, layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context';
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState';
+import { ACTIONS, CAMERADOCK_POSITION, PANELS } from '../enums';
+import Storage from '/imports/ui/services/storage/session';
+import { defaultsDeep } from '/imports/utils/array-utils';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+const windowWidth = () => window.document.documentElement.clientWidth;
+const windowHeight = () => window.document.documentElement.clientHeight;
+const min = (value1, value2) => (value1 <= value2 ? value1 : value2);
+const max = (value1, value2) => (value1 >= value2 ? value1 : value2);
+
+const CustomLayout = (props) => {
+ const { bannerAreaHeight, calculatesActionbarHeight, isMobile } = props;
+
+ function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+ }
+
+ const input = layoutSelect((i) => i.input);
+ const deviceType = layoutSelect((i) => i.deviceType);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const fontSize = layoutSelect((i) => i.fontSize);
+ const currentPanelType = layoutSelect((i) => i.currentPanelType);
+
+ const presentationInput = layoutSelectInput((i) => i.presentation);
+ const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
+ const screenShareInput = layoutSelectInput((i) => i.screenShare);
+ const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
+
+ const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
+ const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const actionbarInput = layoutSelectInput((i) => i.actionBar);
+ const navbarInput = layoutSelectInput((i) => i.navBar);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { isResizing } = cameraDockInput;
+
+ const prevDeviceType = usePrevious(deviceType);
+ const prevIsResizing = usePrevious(isResizing);
+
+ const throttledCalculatesLayout = throttle(() => calculatesLayout(),
+ 50, { trailing: true, leading: true });
+
+ useEffect(() => {
+ window.addEventListener('resize', () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_BROWSER_SIZE,
+ value: {
+ width: window.document.documentElement.clientWidth,
+ height: window.document.documentElement.clientHeight,
+ },
+ });
+ });
+ }, []);
+
+ useEffect(() => {
+ if (deviceType === null) return () => null;
+
+ if (deviceType !== prevDeviceType) {
+ // reset layout if deviceType changed
+ // not all options is supported in all devices
+ init();
+ } else {
+ throttledCalculatesLayout();
+ }
+ }, [input, deviceType, isRTL, fontSize, fullscreen]);
+
+ const calculatesDropAreas = (sidebarNavWidth, sidebarContentWidth, cameraDockBounds) => {
+ const { height: actionBarHeight } = calculatesActionbarHeight();
+ const mediaAreaHeight = windowHeight() - (DEFAULT_VALUES.navBarHeight + actionBarHeight);
+ const mediaAreaWidth = windowWidth() - (sidebarNavWidth + sidebarContentWidth);
+ const DROP_ZONE_DEFAUL_SIZE = 100;
+ const dropZones = {};
+ const sidebarSize = sidebarNavWidth + sidebarContentWidth;
+
+ dropZones[CAMERADOCK_POSITION.CONTENT_TOP] = {
+ top: DEFAULT_VALUES.navBarHeight,
+ left: !isRTL ? sidebarSize : null,
+ right: isRTL ? sidebarSize : null,
+ width: mediaAreaWidth,
+ height: DROP_ZONE_DEFAUL_SIZE,
+ zIndex: cameraDockBounds.zIndex,
+ };
+
+ dropZones[CAMERADOCK_POSITION.CONTENT_RIGHT] = {
+ top: DEFAULT_VALUES.navBarHeight + DROP_ZONE_DEFAUL_SIZE,
+ left: !isRTL ? windowWidth() - DROP_ZONE_DEFAUL_SIZE : 0,
+ height: mediaAreaHeight - 2 * DROP_ZONE_DEFAUL_SIZE,
+ width: DROP_ZONE_DEFAUL_SIZE,
+ zIndex: cameraDockBounds.zIndex,
+ };
+
+ dropZones[CAMERADOCK_POSITION.CONTENT_BOTTOM] = {
+ top: DEFAULT_VALUES.navBarHeight + mediaAreaHeight - DROP_ZONE_DEFAUL_SIZE,
+ left: !isRTL ? sidebarSize : null,
+ right: isRTL ? sidebarSize : null,
+ width: mediaAreaWidth,
+ height: DROP_ZONE_DEFAUL_SIZE,
+ zIndex: cameraDockBounds.zIndex,
+ };
+
+ dropZones[CAMERADOCK_POSITION.CONTENT_LEFT] = {
+ top: DEFAULT_VALUES.navBarHeight + DROP_ZONE_DEFAUL_SIZE,
+ left: !isRTL ? sidebarSize : null,
+ right: isRTL ? sidebarSize : null,
+ height: mediaAreaHeight - 2 * DROP_ZONE_DEFAUL_SIZE,
+ width: DROP_ZONE_DEFAUL_SIZE,
+ zIndex: cameraDockBounds.zIndex,
+ };
+
+ dropZones[CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM] = {
+ top: windowHeight() - DROP_ZONE_DEFAUL_SIZE,
+ left: !isRTL ? sidebarNavWidth : null,
+ right: isRTL ? sidebarNavWidth : null,
+ width: sidebarContentWidth,
+ height: DROP_ZONE_DEFAUL_SIZE,
+ zIndex: cameraDockBounds.zIndex,
+ };
+
+ return dropZones;
+ };
+
+ const init = () => {
+ const { sidebarContentPanel } = sidebarContentInput;
+
+ if (isMobile) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ sidebarNavPanel: sidebarNavigationInput.sidebarNavPanel,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel: sidebarContentInput.sidebarContentPanel,
+ },
+ sidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: input.externalVideo.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: input.screenShare.hasScreenShare,
+ width: input.screenShare.width,
+ height: input.screenShare.height,
+ },
+ sharedNotes: {
+ isPinned: sharedNotesInput.isPinned,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ sidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: input.externalVideo.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: input.screenShare.hasScreenShare,
+ width: input.screenShare.width,
+ height: input.screenShare.height,
+ },
+ sharedNotes: {
+ isPinned: sharedNotesInput.isPinned,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ }
+ Session.set('layoutReady', true);
+ throttledCalculatesLayout();
+ };
+
+ const calculatesSidebarContentHeight = (cameraDockHeight) => {
+ const { isOpen, slidesLength } = presentationInput;
+ const { hasExternalVideo } = externalVideoInput;
+ const { hasScreenShare } = screenShareInput;
+ const { isPinned: isSharedNotesPinned } = sharedNotesInput;
+
+ const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
+ const isGeneralMediaOff =
+ !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
+
+ let sidebarContentHeight = 0;
+ if (sidebarContentInput.isOpen) {
+ if (isMobile) {
+ sidebarContentHeight = windowHeight() - DEFAULT_VALUES.navBarHeight;
+ } else if (
+ cameraDockInput.numCameras > 0 &&
+ cameraDockInput.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM &&
+ isOpen &&
+ !isGeneralMediaOff
+ ) {
+ sidebarContentHeight = windowHeight() - cameraDockHeight;
+ } else {
+ sidebarContentHeight = windowHeight();
+ }
+ sidebarContentHeight -= bannerAreaHeight();
+ }
+ return sidebarContentHeight;
+ };
+
+ const calculatesCameraDockBounds = (sidebarNavWidth, sidebarContentWidth, mediaAreaBounds) => {
+ const { baseCameraDockBounds } = props;
+ const sidebarSize = sidebarNavWidth + sidebarContentWidth;
+
+ const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize);
+
+ // do not proceed if using values from LayoutEngine
+ if (Object.keys(baseBounds).length > 0) {
+ return baseBounds;
+ }
+
+ const {
+ camerasMargin,
+ cameraDockMinHeight,
+ cameraDockMinWidth,
+ navBarHeight,
+ presentationToolbarMinWidth,
+ } = DEFAULT_VALUES;
+
+ const cameraDockBounds = {};
+
+ let cameraDockHeight = 0;
+ let cameraDockWidth = 0;
+
+ const lastSize = Storage.getItem('webcamSize') || { width: 0, height: 0 };
+ let { width: lastWidth, height: lastHeight } = lastSize;
+
+ if (cameraDockInput.isDragging) cameraDockBounds.zIndex = 99;
+ else cameraDockBounds.zIndex = 1;
+
+ const isCameraTop = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_TOP;
+ const isCameraBottom = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_BOTTOM;
+ const isCameraLeft = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_LEFT;
+ const isCameraRight = cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_RIGHT;
+ const isCameraSidebar = cameraDockInput.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM;
+
+ const stoppedResizing = prevIsResizing && !isResizing;
+ if (stoppedResizing) {
+ const isCameraTopOrBottom =
+ cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_TOP ||
+ cameraDockInput.position === CAMERADOCK_POSITION.CONTENT_BOTTOM;
+
+ Storage.setItem('webcamSize', {
+ width: isCameraTopOrBottom || isCameraSidebar ? lastWidth : cameraDockInput.width,
+ height: isCameraTopOrBottom || isCameraSidebar ? cameraDockInput.height : lastHeight,
+ });
+
+ const updatedLastSize = Storage.getItem('webcamSize');
+ lastWidth = updatedLastSize.width;
+ lastHeight = updatedLastSize.height;
+ }
+
+ if (isCameraTop || isCameraBottom) {
+ if ((lastHeight === 0 && !isResizing) || (isCameraTop && isMobile)) {
+ cameraDockHeight = min(
+ max(mediaAreaBounds.height * 0.2, cameraDockMinHeight),
+ mediaAreaBounds.height - cameraDockMinHeight
+ );
+ } else {
+ const height = isResizing ? cameraDockInput.height : lastHeight;
+ cameraDockHeight = min(
+ max(height, cameraDockMinHeight),
+ mediaAreaBounds.height - cameraDockMinHeight
+ );
+ }
+
+ cameraDockBounds.top = navBarHeight;
+ cameraDockBounds.left = mediaAreaBounds.left;
+ cameraDockBounds.right = isRTL ? sidebarSize : null;
+ cameraDockBounds.minWidth = mediaAreaBounds.width;
+ cameraDockBounds.width = mediaAreaBounds.width;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width;
+ cameraDockBounds.minHeight = cameraDockMinHeight;
+ cameraDockBounds.height = cameraDockHeight;
+ cameraDockBounds.maxHeight = mediaAreaBounds.height * 0.8;
+
+ if (isCameraBottom) {
+ cameraDockBounds.top += mediaAreaBounds.height - cameraDockHeight;
+ }
+
+ return cameraDockBounds;
+ }
+
+ if (isCameraLeft || isCameraRight) {
+ if (lastWidth === 0 && !isResizing) {
+ cameraDockWidth = min(
+ max(mediaAreaBounds.width * 0.2, cameraDockMinWidth),
+ mediaAreaBounds.width - cameraDockMinWidth
+ );
+ } else {
+ const width = isResizing ? cameraDockInput.width : lastWidth;
+ cameraDockWidth = min(
+ max(width, cameraDockMinWidth),
+ mediaAreaBounds.width - cameraDockMinWidth
+ );
+ }
+
+ cameraDockBounds.top = navBarHeight + bannerAreaHeight();
+ cameraDockBounds.minWidth = cameraDockMinWidth;
+ cameraDockBounds.width = cameraDockWidth;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width * 0.8;
+ cameraDockBounds.presenterMaxWidth =
+ mediaAreaBounds.width - presentationToolbarMinWidth - camerasMargin;
+ cameraDockBounds.minHeight = cameraDockMinHeight;
+ cameraDockBounds.height = mediaAreaBounds.height;
+ cameraDockBounds.maxHeight = mediaAreaBounds.height;
+ // button size in vertical position
+ cameraDockBounds.height -= 20;
+
+ if (isCameraRight) {
+ const sizeValue = mediaAreaBounds.left + mediaAreaBounds.width - cameraDockWidth;
+ cameraDockBounds.left = !isRTL ? sizeValue - camerasMargin : 0;
+ cameraDockBounds.right = isRTL ? sizeValue + sidebarSize - camerasMargin : null;
+ } else if (isCameraLeft) {
+ cameraDockBounds.left = mediaAreaBounds.left + camerasMargin;
+ cameraDockBounds.right = isRTL ? sidebarSize + camerasMargin * 2 : null;
+ }
+
+ return cameraDockBounds;
+ }
+
+ if (isCameraSidebar) {
+ if (lastHeight === 0 && !isResizing) {
+ cameraDockHeight = min(
+ max(windowHeight() * 0.2, cameraDockMinHeight),
+ windowHeight() - cameraDockMinHeight
+ );
+ } else {
+ const height = isResizing ? cameraDockInput.height : lastHeight;
+ cameraDockHeight = min(
+ max(height, cameraDockMinHeight),
+ windowHeight() - cameraDockMinHeight
+ );
+ }
+
+ cameraDockBounds.top = windowHeight() - cameraDockHeight - bannerAreaHeight();
+ cameraDockBounds.left = !isRTL ? sidebarNavWidth : 0;
+ cameraDockBounds.right = isRTL ? sidebarNavWidth : 0;
+ cameraDockBounds.minWidth = sidebarContentWidth;
+ cameraDockBounds.width = sidebarContentWidth;
+ cameraDockBounds.maxWidth = sidebarContentWidth;
+ cameraDockBounds.minHeight = cameraDockMinHeight;
+ cameraDockBounds.height = cameraDockHeight;
+ cameraDockBounds.maxHeight = windowHeight() * 0.8;
+ }
+ return cameraDockBounds;
+ };
+
+ const calculatesMediaBounds = (sidebarNavWidth, sidebarContentWidth, cameraDockBounds) => {
+ const { isOpen, slidesLength } = presentationInput;
+ const { hasExternalVideo } = externalVideoInput;
+ const { hasScreenShare } = screenShareInput;
+ const { isPinned: isSharedNotesPinned } = sharedNotesInput;
+
+ const { height: actionBarHeight } = calculatesActionbarHeight();
+ const mediaAreaHeight =
+ windowHeight() - (DEFAULT_VALUES.navBarHeight + actionBarHeight + bannerAreaHeight());
+ const mediaAreaWidth = windowWidth() - (sidebarNavWidth + sidebarContentWidth);
+ const mediaBounds = {};
+ const { element: fullscreenElement } = fullscreen;
+ const { navBarHeight, camerasMargin } = DEFAULT_VALUES;
+
+ const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
+ const isGeneralMediaOff =
+ !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
+
+ if (!isOpen || isGeneralMediaOff) {
+ mediaBounds.width = 0;
+ mediaBounds.height = 0;
+ mediaBounds.top = 0;
+ mediaBounds.left = !isRTL ? 0 : null;
+ mediaBounds.right = isRTL ? 0 : null;
+ mediaBounds.zIndex = 0;
+ return mediaBounds;
+ }
+
+ if (
+ fullscreenElement === 'Presentation' ||
+ fullscreenElement === 'Screenshare' ||
+ fullscreenElement === 'ExternalVideo'
+ ) {
+ mediaBounds.width = windowWidth();
+ mediaBounds.height = windowHeight();
+ mediaBounds.top = 0;
+ mediaBounds.left = !isRTL ? 0 : null;
+ mediaBounds.right = isRTL ? 0 : null;
+ mediaBounds.zIndex = 99;
+ return mediaBounds;
+ }
+
+ const sidebarSize = sidebarNavWidth + sidebarContentWidth;
+
+ if (cameraDockInput.numCameras > 0 && !cameraDockInput.isDragging) {
+ switch (cameraDockInput.position) {
+ case CAMERADOCK_POSITION.CONTENT_TOP: {
+ mediaBounds.width = mediaAreaWidth;
+ mediaBounds.height = mediaAreaHeight - cameraDockBounds.height - camerasMargin;
+ mediaBounds.top =
+ navBarHeight + cameraDockBounds.height + camerasMargin + bannerAreaHeight();
+ mediaBounds.left = !isRTL ? sidebarSize : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ break;
+ }
+ case CAMERADOCK_POSITION.CONTENT_RIGHT: {
+ mediaBounds.width = mediaAreaWidth - cameraDockBounds.width - camerasMargin * 2;
+ mediaBounds.height = mediaAreaHeight;
+ mediaBounds.top = navBarHeight + bannerAreaHeight();
+ mediaBounds.left = !isRTL ? sidebarSize : null;
+ mediaBounds.right = isRTL ? sidebarSize - camerasMargin * 2 : null;
+ break;
+ }
+ case CAMERADOCK_POSITION.CONTENT_BOTTOM: {
+ mediaBounds.width = mediaAreaWidth;
+ mediaBounds.height = mediaAreaHeight - cameraDockBounds.height - camerasMargin;
+ mediaBounds.top = navBarHeight - camerasMargin + bannerAreaHeight();
+ mediaBounds.left = !isRTL ? sidebarSize : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ break;
+ }
+ case CAMERADOCK_POSITION.CONTENT_LEFT: {
+ mediaBounds.width = mediaAreaWidth - cameraDockBounds.width - camerasMargin * 2;
+ mediaBounds.height = mediaAreaHeight;
+ mediaBounds.top = navBarHeight + bannerAreaHeight();
+ const sizeValue =
+ sidebarNavWidth + sidebarContentWidth + mediaAreaWidth - mediaBounds.width;
+ mediaBounds.left = !isRTL ? sizeValue : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ break;
+ }
+ case CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM: {
+ mediaBounds.width = mediaAreaWidth;
+ mediaBounds.height = mediaAreaHeight;
+ mediaBounds.top = navBarHeight + bannerAreaHeight();
+ mediaBounds.left = !isRTL ? sidebarSize : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ break;
+ }
+ default: {
+ console.log('presentation - camera default');
+ }
+ }
+ mediaBounds.zIndex = 1;
+ } else {
+ mediaBounds.width = mediaAreaWidth;
+ mediaBounds.height = mediaAreaHeight;
+ mediaBounds.top = DEFAULT_VALUES.navBarHeight + bannerAreaHeight();
+ mediaBounds.left = !isRTL ? sidebarSize : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ }
+
+ return mediaBounds;
+ };
+
+ const calculatesLayout = () => {
+ const {
+ calculatesNavbarBounds,
+ calculatesActionbarBounds,
+ calculatesSidebarNavWidth,
+ calculatesSidebarNavHeight,
+ calculatesSidebarNavBounds,
+ calculatesSidebarContentWidth,
+ calculatesSidebarContentBounds,
+ calculatesMediaAreaBounds,
+ isTablet,
+ } = props;
+ const { position: cameraPosition } = cameraDockInput;
+ const { camerasMargin, captionsMargin } = DEFAULT_VALUES;
+
+ const sidebarNavWidth = calculatesSidebarNavWidth();
+ const sidebarNavHeight = calculatesSidebarNavHeight();
+ const sidebarContentWidth = calculatesSidebarContentWidth();
+ const sidebarNavBounds = calculatesSidebarNavBounds();
+ const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width);
+ const mediaAreaBounds = calculatesMediaAreaBounds(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width
+ );
+ const navbarBounds = calculatesNavbarBounds(mediaAreaBounds);
+ const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds);
+ const cameraDockBounds = calculatesCameraDockBounds(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width,
+ mediaAreaBounds
+ );
+ const dropZoneAreas = calculatesDropAreas(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width,
+ cameraDockBounds
+ );
+ const sidebarContentHeight = calculatesSidebarContentHeight(cameraDockBounds.height);
+ const mediaBounds = calculatesMediaBounds(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width,
+ cameraDockBounds
+ );
+ const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width;
+ const { height: actionBarHeight } = calculatesActionbarHeight();
+
+ let horizontalCameraDiff = 0;
+
+ if (cameraPosition === CAMERADOCK_POSITION.CONTENT_LEFT) {
+ horizontalCameraDiff = cameraDockBounds.width + camerasMargin * 2;
+ }
+
+ if (cameraPosition === CAMERADOCK_POSITION.CONTENT_RIGHT) {
+ horizontalCameraDiff = camerasMargin * 2;
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_NAVBAR_OUTPUT,
+ value: {
+ display: navbarInput.hasNavBar,
+ width: navbarBounds.width,
+ height: navbarBounds.height,
+ top: navbarBounds.top,
+ left: navbarBounds.left,
+ tabOrder: DEFAULT_VALUES.navBarTabOrder,
+ zIndex: navbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_ACTIONBAR_OUTPUT,
+ value: {
+ display: actionbarInput.hasActionBar,
+ width: actionbarBounds.width,
+ height: actionbarBounds.height,
+ innerHeight: actionbarBounds.innerHeight,
+ top: actionbarBounds.top,
+ left: actionbarBounds.left,
+ padding: actionbarBounds.padding,
+ tabOrder: DEFAULT_VALUES.actionBarTabOrder,
+ zIndex: actionbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAPTIONS_OUTPUT,
+ value: {
+ left: !isRTL ? sidebarSize + captionsMargin : null,
+ right: isRTL ? sidebarSize + captionsMargin : null,
+ maxWidth: mediaAreaBounds.width - captionsMargin * 2,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT,
+ value: {
+ display: sidebarNavigationInput.isOpen,
+ minWidth: sidebarNavWidth.minWidth,
+ width: sidebarNavWidth.width,
+ maxWidth: sidebarNavWidth.maxWidth,
+ height: sidebarNavHeight,
+ top: sidebarNavBounds.top,
+ left: sidebarNavBounds.left,
+ right: sidebarNavBounds.right,
+ tabOrder: DEFAULT_VALUES.sidebarNavTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarNavBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: false,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT,
+ value: {
+ display: sidebarContentInput.isOpen,
+ minWidth: sidebarContentWidth.minWidth,
+ width: sidebarContentWidth.width,
+ maxWidth: sidebarContentWidth.maxWidth,
+ height: sidebarContentHeight,
+ top: sidebarContentBounds.top,
+ left: sidebarContentBounds.left,
+ right: sidebarContentBounds.right,
+ currentPanelType,
+ tabOrder: DEFAULT_VALUES.sidebarContentTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarContentBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: false,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_MEDIA_AREA_SIZE,
+ value: {
+ width: windowWidth() - sidebarNavWidth.width - sidebarContentWidth.width,
+ height: windowHeight() - DEFAULT_VALUES.navBarHeight - actionBarHeight,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_OUTPUT,
+ value: {
+ display: cameraDockInput.numCameras > 0,
+ position: cameraDockInput.position,
+ minWidth: cameraDockBounds.minWidth,
+ width: cameraDockBounds.width,
+ maxWidth: cameraDockBounds.maxWidth,
+ presenterMaxWidth: cameraDockBounds.presenterMaxWidth,
+ minHeight: cameraDockBounds.minHeight,
+ height: cameraDockBounds.height,
+ maxHeight: cameraDockBounds.maxHeight,
+ top: cameraDockBounds.top,
+ left: cameraDockBounds.left,
+ right: cameraDockBounds.right,
+ tabOrder: 4,
+ isDraggable: !isMobile && !isTablet && presentationInput.isOpen,
+ resizableEdge: {
+ top:
+ input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_BOTTOM ||
+ (input.cameraDock.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM &&
+ input.sidebarContent.isOpen),
+ right:
+ (!isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT) ||
+ (isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT),
+ bottom: input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_TOP,
+ left:
+ (!isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT) ||
+ (isRTL && input.cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT),
+ },
+ zIndex: cameraDockBounds.zIndex,
+ focusedId: input.cameraDock.focusedId,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_DROP_AREAS,
+ value: dropZoneAreas,
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_OUTPUT,
+ value: {
+ display: presentationInput.isOpen,
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ tabOrder: DEFAULT_VALUES.presentationTabOrder,
+ isResizable: false,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SCREEN_SHARE_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ },
+ });
+ };
+
+ return null;
+};
+
+export default CustomLayout;
\ No newline at end of file
diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx
new file mode 100644
index 00000000..a111f242
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/layout-manager/layoutEngine.jsx
@@ -0,0 +1,319 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context';
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+import { LAYOUT_TYPE, DEVICE_TYPE } from '/imports/ui/components/layout/enums';
+
+import CustomLayout from '/imports/ui/components/layout/layout-manager/customLayout';
+import SmartLayout from '/imports/ui/components/layout/layout-manager/smartLayout';
+import PresentationFocusLayout from '/imports/ui/components/layout/layout-manager/presentationFocusLayout';
+import VideoFocusLayout from '/imports/ui/components/layout/layout-manager/videoFocusLayout';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+const propTypes = {
+ layoutType: PropTypes.string.isRequired,
+};
+
+const LayoutEngine = ({ layoutType }) => {
+ const bannerBarInput = layoutSelectInput((i) => i.bannerBar);
+ const notificationsBarInput = layoutSelectInput((i) => i.notificationsBar);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const presentationInput = layoutSelectInput((i) => i.presentation);
+ const actionbarInput = layoutSelectInput((i) => i.actionBar);
+ const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
+ const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
+ const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
+ const screenShareInput = layoutSelectInput((i) => i.screenShare);
+ const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
+
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const fontSize = layoutSelect((i) => i.fontSize);
+ const deviceType = layoutSelect((i) => i.deviceType);
+
+ const isMobile = deviceType === DEVICE_TYPE.MOBILE;
+ const isTablet = deviceType === DEVICE_TYPE.TABLET;
+ const windowWidth = () => window.document.documentElement.clientWidth;
+ const windowHeight = () => window.document.documentElement.clientHeight;
+ const min = (value1, value2) => (value1 <= value2 ? value1 : value2);
+ const max = (value1, value2) => (value1 >= value2 ? value1 : value2);
+
+ const bannerAreaHeight = () => {
+ const { hasNotification } = notificationsBarInput;
+ const { hasBanner } = bannerBarInput;
+ const bannerHeight = hasBanner ? DEFAULT_VALUES.bannerHeight : 0;
+ const notificationHeight = hasNotification ? DEFAULT_VALUES.bannerHeight : 0;
+
+ return bannerHeight + notificationHeight;
+ };
+
+ const baseCameraDockBounds = (mediaAreaBounds, sidebarSize) => {
+ const { isOpen, slidesLength } = presentationInput;
+ const { hasExternalVideo } = externalVideoInput;
+ const { hasScreenShare } = screenShareInput;
+ const { isPinned: isSharedNotesPinned } = sharedNotesInput;
+
+ const cameraDockBounds = {};
+
+ if (cameraDockInput.numCameras === 0 && layoutType !== LAYOUT_TYPE.VIDEO_FOCUS) {
+ cameraDockBounds.width = 0;
+ cameraDockBounds.height = 0;
+
+ return cameraDockBounds;
+ }
+
+ const hasPresentation = isPresentationEnabled() && slidesLength !== 0
+ const isGeneralMediaOff = !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
+
+ if (!isOpen || isGeneralMediaOff) {
+ cameraDockBounds.width = mediaAreaBounds.width;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width;
+ cameraDockBounds.height = mediaAreaBounds.height - bannerAreaHeight();
+ cameraDockBounds.maxHeight = mediaAreaBounds.height;
+ cameraDockBounds.top = DEFAULT_VALUES.navBarHeight + bannerAreaHeight();
+ cameraDockBounds.left = !isRTL ? mediaAreaBounds.left : 0;
+ cameraDockBounds.right = isRTL ? sidebarSize : null;
+ }
+
+ if (fullscreen.group === 'webcams') {
+ cameraDockBounds.width = windowWidth();
+ cameraDockBounds.minWidth = windowWidth();
+ cameraDockBounds.maxWidth = windowWidth();
+ cameraDockBounds.height = windowHeight();
+ cameraDockBounds.minHeight = windowHeight();
+ cameraDockBounds.maxHeight = windowHeight();
+ cameraDockBounds.top = 0;
+ cameraDockBounds.left = 0;
+ cameraDockBounds.right = 0;
+ cameraDockBounds.zIndex = 99;
+
+ return cameraDockBounds;
+ }
+
+ return cameraDockBounds;
+ };
+
+ const calculatesNavbarBounds = (mediaAreaBounds) => {
+ const { navBarHeight, navBarTop } = DEFAULT_VALUES;
+
+ return {
+ width: mediaAreaBounds.width,
+ height: navBarHeight,
+ top: navBarTop + bannerAreaHeight(),
+ left: !isRTL ? mediaAreaBounds.left : 0,
+ zIndex: 1,
+ };
+ };
+
+ const calculatesActionbarHeight = () => {
+ const { actionBarHeight, actionBarPadding } = DEFAULT_VALUES;
+
+ const BASE_FONT_SIZE = 14; // 90% font size
+ const height = ((actionBarHeight / BASE_FONT_SIZE) * fontSize);
+
+ return {
+ height: height + (actionBarPadding * 2),
+ innerHeight: height,
+ padding: actionBarPadding,
+ };
+ };
+
+ const calculatesActionbarBounds = (mediaAreaBounds) => {
+ const actionBarHeight = calculatesActionbarHeight();
+
+ return {
+ display: actionbarInput.hasActionBar,
+ width: mediaAreaBounds.width,
+ height: actionBarHeight.height,
+ innerHeight: actionBarHeight.innerHeight,
+ padding: actionBarHeight.padding,
+ top: windowHeight() - actionBarHeight.height,
+ left: !isRTL ? mediaAreaBounds.left : 0,
+ zIndex: 1,
+ };
+ };
+
+ const calculatesSidebarNavWidth = () => {
+ const {
+ sidebarNavMinWidth,
+ sidebarNavMaxWidth,
+ } = DEFAULT_VALUES;
+
+ const { isOpen, width: sidebarNavWidth } = sidebarNavigationInput;
+
+ let minWidth = 0;
+ let width = 0;
+ let maxWidth = 0;
+ if (isOpen) {
+ if (isMobile) {
+ minWidth = windowWidth();
+ width = windowWidth();
+ maxWidth = windowWidth();
+ } else {
+ if (sidebarNavWidth === 0) {
+ width = min(max((windowWidth() * 0.2), sidebarNavMinWidth), sidebarNavMaxWidth);
+ } else {
+ width = min(max(sidebarNavWidth, sidebarNavMinWidth), sidebarNavMaxWidth);
+ }
+ minWidth = sidebarNavMinWidth;
+ maxWidth = sidebarNavMaxWidth;
+ }
+ }
+ return {
+ minWidth,
+ width,
+ maxWidth,
+ };
+ };
+
+ const calculatesSidebarNavHeight = () => {
+ const { navBarHeight } = DEFAULT_VALUES;
+ const { isOpen } = sidebarNavigationInput;
+
+ let sidebarNavHeight = 0;
+ if (isOpen) {
+ if (isMobile) {
+ sidebarNavHeight = windowHeight() - navBarHeight - bannerAreaHeight();
+ } else {
+ sidebarNavHeight = windowHeight() - bannerAreaHeight();
+ }
+ }
+ return sidebarNavHeight;
+ };
+
+ const calculatesSidebarNavBounds = () => {
+ const { sidebarNavTop, navBarHeight, sidebarNavLeft } = DEFAULT_VALUES;
+
+ let top = sidebarNavTop + bannerAreaHeight();
+
+ if (isMobile) {
+ top = navBarHeight + bannerAreaHeight();
+ }
+
+ return {
+ top,
+ left: !isRTL ? sidebarNavLeft : null,
+ right: isRTL ? sidebarNavLeft : null,
+ zIndex: isMobile ? 11 : 2,
+ };
+ };
+
+ const calculatesSidebarContentWidth = () => {
+ const {
+ sidebarContentMinWidth,
+ sidebarContentMaxWidth,
+ } = DEFAULT_VALUES;
+
+ const { isOpen, width: sidebarContentWidth } = sidebarContentInput;
+
+ let minWidth = 0;
+ let width = 0;
+ let maxWidth = 0;
+
+ if (isOpen) {
+ if (isMobile) {
+ minWidth = windowWidth();
+ width = windowWidth();
+ maxWidth = windowWidth();
+ } else {
+ if (sidebarContentWidth === 0) {
+ width = min(
+ max((windowWidth() * 0.2), sidebarContentMinWidth), sidebarContentMaxWidth,
+ );
+ } else {
+ width = min(max(sidebarContentWidth, sidebarContentMinWidth),
+ sidebarContentMaxWidth);
+ }
+ minWidth = sidebarContentMinWidth;
+ maxWidth = sidebarContentMaxWidth;
+ }
+ }
+ return {
+ minWidth,
+ width,
+ maxWidth,
+ };
+ };
+
+ const calculatesSidebarContentBounds = (sidebarNavWidth) => {
+ const { navBarHeight, sidebarNavTop } = DEFAULT_VALUES;
+
+ let top = sidebarNavTop + bannerAreaHeight();
+
+ if (isMobile) top = navBarHeight + bannerAreaHeight();
+
+ let left = isMobile ? 0 : sidebarNavWidth;
+ let right = isMobile ? 0 : sidebarNavWidth;
+ left = !isRTL ? left : null;
+ right = isRTL ? right : null;
+
+ const zIndex = isMobile ? 11 : 1;
+
+ return {
+ top,
+ left,
+ right,
+ zIndex,
+ };
+ };
+
+ const calculatesMediaAreaBounds = (sidebarNavWidth, sidebarContentWidth) => {
+ const { navBarHeight } = DEFAULT_VALUES;
+ const { height: actionBarHeight } = calculatesActionbarHeight();
+ let left = 0;
+ let width = 0;
+ if (isMobile) {
+ width = windowWidth();
+ } else {
+ left = !isRTL ? sidebarNavWidth + sidebarContentWidth : 0;
+ width = windowWidth() - sidebarNavWidth - sidebarContentWidth;
+ }
+
+ return {
+ width,
+ height: windowHeight() - (navBarHeight + actionBarHeight + bannerAreaHeight()),
+ top: navBarHeight + bannerAreaHeight(),
+ left,
+ };
+ };
+
+ const common = {
+ bannerAreaHeight,
+ baseCameraDockBounds,
+ calculatesNavbarBounds,
+ calculatesActionbarHeight,
+ calculatesActionbarBounds,
+ calculatesSidebarNavWidth,
+ calculatesSidebarNavHeight,
+ calculatesSidebarNavBounds,
+ calculatesSidebarContentWidth,
+ calculatesSidebarContentBounds,
+ calculatesMediaAreaBounds,
+ isMobile,
+ isTablet,
+ };
+
+ const layout = document.getElementById('layout');
+
+ switch (layoutType) {
+ case LAYOUT_TYPE.CUSTOM_LAYOUT:
+ layout?.setAttribute("data-layout", LAYOUT_TYPE.CUSTOM_LAYOUT);
+ return ;
+ case LAYOUT_TYPE.SMART_LAYOUT:
+ layout?.setAttribute("data-layout", LAYOUT_TYPE.SMART_LAYOUT);
+ return ;
+ case LAYOUT_TYPE.PRESENTATION_FOCUS:
+ layout?.setAttribute("data-layout", LAYOUT_TYPE.PRESENTATION_FOCUS);
+ return ;
+ case LAYOUT_TYPE.VIDEO_FOCUS:
+ layout?.setAttribute("data-layout",LAYOUT_TYPE.VIDEO_FOCUS);
+ return ;
+ default:
+ layout?.setAttribute("data-layout", LAYOUT_TYPE.CUSTOM_LAYOUT);
+ return ;
+ }
+};
+
+LayoutEngine.propTypes = propTypes;
+
+export default LayoutEngine;
diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx
new file mode 100644
index 00000000..b37d0761
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/layout-manager/presentationFocusLayout.jsx
@@ -0,0 +1,514 @@
+import { useEffect, useRef } from 'react';
+import { throttle } from '/imports/utils/throttle';
+import { layoutDispatch, layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context';
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState';
+import {
+ ACTIONS,
+ PANELS,
+ CAMERADOCK_POSITION,
+} from '/imports/ui/components/layout/enums';
+import { defaultsDeep } from '/imports/utils/array-utils';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+const windowWidth = () => window.document.documentElement.clientWidth;
+const windowHeight = () => window.document.documentElement.clientHeight;
+const min = (value1, value2) => (value1 <= value2 ? value1 : value2);
+const max = (value1, value2) => (value1 >= value2 ? value1 : value2);
+
+const PresentationFocusLayout = (props) => {
+ const { bannerAreaHeight, isMobile } = props;
+
+ function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+ }
+
+ const input = layoutSelect((i) => i.input);
+ const deviceType = layoutSelect((i) => i.deviceType);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const fontSize = layoutSelect((i) => i.fontSize);
+ const currentPanelType = layoutSelect((i) => i.currentPanelType);
+
+ const presentationInput = layoutSelectInput((i) => i.presentation);
+ const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
+ const screenShareInput = layoutSelectInput((i) => i.screenShare);
+ const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
+
+ const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
+ const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const actionbarInput = layoutSelectInput((i) => i.actionBar);
+ const navbarInput = layoutSelectInput((i) => i.navBar);
+ const layoutContextDispatch = layoutDispatch();
+
+ const prevDeviceType = usePrevious(deviceType);
+
+ const throttledCalculatesLayout = throttle(() => calculatesLayout(),
+ 50, { trailing: true, leading: true });
+
+ useEffect(() => {
+ window.addEventListener('resize', () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_BROWSER_SIZE,
+ value: {
+ width: window.document.documentElement.clientWidth,
+ height: window.document.documentElement.clientHeight,
+ },
+ });
+ });
+ }, []);
+
+ useEffect(() => {
+ if (deviceType === null) return () => null;
+
+ if (deviceType !== prevDeviceType) {
+ // reset layout if deviceType changed
+ // not all options is supported in all devices
+ init();
+ } else {
+ throttledCalculatesLayout();
+ }
+ }, [input, deviceType, isRTL, fontSize, fullscreen]);
+
+ const init = () => {
+ const { sidebarContentPanel } = sidebarContentInput;
+ if (isMobile) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ SidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: input.externalVideo.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: input.screenShare.hasScreenShare,
+ width: input.screenShare.width,
+ height: input.screenShare.height,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ SidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: input.externalVideo.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: input.screenShare.hasScreenShare,
+ width: input.screenShare.width,
+ height: input.screenShare.height,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ }
+ Session.set('layoutReady', true);
+ throttledCalculatesLayout();
+ };
+
+ const calculatesSidebarContentHeight = () => {
+ const { isOpen, slidesLength } = presentationInput;
+ const { hasExternalVideo } = externalVideoInput;
+ const { hasScreenShare } = screenShareInput;
+ const { isPinned: isSharedNotesPinned } = sharedNotesInput;
+
+ const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
+ const isGeneralMediaOff =
+ !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
+
+ const { navBarHeight, sidebarContentMinHeight } = DEFAULT_VALUES;
+ let height = 0;
+ let minHeight = 0;
+ let maxHeight = 0;
+ if (sidebarContentInput.isOpen) {
+ if (isMobile) {
+ height = windowHeight() - navBarHeight - bannerAreaHeight();
+ minHeight = height;
+ maxHeight = height;
+ } else if (cameraDockInput.numCameras > 0 && isOpen && !isGeneralMediaOff) {
+ if (sidebarContentInput.height === 0) {
+ height = windowHeight() * 0.75 - bannerAreaHeight();
+ } else {
+ height = min(max(sidebarContentInput.height, sidebarContentMinHeight), windowHeight());
+ }
+ minHeight = windowHeight() * 0.25 - bannerAreaHeight();
+ maxHeight = windowHeight() * 0.75 - bannerAreaHeight();
+ } else {
+ height = windowHeight() - bannerAreaHeight();
+ minHeight = height;
+ maxHeight = height;
+ }
+ }
+ return {
+ height,
+ minHeight,
+ maxHeight,
+ };
+ };
+
+ const calculatesCameraDockBounds = (
+ mediaBounds,
+ mediaAreaBounds,
+ sidebarNavWidth,
+ sidebarContentWidth,
+ sidebarContentHeight
+ ) => {
+ const { baseCameraDockBounds } = props;
+ const sidebarSize = sidebarNavWidth + sidebarContentWidth;
+
+ const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize);
+
+ // do not proceed if using values from LayoutEngine
+ if (Object.keys(baseBounds).length > 0) {
+ return baseBounds;
+ }
+
+ const { cameraDockMinHeight } = DEFAULT_VALUES;
+
+ const cameraDockBounds = {};
+
+ let cameraDockHeight = 0;
+
+ if (isMobile) {
+ cameraDockBounds.top = mediaAreaBounds.top + mediaBounds.height;
+ cameraDockBounds.left = 0;
+ cameraDockBounds.right = 0;
+ cameraDockBounds.minWidth = mediaAreaBounds.width;
+ cameraDockBounds.width = mediaAreaBounds.width;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width;
+ cameraDockBounds.minHeight = cameraDockMinHeight;
+ cameraDockBounds.height = mediaAreaBounds.height - mediaBounds.height;
+ cameraDockBounds.maxHeight = mediaAreaBounds.height - mediaBounds.height;
+ } else {
+ if (cameraDockInput.height === 0) {
+ cameraDockHeight = min(
+ max(windowHeight() - sidebarContentHeight, cameraDockMinHeight),
+ windowHeight() - cameraDockMinHeight
+ );
+ const bannerAreaDiff =
+ windowHeight() - sidebarContentHeight - cameraDockHeight - bannerAreaHeight();
+ cameraDockHeight += bannerAreaDiff;
+ } else {
+ cameraDockHeight = min(
+ max(cameraDockInput.height, cameraDockMinHeight),
+ windowHeight() - cameraDockMinHeight
+ );
+ }
+ cameraDockBounds.top = windowHeight() - cameraDockHeight - bannerAreaHeight();
+ cameraDockBounds.left = !isRTL ? sidebarNavWidth : 0;
+ cameraDockBounds.right = isRTL ? sidebarNavWidth : 0;
+ cameraDockBounds.minWidth = sidebarContentWidth;
+ cameraDockBounds.width = sidebarContentWidth;
+ cameraDockBounds.maxWidth = sidebarContentWidth;
+ cameraDockBounds.minHeight = cameraDockMinHeight;
+ cameraDockBounds.height = cameraDockHeight;
+ cameraDockBounds.maxHeight = windowHeight() - sidebarContentHeight;
+ cameraDockBounds.zIndex = 1;
+ }
+ return cameraDockBounds;
+ };
+
+ const calculatesMediaBounds = (mediaAreaBounds, sidebarSize) => {
+ const mediaBounds = {};
+ const { element: fullscreenElement } = fullscreen;
+
+ if (
+ fullscreenElement === 'Presentation' ||
+ fullscreenElement === 'Screenshare' ||
+ fullscreenElement === 'ExternalVideo'
+ ) {
+ mediaBounds.width = windowWidth();
+ mediaBounds.height = windowHeight();
+ mediaBounds.top = 0;
+ mediaBounds.left = !isRTL ? 0 : null;
+ mediaBounds.right = isRTL ? 0 : null;
+ mediaBounds.zIndex = 99;
+ return mediaBounds;
+ }
+
+ if (isMobile && cameraDockInput.numCameras > 0) {
+ mediaBounds.height = mediaAreaBounds.height * 0.7;
+ } else {
+ mediaBounds.height = mediaAreaBounds.height;
+ }
+ mediaBounds.width = mediaAreaBounds.width;
+ mediaBounds.top = DEFAULT_VALUES.navBarHeight + bannerAreaHeight();
+ mediaBounds.left = !isRTL ? mediaAreaBounds.left : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ mediaBounds.zIndex = 1;
+
+ return mediaBounds;
+ };
+
+ const calculatesLayout = () => {
+ const {
+ calculatesNavbarBounds,
+ calculatesActionbarBounds,
+ calculatesSidebarNavWidth,
+ calculatesSidebarNavHeight,
+ calculatesSidebarNavBounds,
+ calculatesSidebarContentWidth,
+ calculatesSidebarContentBounds,
+ calculatesMediaAreaBounds,
+ isTablet,
+ } = props;
+ const { captionsMargin } = DEFAULT_VALUES;
+
+ const sidebarNavWidth = calculatesSidebarNavWidth();
+ const sidebarNavHeight = calculatesSidebarNavHeight();
+ const sidebarContentWidth = calculatesSidebarContentWidth();
+ const sidebarNavBounds = calculatesSidebarNavBounds();
+ const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width);
+ const mediaAreaBounds = calculatesMediaAreaBounds(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width
+ );
+ const navbarBounds = calculatesNavbarBounds(mediaAreaBounds);
+ const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds);
+ const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width;
+ const mediaBounds = calculatesMediaBounds(mediaAreaBounds, sidebarSize);
+ const sidebarContentHeight = calculatesSidebarContentHeight();
+ const cameraDockBounds = calculatesCameraDockBounds(
+ mediaBounds,
+ mediaAreaBounds,
+ sidebarNavWidth.width,
+ sidebarContentWidth.width,
+ sidebarContentHeight.height
+ );
+ const { isOpen } = presentationInput;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_NAVBAR_OUTPUT,
+ value: {
+ display: navbarInput.hasNavBar,
+ width: navbarBounds.width,
+ height: navbarBounds.height,
+ top: navbarBounds.top,
+ left: navbarBounds.left,
+ tabOrder: DEFAULT_VALUES.navBarTabOrder,
+ zIndex: navbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_ACTIONBAR_OUTPUT,
+ value: {
+ display: actionbarInput.hasActionBar,
+ width: actionbarBounds.width,
+ height: actionbarBounds.height,
+ innerHeight: actionbarBounds.innerHeight,
+ top: actionbarBounds.top,
+ left: actionbarBounds.left,
+ padding: actionbarBounds.padding,
+ tabOrder: DEFAULT_VALUES.actionBarTabOrder,
+ zIndex: actionbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAPTIONS_OUTPUT,
+ value: {
+ left: !isRTL ? sidebarSize + captionsMargin : null,
+ right: isRTL ? sidebarSize + captionsMargin : null,
+ maxWidth: mediaAreaBounds.width - captionsMargin * 2,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT,
+ value: {
+ display: sidebarNavigationInput.isOpen,
+ minWidth: sidebarNavWidth.minWidth,
+ width: sidebarNavWidth.width,
+ maxWidth: sidebarNavWidth.maxWidth,
+ height: sidebarNavHeight,
+ top: sidebarNavBounds.top,
+ left: sidebarNavBounds.left,
+ right: sidebarNavBounds.right,
+ tabOrder: DEFAULT_VALUES.sidebarNavTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarNavBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: false,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT,
+ value: {
+ display: sidebarContentInput.isOpen,
+ minWidth: sidebarContentWidth.minWidth,
+ width: sidebarContentWidth.width,
+ maxWidth: sidebarContentWidth.maxWidth,
+ minHeight: sidebarContentHeight.minHeight,
+ height: sidebarContentHeight.height,
+ maxHeight: sidebarContentHeight.maxHeight,
+ top: sidebarContentBounds.top,
+ left: sidebarContentBounds.left,
+ right: sidebarContentBounds.right,
+ currentPanelType,
+ tabOrder: DEFAULT_VALUES.sidebarContentTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarContentBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: cameraDockInput.numCameras > 0,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_MEDIA_AREA_SIZE,
+ value: {
+ width: mediaAreaBounds.width,
+ height: mediaAreaBounds.height,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_OUTPUT,
+ value: {
+ display: cameraDockInput.numCameras > 0,
+ position: CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM,
+ minWidth: cameraDockBounds.minWidth,
+ width: cameraDockBounds.width,
+ maxWidth: cameraDockBounds.maxWidth,
+ minHeight: cameraDockBounds.minHeight,
+ height: cameraDockBounds.height,
+ maxHeight: cameraDockBounds.maxHeight,
+ top: cameraDockBounds.top,
+ left: cameraDockBounds.left,
+ right: cameraDockBounds.right,
+ tabOrder: 4,
+ isDraggable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ focusedId: input.cameraDock.focusedId,
+ zIndex: cameraDockBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_OUTPUT,
+ value: {
+ display: presentationInput.isOpen,
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right : null,
+ tabOrder: DEFAULT_VALUES.presentationTabOrder,
+ isResizable: false,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SCREEN_SHARE_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: isOpen ? mediaBounds.height : 0,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: mediaBounds.right,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT,
+ value: {
+ width: isOpen ? mediaBounds.width : 0,
+ height: isOpen ? mediaBounds.height : 0,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: mediaBounds.right,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
+ value: {
+ width: isOpen ? mediaBounds.width : 0,
+ height: isOpen ? mediaBounds.height : 0,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: mediaBounds.right,
+ },
+ });
+ };
+
+ return null;
+};
+
+export default PresentationFocusLayout;
\ No newline at end of file
diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx
new file mode 100644
index 00000000..a10756e2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/layout-manager/smartLayout.jsx
@@ -0,0 +1,585 @@
+import { useEffect, useRef } from 'react';
+import { throttle } from '/imports/utils/throttle';
+import { layoutDispatch, layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context';
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState';
+import { ACTIONS, PANELS, CAMERADOCK_POSITION } from '/imports/ui/components/layout/enums';
+import { defaultsDeep } from '/imports/utils/array-utils';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+const windowWidth = () => window.document.documentElement.clientWidth;
+const windowHeight = () => window.document.documentElement.clientHeight;
+
+const SmartLayout = (props) => {
+ const { bannerAreaHeight, isMobile } = props;
+
+ function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+ }
+
+ const input = layoutSelect((i) => i.input);
+ const deviceType = layoutSelect((i) => i.deviceType);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const fontSize = layoutSelect((i) => i.fontSize);
+ const currentPanelType = layoutSelect((i) => i.currentPanelType);
+
+ const presentationInput = layoutSelectInput((i) => i.presentation);
+ const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
+ const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const actionbarInput = layoutSelectInput((i) => i.actionBar);
+ const navbarInput = layoutSelectInput((i) => i.navBar);
+ const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
+ const screenShareInput = layoutSelectInput((i) => i.screenShare);
+ const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
+ const layoutContextDispatch = layoutDispatch();
+
+ const prevDeviceType = usePrevious(deviceType);
+
+ const throttledCalculatesLayout = throttle(() => calculatesLayout(), 50, {
+ trailing: true,
+ leading: true,
+ });
+
+ useEffect(() => {
+ window.addEventListener('resize', () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_BROWSER_SIZE,
+ value: {
+ width: window.document.documentElement.clientWidth,
+ height: window.document.documentElement.clientHeight,
+ },
+ });
+ });
+ }, []);
+
+ useEffect(() => {
+ if (deviceType === null) return () => null;
+
+ if (deviceType !== prevDeviceType) {
+ // reset layout if deviceType changed
+ // not all options is supported in all devices
+ init();
+ } else {
+ throttledCalculatesLayout();
+ }
+ }, [input, deviceType, isRTL, fontSize, fullscreen]);
+
+ const init = () => {
+ const { sidebarContentPanel } = sidebarContentInput;
+
+ if (isMobile) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ SidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: externalVideoInput.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: screenShareInput.hasScreenShare,
+ width: screenShareInput.width,
+ height: screenShareInput.height,
+ },
+ sharedNotes: {
+ isPinned: sharedNotesInput.isPinned,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ SidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: externalVideoInput.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: screenShareInput.hasScreenShare,
+ width: screenShareInput.width,
+ height: screenShareInput.height,
+ },
+ sharedNotes: {
+ isPinned: sharedNotesInput.isPinned,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ }
+ Session.set('layoutReady', true);
+ throttledCalculatesLayout();
+ };
+
+ const calculatesSidebarContentHeight = () => {
+ let sidebarContentHeight = 0;
+ if (sidebarContentInput.isOpen) {
+ if (isMobile) {
+ sidebarContentHeight = windowHeight() - DEFAULT_VALUES.navBarHeight;
+ } else {
+ sidebarContentHeight = windowHeight();
+ }
+ sidebarContentHeight -= bannerAreaHeight();
+ }
+ return sidebarContentHeight;
+ };
+
+ const calculatesCameraDockBounds = (mediaAreaBounds, mediaBounds, sidebarSize) => {
+ const { baseCameraDockBounds } = props;
+
+ const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize);
+
+ // do not proceed if using values from LayoutEngine
+ if (Object.keys(baseBounds).length > 0) {
+ baseBounds.isCameraHorizontal = false;
+ return baseBounds;
+ }
+
+ const { camerasMargin, presentationToolbarMinWidth, navBarHeight } = DEFAULT_VALUES;
+
+ const cameraDockBounds = {};
+
+ cameraDockBounds.isCameraHorizontal = false;
+
+ const mediaBoundsWidth =
+ mediaBounds.width > presentationToolbarMinWidth && !isMobile
+ ? mediaBounds.width
+ : presentationToolbarMinWidth;
+
+ cameraDockBounds.top = navBarHeight;
+ cameraDockBounds.left = mediaAreaBounds.left;
+ cameraDockBounds.right = isRTL ? sidebarSize : null;
+ cameraDockBounds.zIndex = 1;
+
+ if (mediaBounds.width < mediaAreaBounds.width) {
+ cameraDockBounds.top = navBarHeight + bannerAreaHeight();
+ cameraDockBounds.width = mediaAreaBounds.width - mediaBoundsWidth;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width * 0.8;
+ cameraDockBounds.height = mediaAreaBounds.height;
+ cameraDockBounds.maxHeight = mediaAreaBounds.height;
+ cameraDockBounds.left += camerasMargin;
+ cameraDockBounds.width -= camerasMargin * 2;
+ cameraDockBounds.isCameraHorizontal = true;
+ cameraDockBounds.position = CAMERADOCK_POSITION.CONTENT_LEFT;
+ // button size in vertical position
+ cameraDockBounds.height -= 20;
+ } else {
+ cameraDockBounds.width = mediaAreaBounds.width;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width;
+ cameraDockBounds.height = mediaAreaBounds.height - mediaBounds.height;
+ cameraDockBounds.maxHeight = mediaAreaBounds.height * 0.8;
+ cameraDockBounds.top += camerasMargin;
+ cameraDockBounds.height -= camerasMargin * 2;
+ cameraDockBounds.position = CAMERADOCK_POSITION.CONTENT_TOP;
+ }
+
+ cameraDockBounds.minWidth = cameraDockBounds.width;
+ cameraDockBounds.minHeight = cameraDockBounds.height;
+
+ return cameraDockBounds;
+ };
+
+ const calculatesSlideSize = (mediaAreaBounds) => {
+ const { currentSlide } = presentationInput;
+
+ if (currentSlide.size.width === 0 && currentSlide.size.height === 0) {
+ return {
+ width: 0,
+ height: 0,
+ };
+ }
+
+ let slideWidth;
+ let slideHeight;
+
+ slideWidth = (currentSlide.size.width * mediaAreaBounds.height) / currentSlide.size.height;
+ slideHeight = mediaAreaBounds.height;
+
+ if (slideWidth > mediaAreaBounds.width) {
+ slideWidth = mediaAreaBounds.width;
+ slideHeight = (currentSlide.size.height * mediaAreaBounds.width) / currentSlide.size.width;
+ }
+
+ return {
+ width: slideWidth,
+ height: slideHeight,
+ };
+ };
+
+ const calculatesScreenShareSize = (mediaAreaBounds) => {
+ const { width = 0, height = 0 } = screenShareInput;
+
+ if (width === 0 && height === 0) return { width, height };
+
+ let screeShareWidth;
+ let screeShareHeight;
+
+ screeShareWidth = (width * mediaAreaBounds.height) / height;
+ screeShareHeight = mediaAreaBounds.height;
+
+ if (screeShareWidth > mediaAreaBounds.width) {
+ screeShareWidth = mediaAreaBounds.width;
+ screeShareHeight = (height * mediaAreaBounds.width) / width;
+ }
+
+ return {
+ width: screeShareWidth,
+ height: screeShareHeight,
+ };
+ };
+
+ const calculatesMediaBounds = (mediaAreaBounds, slideSize, sidebarSize, screenShareSize) => {
+ const { isOpen, slidesLength } = presentationInput;
+ const { hasExternalVideo } = externalVideoInput;
+ const { hasScreenShare } = screenShareInput;
+ const { isPinned: isSharedNotesPinned } = sharedNotesInput;
+
+ const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
+ const isGeneralMediaOff =
+ !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
+
+ const mediaBounds = {};
+ const { element: fullscreenElement } = fullscreen;
+
+ if (!isOpen || isGeneralMediaOff) {
+ mediaBounds.width = 0;
+ mediaBounds.height = 0;
+ mediaBounds.top = 0;
+ mediaBounds.left = !isRTL ? 0 : null;
+ mediaBounds.right = isRTL ? 0 : null;
+ mediaBounds.zIndex = 0;
+ return mediaBounds;
+ }
+
+ if (
+ fullscreenElement === 'Presentation' ||
+ fullscreenElement === 'Screenshare' ||
+ fullscreenElement === 'ExternalVideo'
+ ) {
+ mediaBounds.width = windowWidth();
+ mediaBounds.height = windowHeight();
+ mediaBounds.top = 0;
+ mediaBounds.left = !isRTL ? 0 : null;
+ mediaBounds.right = isRTL ? 0 : null;
+ mediaBounds.zIndex = 99;
+ return mediaBounds;
+ }
+
+ const mediaContentSize = hasScreenShare ? screenShareSize : slideSize;
+
+ if (cameraDockInput.numCameras > 0 && !cameraDockInput.isDragging) {
+ if (mediaContentSize.width !== 0 && mediaContentSize.height !== 0 && !hasExternalVideo) {
+ if (mediaContentSize.width < mediaAreaBounds.width && !isMobile) {
+ if (mediaContentSize.width < mediaAreaBounds.width * 0.8) {
+ mediaBounds.width = mediaContentSize.width;
+ } else {
+ mediaBounds.width = mediaAreaBounds.width * 0.8;
+ }
+ mediaBounds.height = mediaAreaBounds.height;
+ mediaBounds.top = mediaAreaBounds.top;
+ const sizeValue = mediaAreaBounds.left + (mediaAreaBounds.width - mediaBounds.width);
+ mediaBounds.left = !isRTL ? sizeValue : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ } else {
+ if (mediaContentSize.height < mediaAreaBounds.height * 0.8) {
+ mediaBounds.height = mediaContentSize.height;
+ } else {
+ mediaBounds.height = mediaAreaBounds.height * 0.8;
+ }
+ mediaBounds.width = mediaAreaBounds.width;
+ mediaBounds.top = mediaAreaBounds.top + (mediaAreaBounds.height - mediaBounds.height);
+ const sizeValue = mediaAreaBounds.left;
+ mediaBounds.left = !isRTL ? sizeValue : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ }
+ } else {
+ mediaBounds.width = mediaAreaBounds.width;
+ mediaBounds.height = mediaAreaBounds.height * 0.8;
+ mediaBounds.top = mediaAreaBounds.top + (mediaAreaBounds.height - mediaBounds.height);
+ const sizeValue = mediaAreaBounds.left;
+ mediaBounds.left = !isRTL ? sizeValue : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ }
+ } else {
+ mediaBounds.width = mediaAreaBounds.width;
+ mediaBounds.height = mediaAreaBounds.height;
+ mediaBounds.top = mediaAreaBounds.top;
+ const sizeValue = mediaAreaBounds.left;
+ mediaBounds.left = !isRTL ? sizeValue : null;
+ mediaBounds.right = isRTL ? sidebarSize : null;
+ }
+ mediaBounds.zIndex = 1;
+
+ return mediaBounds;
+ };
+
+ const calculatesLayout = () => {
+ const {
+ calculatesNavbarBounds,
+ calculatesActionbarBounds,
+ calculatesSidebarNavWidth,
+ calculatesSidebarNavHeight,
+ calculatesSidebarNavBounds,
+ calculatesSidebarContentWidth,
+ calculatesSidebarContentBounds,
+ calculatesMediaAreaBounds,
+ isTablet,
+ } = props;
+ const { camerasMargin, captionsMargin } = DEFAULT_VALUES;
+
+ const sidebarNavWidth = calculatesSidebarNavWidth();
+ const sidebarNavHeight = calculatesSidebarNavHeight();
+ const sidebarContentWidth = calculatesSidebarContentWidth();
+ const sidebarContentHeight = calculatesSidebarContentHeight();
+ const sidebarNavBounds = calculatesSidebarNavBounds();
+ const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width);
+ const mediaAreaBounds = calculatesMediaAreaBounds(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width
+ );
+ const navbarBounds = calculatesNavbarBounds(mediaAreaBounds);
+ const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds);
+ const slideSize = calculatesSlideSize(mediaAreaBounds);
+ const screenShareSize = calculatesScreenShareSize(mediaAreaBounds);
+ const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width;
+ const mediaBounds = calculatesMediaBounds(
+ mediaAreaBounds,
+ slideSize,
+ sidebarSize,
+ screenShareSize
+ );
+ const cameraDockBounds = calculatesCameraDockBounds(mediaAreaBounds, mediaBounds, sidebarSize);
+ const horizontalCameraDiff = cameraDockBounds.isCameraHorizontal
+ ? cameraDockBounds.width + camerasMargin * 2
+ : 0;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_NAVBAR_OUTPUT,
+ value: {
+ display: navbarInput.hasNavBar,
+ width: navbarBounds.width,
+ height: navbarBounds.height,
+ top: navbarBounds.top,
+ left: navbarBounds.left,
+ tabOrder: DEFAULT_VALUES.navBarTabOrder,
+ zIndex: navbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_ACTIONBAR_OUTPUT,
+ value: {
+ display: actionbarInput.hasActionBar,
+ width: actionbarBounds.width,
+ height: actionbarBounds.height,
+ innerHeight: actionbarBounds.innerHeight,
+ top: actionbarBounds.top,
+ left: actionbarBounds.left,
+ padding: actionbarBounds.padding,
+ tabOrder: DEFAULT_VALUES.actionBarTabOrder,
+ zIndex: actionbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAPTIONS_OUTPUT,
+ value: {
+ left: !isRTL ? sidebarSize + captionsMargin : null,
+ right: isRTL ? sidebarSize + captionsMargin : null,
+ maxWidth: mediaAreaBounds.width - captionsMargin * 2,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT,
+ value: {
+ display: sidebarNavigationInput.isOpen,
+ minWidth: sidebarNavWidth.minWidth,
+ width: sidebarNavWidth.width,
+ maxWidth: sidebarNavWidth.maxWidth,
+ height: sidebarNavHeight,
+ top: sidebarNavBounds.top,
+ left: sidebarNavBounds.left,
+ right: sidebarNavBounds.right,
+ tabOrder: DEFAULT_VALUES.sidebarNavTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarNavBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: false,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT,
+ value: {
+ display: sidebarContentInput.isOpen,
+ minWidth: sidebarContentWidth.minWidth,
+ width: sidebarContentWidth.width,
+ maxWidth: sidebarContentWidth.maxWidth,
+ height: sidebarContentHeight,
+ top: sidebarContentBounds.top,
+ left: sidebarContentBounds.left,
+ right: sidebarContentBounds.right,
+ currentPanelType,
+ tabOrder: DEFAULT_VALUES.sidebarContentTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarContentBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: false,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_MEDIA_AREA_SIZE,
+ value: {
+ width: mediaAreaBounds.width,
+ height: mediaAreaBounds.height,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_OUTPUT,
+ value: {
+ display: cameraDockInput.numCameras > 0,
+ position: cameraDockBounds.position,
+ minWidth: cameraDockBounds.minWidth,
+ width: cameraDockBounds.width,
+ maxWidth: cameraDockBounds.maxWidth,
+ minHeight: cameraDockBounds.minHeight,
+ height: cameraDockBounds.height,
+ maxHeight: cameraDockBounds.maxHeight,
+ top: cameraDockBounds.top,
+ left: cameraDockBounds.left,
+ right: cameraDockBounds.right,
+ tabOrder: 4,
+ isDraggable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ zIndex: cameraDockBounds.zIndex,
+ focusedId: input.cameraDock.focusedId,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_OUTPUT,
+ value: {
+ display: presentationInput.isOpen,
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ tabOrder: DEFAULT_VALUES.presentationTabOrder,
+ isResizable: false,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SCREEN_SHARE_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
+ },
+ });
+ };
+
+ return null;
+};
+
+export default SmartLayout;
\ No newline at end of file
diff --git a/src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx b/src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx
new file mode 100644
index 00000000..16bc993e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/layout-manager/videoFocusLayout.jsx
@@ -0,0 +1,524 @@
+import { useEffect, useRef } from 'react';
+import { throttle } from '/imports/utils/throttle';
+import {
+ layoutDispatch,
+ layoutSelect,
+ layoutSelectInput,
+ layoutSelectOutput,
+} from '/imports/ui/components/layout/context';
+import DEFAULT_VALUES from '/imports/ui/components/layout/defaultValues';
+import { INITIAL_INPUT_STATE } from '/imports/ui/components/layout/initState';
+import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums';
+import { defaultsDeep } from '/imports/utils/array-utils';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+const windowWidth = () => window.document.documentElement.clientWidth;
+const windowHeight = () => window.document.documentElement.clientHeight;
+
+const VideoFocusLayout = (props) => {
+ const { bannerAreaHeight, isMobile } = props;
+
+ function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+ }
+
+ const input = layoutSelect((i) => i.input);
+ const deviceType = layoutSelect((i) => i.deviceType);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const fontSize = layoutSelect((i) => i.fontSize);
+ const currentPanelType = layoutSelect((i) => i.currentPanelType);
+
+ const presentationInput = layoutSelectInput((i) => i.presentation);
+ const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
+ const screenShareInput = layoutSelectInput((i) => i.screenShare);
+ const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
+
+ const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
+ const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const actionbarInput = layoutSelectInput((i) => i.actionBar);
+ const navbarInput = layoutSelectInput((i) => i.navBar);
+ const layoutContextDispatch = layoutDispatch();
+
+ const sidebarContentOutput = layoutSelectOutput((i) => i.sidebarContent);
+
+ const prevDeviceType = usePrevious(deviceType);
+
+ const throttledCalculatesLayout = throttle(() => calculatesLayout(), 50, {
+ trailing: true,
+ leading: true,
+ });
+
+ useEffect(() => {
+ window.addEventListener('resize', () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_BROWSER_SIZE,
+ value: {
+ width: window.document.documentElement.clientWidth,
+ height: window.document.documentElement.clientHeight,
+ },
+ });
+ });
+ }, []);
+
+ useEffect(() => {
+ if (deviceType === null) return () => null;
+
+ if (deviceType !== prevDeviceType) {
+ // reset layout if deviceType changed
+ // not all options is supported in all devices
+ init();
+ } else {
+ throttledCalculatesLayout();
+ }
+ }, [input, deviceType, isRTL, fontSize, fullscreen]);
+
+ const init = () => {
+ const { sidebarContentPanel } = sidebarContentInput;
+ if (isMobile) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ SidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: input.externalVideo.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: input.screenShare.hasScreenShare,
+ width: input.screenShare.width,
+ height: input.screenShare.height,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_INPUT,
+ value: defaultsDeep(
+ {
+ sidebarNavigation: {
+ isOpen:
+ input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
+ },
+ sidebarContent: {
+ isOpen: sidebarContentPanel !== PANELS.NONE,
+ sidebarContentPanel,
+ },
+ SidebarContentHorizontalResizer: {
+ isOpen: false,
+ },
+ presentation: {
+ isOpen: presentationInput.isOpen,
+ slidesLength: presentationInput.slidesLength,
+ currentSlide: {
+ ...presentationInput.currentSlide,
+ },
+ },
+ cameraDock: {
+ numCameras: cameraDockInput.numCameras,
+ },
+ externalVideo: {
+ hasExternalVideo: input.externalVideo.hasExternalVideo,
+ },
+ screenShare: {
+ hasScreenShare: input.screenShare.hasScreenShare,
+ width: input.screenShare.width,
+ height: input.screenShare.height,
+ },
+ },
+ INITIAL_INPUT_STATE
+ ),
+ });
+ }
+ Session.set('layoutReady', true);
+ throttledCalculatesLayout();
+ };
+
+ const calculatesSidebarContentHeight = () => {
+ const { isOpen, slidesLength } = presentationInput;
+ const { hasExternalVideo } = externalVideoInput;
+ const { hasScreenShare } = screenShareInput;
+ const { isPinned: isSharedNotesPinned } = sharedNotesInput;
+
+ const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
+ const isGeneralMediaOff =
+ !hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
+
+ let minHeight = 0;
+ let height = 0;
+ let maxHeight = 0;
+ if (sidebarContentInput.isOpen) {
+ if (isMobile) {
+ height = windowHeight() - DEFAULT_VALUES.navBarHeight - bannerAreaHeight();
+ minHeight = height;
+ maxHeight = height;
+ } else if (isOpen && !isGeneralMediaOff) {
+ if (sidebarContentInput.height > 0 && sidebarContentInput.height < windowHeight()) {
+ height = sidebarContentInput.height - bannerAreaHeight();
+ } else {
+ const { size: slideSize } = presentationInput.currentSlide;
+ let calculatedHeight = (windowHeight() - bannerAreaHeight()) * 0.3;
+
+ if (slideSize.height > 0 && slideSize.width > 0) {
+ calculatedHeight = (slideSize.height * sidebarContentOutput.width) / slideSize.width;
+ }
+ height = windowHeight() - calculatedHeight - bannerAreaHeight();
+ }
+ maxHeight = windowHeight() * 0.75 - bannerAreaHeight();
+ minHeight = windowHeight() * 0.25 - bannerAreaHeight();
+
+ if (height > maxHeight) {
+ height = maxHeight;
+ }
+ } else {
+ height = windowHeight() - bannerAreaHeight();
+ maxHeight = height;
+ minHeight = height;
+ }
+ }
+ return {
+ minHeight,
+ height,
+ maxHeight,
+ };
+ };
+
+ const calculatesCameraDockBounds = (mediaAreaBounds, sidebarSize) => {
+ const { baseCameraDockBounds } = props;
+
+ const baseBounds = baseCameraDockBounds(mediaAreaBounds, sidebarSize);
+
+ // do not proceed if using values from LayoutEngine
+ if (Object.keys(baseBounds).length > 0) {
+ return baseBounds;
+ }
+
+ const { navBarHeight } = DEFAULT_VALUES;
+
+ const cameraDockBounds = {};
+
+ const mobileCameraHeight = mediaAreaBounds.height * 0.7 - bannerAreaHeight();
+ const cameraHeight = mediaAreaBounds.height - bannerAreaHeight();
+
+ if (isMobile) {
+ cameraDockBounds.minHeight = mobileCameraHeight;
+ cameraDockBounds.height = mobileCameraHeight;
+ cameraDockBounds.maxHeight = mobileCameraHeight;
+ } else {
+ cameraDockBounds.minHeight = cameraHeight;
+ cameraDockBounds.height = cameraHeight;
+ cameraDockBounds.maxHeight = cameraHeight;
+ }
+
+ cameraDockBounds.top = navBarHeight + bannerAreaHeight();
+ cameraDockBounds.left = !isRTL ? mediaAreaBounds.left : null;
+ cameraDockBounds.right = isRTL ? sidebarSize : null;
+ cameraDockBounds.minWidth = mediaAreaBounds.width;
+ cameraDockBounds.width = mediaAreaBounds.width;
+ cameraDockBounds.maxWidth = mediaAreaBounds.width;
+ cameraDockBounds.zIndex = 1;
+
+ return cameraDockBounds;
+ };
+
+ const calculatesMediaBounds = (
+ mediaAreaBounds,
+ cameraDockBounds,
+ sidebarNavWidth,
+ sidebarContentWidth,
+ sidebarContentHeight
+ ) => {
+ const mediaBounds = {};
+ const { element: fullscreenElement } = fullscreen;
+
+ if (
+ fullscreenElement === 'Presentation' ||
+ fullscreenElement === 'Screenshare' ||
+ fullscreenElement === 'ExternalVideo'
+ ) {
+ mediaBounds.width = windowWidth();
+ mediaBounds.height = windowHeight();
+ mediaBounds.top = 0;
+ mediaBounds.left = 0;
+ mediaBounds.right = 0;
+ mediaBounds.zIndex = 99;
+ return mediaBounds;
+ }
+
+ if (isMobile) {
+ mediaBounds.height = mediaAreaBounds.height - cameraDockBounds.height;
+ mediaBounds.left = mediaAreaBounds.left;
+ mediaBounds.top = mediaAreaBounds.top + cameraDockBounds.height;
+ mediaBounds.width = mediaAreaBounds.width;
+ } else if (presentationInput.isOpen) {
+ mediaBounds.height = windowHeight() - sidebarContentHeight - bannerAreaHeight();
+ mediaBounds.left = !isRTL ? sidebarNavWidth : 0;
+ mediaBounds.right = isRTL ? sidebarNavWidth : 0;
+ mediaBounds.top = sidebarContentHeight + bannerAreaHeight();
+ mediaBounds.width = sidebarContentWidth;
+ mediaBounds.zIndex = 1;
+ } else if (!presentationInput.isOpen) {
+ mediaBounds.width = 0;
+ mediaBounds.height = 0;
+ mediaBounds.top = 0;
+ mediaBounds.left = 0;
+ }
+
+ return mediaBounds;
+ };
+
+ const calculatesLayout = () => {
+ const {
+ calculatesNavbarBounds,
+ calculatesActionbarBounds,
+ calculatesSidebarNavWidth,
+ calculatesSidebarNavHeight,
+ calculatesSidebarNavBounds,
+ calculatesSidebarContentWidth,
+ calculatesSidebarContentBounds,
+ calculatesMediaAreaBounds,
+ isTablet,
+ } = props;
+ const { captionsMargin } = DEFAULT_VALUES;
+
+ const sidebarNavWidth = calculatesSidebarNavWidth();
+ const sidebarNavHeight = calculatesSidebarNavHeight();
+ const sidebarContentWidth = calculatesSidebarContentWidth();
+ const sidebarNavBounds = calculatesSidebarNavBounds();
+ const sidebarContentBounds = calculatesSidebarContentBounds(sidebarNavWidth.width);
+ const mediaAreaBounds = calculatesMediaAreaBounds(
+ sidebarNavWidth.width,
+ sidebarContentWidth.width
+ );
+ const navbarBounds = calculatesNavbarBounds(mediaAreaBounds);
+ const actionbarBounds = calculatesActionbarBounds(mediaAreaBounds);
+ const sidebarSize = sidebarContentWidth.width + sidebarNavWidth.width;
+ const cameraDockBounds = calculatesCameraDockBounds(mediaAreaBounds, sidebarSize);
+ const sidebarContentHeight = calculatesSidebarContentHeight();
+ const mediaBounds = calculatesMediaBounds(
+ mediaAreaBounds,
+ cameraDockBounds,
+ sidebarNavWidth.width,
+ sidebarContentWidth.width,
+ sidebarContentHeight.height
+ );
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_NAVBAR_OUTPUT,
+ value: {
+ display: navbarInput.hasNavBar,
+ width: navbarBounds.width,
+ height: navbarBounds.height,
+ top: navbarBounds.top,
+ left: navbarBounds.left,
+ tabOrder: DEFAULT_VALUES.navBarTabOrder,
+ zIndex: navbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_ACTIONBAR_OUTPUT,
+ value: {
+ display: actionbarInput.hasActionBar,
+ width: actionbarBounds.width,
+ height: actionbarBounds.height,
+ innerHeight: actionbarBounds.innerHeight,
+ top: actionbarBounds.top,
+ left: actionbarBounds.left,
+ padding: actionbarBounds.padding,
+ tabOrder: DEFAULT_VALUES.actionBarTabOrder,
+ zIndex: actionbarBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAPTIONS_OUTPUT,
+ value: {
+ left: !isRTL ? sidebarSize + captionsMargin : null,
+ right: isRTL ? sidebarSize + captionsMargin : null,
+ maxWidth: mediaAreaBounds.width - captionsMargin * 2,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_OUTPUT,
+ value: {
+ display: sidebarNavigationInput.isOpen,
+ minWidth: sidebarNavWidth.minWidth,
+ width: sidebarNavWidth.width,
+ maxWidth: sidebarNavWidth.maxWidth,
+ height: sidebarNavHeight,
+ top: sidebarNavBounds.top,
+ left: sidebarNavBounds.left,
+ right: sidebarNavBounds.right,
+ tabOrder: DEFAULT_VALUES.sidebarNavTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarNavBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: false,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_OUTPUT,
+ value: {
+ display: sidebarContentInput.isOpen,
+ minWidth: sidebarContentWidth.minWidth,
+ width: sidebarContentWidth.width,
+ maxWidth: sidebarContentWidth.maxWidth,
+ minHeight: sidebarContentHeight.minHeight,
+ height: sidebarContentHeight.height,
+ maxHeight: sidebarContentHeight.maxHeight,
+ top: sidebarContentBounds.top,
+ left: sidebarContentBounds.left,
+ right: sidebarContentBounds.right,
+ currentPanelType,
+ tabOrder: DEFAULT_VALUES.sidebarContentTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: sidebarContentBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_RESIZABLE_EDGE,
+ value: {
+ top: false,
+ right: !isRTL,
+ bottom: true,
+ left: isRTL,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_MEDIA_AREA_SIZE,
+ value: {
+ width: mediaAreaBounds.width,
+ height: mediaAreaBounds.height,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_OUTPUT,
+ value: {
+ display: true,
+ minWidth: cameraDockBounds.minWidth,
+ width: cameraDockBounds.width,
+ maxWidth: cameraDockBounds.maxWidth,
+ minHeight: cameraDockBounds.minHeight,
+ height: cameraDockBounds.height,
+ maxHeight: cameraDockBounds.maxHeight,
+ top: cameraDockBounds.top,
+ left: cameraDockBounds.left,
+ right: cameraDockBounds.right,
+ tabOrder: 4,
+ isDraggable: false,
+ resizableEdge: {
+ top: false,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ zIndex: cameraDockBounds.zIndex,
+ focusedId: input.cameraDock.focusedId,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_OUTPUT,
+ value: {
+ display: presentationInput.isOpen,
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right : null,
+ tabOrder: DEFAULT_VALUES.presentationTabOrder,
+ isResizable: !isMobile && !isTablet,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_RESIZABLE_EDGE,
+ value: {
+ top: true,
+ right: false,
+ bottom: false,
+ left: false,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SCREEN_SHARE_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right : null,
+ zIndex: mediaBounds.zIndex,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_EXTERNAL_VIDEO_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right : null,
+ },
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
+ value: {
+ width: mediaBounds.width,
+ height: mediaBounds.height,
+ top: mediaBounds.top,
+ left: mediaBounds.left,
+ right: isRTL ? mediaBounds.right : null,
+ },
+ });
+ };
+
+ return null;
+};
+
+export default VideoFocusLayout;
\ No newline at end of file
diff --git a/src/2.7.12/imports/ui/components/layout/modal/component.jsx b/src/2.7.12/imports/ui/components/layout/modal/component.jsx
new file mode 100644
index 00000000..7494f57d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/modal/component.jsx
@@ -0,0 +1,201 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { LAYOUT_TYPE, CAMERADOCK_POSITION } from '/imports/ui/components/layout/enums';
+import SettingsService from '/imports/ui/components/settings/service';
+import deviceInfo from '/imports/utils/deviceInfo';
+import Button from '/imports/ui/components/common/button/component';
+import Styled from './styles';
+
+const LayoutModalComponent = (props) => {
+ const {
+ intl,
+ setIsOpen,
+ isModerator,
+ isPresenter,
+ application,
+ updateSettings,
+ onRequestClose,
+ isOpen,
+ } = props;
+
+ const [selectedLayout, setSelectedLayout] = useState(application.selectedLayout);
+ const [updateAllUsed, setUpdateAllUsed] = useState(false);
+
+ const BASE_NAME = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
+
+ const LAYOUTS_PATH = `${BASE_NAME}/resources/images/layouts/`;
+ const isKeepPushingLayoutEnabled = SettingsService.isKeepPushingLayoutEnabled();
+
+ const intlMessages = defineMessages({
+ title: {
+ id: 'app.layout.modal.title',
+ description: 'Modal title',
+ },
+ update: {
+ id: 'app.layout.modal.update',
+ description: 'Modal confirm button',
+ },
+ updateAll: {
+ id: 'app.layout.modal.updateAll',
+ description: 'Modal updateAll button',
+ },
+ layoutLabel: {
+ id: 'app.layout.modal.layoutLabel',
+ description: 'Layout label',
+ },
+ layoutToastLabelAuto: {
+ id: 'app.layout.modal.layoutToastLabelAuto',
+ description: 'Layout toast label',
+ },
+ layoutToastLabel: {
+ id: 'app.layout.modal.layoutToastLabel',
+ description: 'Layout toast label',
+ },
+ layoutToastLabelAutoOff: {
+ id: 'app.layout.modal.layoutToastLabelAutoOff',
+ description: 'Layout toast label',
+ },
+ customLayout: {
+ id: 'app.layout.style.custom',
+ description: 'label for custom layout style',
+ },
+ smartLayout: {
+ id: 'app.layout.style.smart',
+ description: 'label for smart layout style',
+ },
+ presentationFocusLayout: {
+ id: 'app.layout.style.presentationFocus',
+ description: 'label for presentationFocus layout style',
+ },
+ videoFocusLayout: {
+ id: 'app.layout.style.videoFocus',
+ description: 'label for videoFocus layout style',
+ },
+ layoutSingular: {
+ id: 'app.layout.modal.layoutSingular',
+ description: 'label for singular layout',
+ },
+ layoutBtnDesc: {
+ id: 'app.layout.modal.layoutBtnDesc',
+ description: 'label for singular layout',
+ },
+ });
+
+ const handleSwitchLayout = (e) => {
+ setSelectedLayout(e);
+ };
+
+ const handleUpdateLayout = (updateAll) => {
+ const obj = {
+ application:
+ { ...application, selectedLayout, pushLayout: updateAll },
+ };
+ if ((isModerator || isPresenter) && updateAll) {
+ updateSettings(obj, intlMessages.layoutToastLabelAuto);
+ setUpdateAllUsed(true);
+ } else if ((isModerator || isPresenter) && !updateAll && !updateAllUsed) {
+ updateSettings(obj, intlMessages.layoutToastLabelAutoOff);
+ setUpdateAllUsed(false);
+ } else {
+ updateSettings(obj, intlMessages.layoutToastLabel);
+ }
+ setIsOpen(false);
+ };
+
+ const renderPushLayoutsOptions = () => {
+ if (!isModerator && !isPresenter) {
+ return null;
+ }
+
+ if (isKeepPushingLayoutEnabled) {
+ return (
+ handleUpdateLayout(true)}
+ color="secondary"
+ data-test="updateEveryoneLayoutBtn"
+ />
+ );
+ }
+ return null;
+ };
+
+ const renderLayoutButtons = () => (
+
+ {Object.values(LAYOUT_TYPE)
+ .map((layout) => (
+
+
+ )}
+ onClick={() => {
+ handleSwitchLayout(layout);
+ if (layout === LAYOUT_TYPE.CUSTOM_LAYOUT && application.selectedLayout !== layout) {
+ document.getElementById('layout')?.setAttribute('data-cam-position', CAMERADOCK_POSITION.CONTENT_TOP);
+ }
+ }}
+ active={(layout === selectedLayout).toString()}
+ aria-describedby="layout-btn-desc"
+ data-test={`${layout}Layout`}
+ />
+ {intl.formatMessage(intlMessages[`${layout}Layout`])}
+
+ ))}
+
+ );
+
+ return (
+ setIsOpen(false)}
+ title={intl.formatMessage(intlMessages.title)}
+ {...{
+ isOpen,
+ onRequestClose,
+ }}
+ >
+
+
+ {renderLayoutButtons()}
+
+
+
+ {renderPushLayoutsOptions()}
+ handleUpdateLayout(false)}
+ data-test="updateLayoutBtn"
+ />
+
+ {intl.formatMessage(intlMessages.layoutBtnDesc)}
+
+ );
+};
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isModerator: PropTypes.bool.isRequired,
+ isPresenter: PropTypes.bool.isRequired,
+ showToggleLabel: PropTypes.bool.isRequired,
+ application: PropTypes.shape({
+ selectedLayout: PropTypes.string.isRequired,
+ }).isRequired,
+ updateSettings: PropTypes.func.isRequired,
+};
+
+LayoutModalComponent.propTypes = propTypes;
+
+export default injectIntl(LayoutModalComponent);
diff --git a/src/2.7.12/imports/ui/components/layout/modal/container.jsx b/src/2.7.12/imports/ui/components/layout/modal/container.jsx
new file mode 100644
index 00000000..f38aa8ce
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/modal/container.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import SettingsService from '/imports/ui/services/settings';
+import LayoutModalComponent from './component';
+
+import {
+ updateSettings,
+ isPresenter,
+} from '/imports/ui/components/settings/service';
+
+const LayoutModalContainer = (props) => {
+ const { intl, setIsOpen,onRequestClose, isOpen, isModerator, isPresenter,
+ application, updateSettings, } = props;
+ return };
+
+export default withTracker(({ amIModerator }) => ({
+ application: SettingsService.application,
+ updateSettings,
+ isPresenter: isPresenter(),
+ isModerator: amIModerator,
+}))(LayoutModalContainer);
diff --git a/src/2.7.12/imports/ui/components/layout/modal/styles.js b/src/2.7.12/imports/ui/components/layout/modal/styles.js
new file mode 100644
index 00000000..a8e29a15
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/modal/styles.js
@@ -0,0 +1,190 @@
+import styled from 'styled-components';
+import {
+ colorPrimary,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import ModalStyles from '/imports/ui/components/common/modal/simple/styles';
+
+const Content = styled.div`
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ padding: .5rem 0 .5rem 0;
+ overflow: hidden;
+`;
+
+const LayoutModal = styled(ModalSimple)`
+ padding: 1rem;
+
+ @media ${smallOnly} {
+ height: unset;
+ }
+
+ ${({ isPhone }) => isPhone && `
+ min-height: 100%;
+ min-width: 100%;
+ border-radius: 0;
+ `}
+
+ ${ModalStyles.Content} {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ }
+`;
+
+const BodyContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const IconSvg = styled.img`
+ height: 8rem;
+ border-radius: 5px;
+ margin: 5px;
+
+ @media ${smallOnly} {
+ height: 3rem;
+ margin: 1px;
+ }
+`;
+
+const LayoutBtn = styled(Button)`
+ display: flex;
+ box-shadow: unset !important;
+ background-color: ${colorWhite};
+ border: ${colorWhite} solid 6px;
+ align-items: center;
+ flex-direction: column;
+ padding: 0 !important;
+ margin: 1rem 1rem 0.5rem 1rem;
+ width: fit-content;
+
+ @media ${smallOnly} {
+ margin: 0.5rem;
+ border: ${colorWhite} solid 4px;
+ border-radius: 10px;
+ width: fit-content;
+ }
+
+ &:focus,
+ &:hover {
+ border: ${colorPrimary} solid 6px;
+ border-radius: 5px;
+ }
+
+ ${({ active }) => (active === 'true') && `
+ border: ${colorPrimary} solid 6px;
+ border-radius: 5px;
+
+ @media ${smallOnly} {
+ border: ${colorPrimary} solid 4px;
+ border-radius: 5px;
+ }
+
+ &:before {
+ font-family: 'bbb-icons';
+ color: ${colorWhite};
+ position: fixed;
+ content: "\\e946";
+ background-color: ${colorPrimary};
+ margin-left: 13.1rem;
+ padding: 0.3rem 0.2rem 0 0.6rem;
+ border-radius: 0 0 0 .3rem;
+
+ [dir="rtl"] & {
+ left: auto;
+ margin-right: 13.1rem;
+ margin-left: unset;
+ padding: 0.3rem 0.6rem 0 0.2rem;
+ border-radius: 0 0 .3rem 0;
+ }
+ width: 1.8rem;
+ height: 1.8rem;
+
+ @media ${smallOnly} {
+ width: 1rem;
+ height: 1rem;
+ font-size: 0.6rem;
+ margin-left: 4.5rem;
+ padding: 0.2rem 0.2rem 0 0.3rem;
+
+ [dir="rtl"] & {
+ margin-right: 4.5rem;
+ margin-left: unset;
+ padding: 0.2rem 0.3rem 0 0.2rem;
+ }
+ }
+ }
+ `};
+`;
+
+const ButtonLayoutContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ @media ${smallOnly} {
+ align-items: center;
+ }
+`;
+
+const ButtonsContainer = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ @media ${smallOnly} {
+ flex-wrap: unset;
+ flex-direction: column;
+ align-items: center;
+ }
+`;
+
+const ButtonBottomContainer = styled.div`
+ align-self: end;
+ padding-right: 3rem;
+ padding-top: 1rem;
+
+ @media ${smallOnly} {
+ align-self: center;
+ padding-right: unset;
+ }
+`;
+
+const LabelLayoutNames = styled.label`
+ text-align: center;
+ margin: 0 0 0.1rem 0;
+`;
+
+const BottomButton = styled(Button)`
+ margin: 0 0.5rem;
+`;
+
+const PushContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 1rem 0 1rem 0;
+`;
+
+const LabelPushLayout = styled.div`
+ padding-right: 0.5rem;
+`;
+
+export default {
+ Content,
+ LayoutModal,
+ BodyContainer,
+ IconSvg,
+ LayoutBtn,
+ ButtonLayoutContainer,
+ ButtonsContainer,
+ ButtonBottomContainer,
+ LabelLayoutNames,
+ BottomButton,
+ PushContainer,
+ LabelPushLayout,
+};
diff --git a/src/2.7.12/imports/ui/components/layout/push-layout/pushLayoutEngine.jsx b/src/2.7.12/imports/ui/components/layout/push-layout/pushLayoutEngine.jsx
new file mode 100644
index 00000000..2d815b5e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/push-layout/pushLayoutEngine.jsx
@@ -0,0 +1,274 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Meteor } from 'meteor/meteor';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import Settings from '/imports/ui/services/settings';
+import MediaService from '/imports/ui/components/media/service';
+import { LAYOUT_TYPE, ACTIONS } from '../enums';
+import { isMobile } from '../utils';
+import { updateSettings } from '/imports/ui/components/settings/service';
+import { Session } from 'meteor/session';
+
+const HIDE_PRESENTATION = Meteor.settings.public.layout.hidePresentationOnJoin;
+
+const equalDouble = (n1, n2) => {
+ const precision = 0.01;
+
+ return Math.abs(n1 - n2) <= precision;
+};
+
+const propTypes = {
+ cameraWidth: PropTypes.number,
+ cameraHeight: PropTypes.number,
+ cameraIsResizing: PropTypes.bool,
+ cameraPosition: PropTypes.string,
+ focusedCamera: PropTypes.string,
+ horizontalPosition: PropTypes.bool,
+ isMeetingLayoutResizing: PropTypes.bool,
+ isPresenter: PropTypes.bool,
+ isModerator: PropTypes.bool,
+ layoutContextDispatch: PropTypes.func,
+ meetingLayout: PropTypes.string,
+ meetingLayoutCameraPosition: PropTypes.string,
+ meetingLayoutFocusedCamera: PropTypes.string,
+ meetingLayoutVideoRate: PropTypes.number,
+ meetingPresentationIsOpen: PropTypes.bool,
+ meetingLayoutUpdatedAt: PropTypes.number,
+ presentationIsOpen: PropTypes.bool,
+ presentationVideoRate: PropTypes.number,
+ pushLayout: PropTypes.bool,
+ pushLayoutMeeting: PropTypes.bool,
+ selectedLayout: PropTypes.string,
+ setMeetingLayout: PropTypes.func,
+ setPushLayout: PropTypes.func,
+ shouldShowScreenshare: PropTypes.bool,
+ shouldShowExternalVideo: PropTypes.bool,
+};
+
+class PushLayoutEngine extends React.Component {
+ constructor(props) {
+ super(props);
+ }
+
+ componentDidMount() {
+ const {
+ cameraWidth,
+ cameraHeight,
+ horizontalPosition,
+ layoutContextDispatch,
+ meetingLayoutCameraPosition,
+ meetingLayoutFocusedCamera,
+ meetingLayoutVideoRate,
+ meetingPresentationIsOpen,
+ shouldShowScreenshare,
+ shouldShowExternalVideo,
+ } = this.props;
+
+ const changedLayout = getFromUserSettings('bbb_change_layout', null);
+ if (changedLayout) {
+ Settings.application.selectedLayout = LAYOUT_TYPE[changedLayout];
+ }
+
+ let { selectedLayout } = Settings.application;
+ if (isMobile()) {
+ selectedLayout = selectedLayout === 'custom' ? 'smart' : selectedLayout;
+ Settings.application.selectedLayout = selectedLayout;
+ }
+ Session.set('isGridEnabled', selectedLayout === LAYOUT_TYPE.VIDEO_FOCUS);
+
+ Settings.save();
+
+ const initialPresentation = !getFromUserSettings('bbb_hide_presentation_on_join', HIDE_PRESENTATION || !meetingPresentationIsOpen) || shouldShowScreenshare || shouldShowExternalVideo;
+ MediaService.setPresentationIsOpen(layoutContextDispatch, initialPresentation);
+ Session.set('presentationLastState', initialPresentation);
+
+ if (selectedLayout === 'custom') {
+ setTimeout(() => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_FOCUSED_CAMERA_ID,
+ value: meetingLayoutFocusedCamera,
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_POSITION,
+ value: meetingLayoutCameraPosition,
+ });
+
+ if (!equalDouble(meetingLayoutVideoRate, 0)) {
+ let w, h;
+ if (horizontalPosition) {
+ w = window.innerWidth * meetingLayoutVideoRate;
+ h = cameraHeight;
+ } else {
+ w = cameraWidth;
+ h = window.innerHeight * meetingLayoutVideoRate;
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_SIZE,
+ value: {
+ width: w,
+ height: h,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ }
+ });
+ }
+ }, 0);
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ cameraWidth,
+ cameraHeight,
+ cameraIsResizing,
+ cameraPosition,
+ focusedCamera,
+ horizontalPosition,
+ isMeetingLayoutResizing,
+ isModerator,
+ isPresenter,
+ layoutContextDispatch,
+ meetingLayout,
+ meetingLayoutUpdatedAt,
+ meetingPresentationIsOpen,
+ meetingLayoutCameraPosition,
+ meetingLayoutFocusedCamera,
+ meetingLayoutVideoRate,
+ presentationIsOpen,
+ presentationVideoRate,
+ pushLayout,
+ pushLayoutMeeting,
+ selectedLayout,
+ setMeetingLayout,
+ setPushLayout,
+ } = this.props;
+
+ const meetingLayoutDidChange = meetingLayout !== prevProps.meetingLayout;
+ const pushLayoutMeetingDidChange = pushLayoutMeeting !== prevProps.pushLayoutMeeting;
+ const shouldSwitchLayout = isPresenter
+ ? meetingLayoutDidChange
+ : (meetingLayoutDidChange || pushLayoutMeetingDidChange) && pushLayoutMeeting;
+
+ if (shouldSwitchLayout) {
+
+ let contextLayout = meetingLayout;
+ if (isMobile()) {
+ contextLayout = meetingLayout === 'custom' ? 'smart' : meetingLayout;
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_LAYOUT_TYPE,
+ value: contextLayout,
+ });
+
+ updateSettings({
+ application: {
+ ...Settings.application,
+ selectedLayout: contextLayout,
+ },
+ });
+ }
+
+ if (pushLayoutMeetingDidChange) {
+ updateSettings({
+ application: {
+ ...Settings.application,
+ pushLayout: pushLayoutMeeting,
+ },
+ });
+ }
+
+ if (meetingLayout === "custom" && selectedLayout === "custom" && !isPresenter) {
+
+ if (meetingLayoutFocusedCamera !== prevProps.meetingLayoutFocusedCamera
+ || meetingLayoutUpdatedAt !== prevProps.meetingLayoutUpdatedAt) {
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_FOCUSED_CAMERA_ID,
+ value: meetingLayoutFocusedCamera,
+ });
+ }
+
+ if (meetingLayoutCameraPosition !== prevProps.meetingLayoutCameraPosition
+ || meetingLayoutUpdatedAt !== prevProps.meetingLayoutUpdatedAt) {
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_POSITION,
+ value: meetingLayoutCameraPosition,
+ });
+ }
+
+ if (!equalDouble(meetingLayoutVideoRate, prevProps.meetingLayoutVideoRate)
+ || meetingLayoutUpdatedAt !== prevProps.meetingLayoutUpdatedAt) {
+
+ let w, h;
+ if (horizontalPosition) {
+ w = window.innerWidth * meetingLayoutVideoRate;
+ h = cameraHeight;
+ } else {
+ w = cameraWidth;
+ h = window.innerHeight * meetingLayoutVideoRate;
+ }
+
+ if (isMeetingLayoutResizing !== prevProps.isMeetingLayoutResizing) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
+ value: isMeetingLayoutResizing,
+ });
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_SIZE,
+ value: {
+ width: w,
+ height: h,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ }
+ });
+ }
+
+ if (meetingPresentationIsOpen !== prevProps.meetingPresentationIsOpen
+ || meetingLayoutUpdatedAt !== prevProps.meetingLayoutUpdatedAt) {
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: meetingPresentationIsOpen,
+ });
+ }
+ }
+
+ const layoutChanged = presentationIsOpen !== prevProps.presentationIsOpen
+ || selectedLayout !== prevProps.selectedLayout
+ || cameraIsResizing !== prevProps.cameraIsResizing
+ || cameraPosition !== prevProps.cameraPosition
+ || focusedCamera !== prevProps.focusedCamera
+ || !equalDouble(presentationVideoRate, prevProps.presentationVideoRate);
+
+ if (pushLayout !== prevProps.pushLayout) { // push layout once after presenter toggles / special case where we set pushLayout to false in all viewers
+ if (isModerator) {
+ setPushLayout(pushLayout);
+ }
+ }
+
+ if (pushLayout && layoutChanged || pushLayout !== prevProps.pushLayout) { // change layout sizes / states
+ if (isPresenter) {
+ setMeetingLayout();
+ }
+ }
+
+ if (selectedLayout !== prevProps.selectedLayout) {
+ Session.set('isGridEnabled', selectedLayout === LAYOUT_TYPE.VIDEO_FOCUS);
+ }
+ }
+
+ render() {
+ return null;
+ }
+};
+
+PushLayoutEngine.propTypes = propTypes;
+
+export default PushLayoutEngine;
diff --git a/src/2.7.12/imports/ui/components/layout/service.js b/src/2.7.12/imports/ui/components/layout/service.js
new file mode 100644
index 00000000..ebb19878
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/service.js
@@ -0,0 +1,14 @@
+import { makeCall } from '/imports/ui/services/api';
+
+const setPushLayout = (pushLayout) => {
+ makeCall('setPushLayout', { pushLayout });
+};
+
+const setMeetingLayout = (options) => {
+ makeCall('changeLayout', options);
+};
+
+export default {
+ setPushLayout,
+ setMeetingLayout,
+};
diff --git a/src/2.7.12/imports/ui/components/layout/utils.js b/src/2.7.12/imports/ui/components/layout/utils.js
new file mode 100644
index 00000000..26c8c98a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/layout/utils.js
@@ -0,0 +1,69 @@
+import { DEVICE_TYPE, LAYOUT_TYPE } from './enums';
+
+const phoneUpperBoundary = 600;
+const tabletPortraitUpperBoundary = 900;
+const tabletLandscapeUpperBoundary = 1200;
+
+const windowSize = () => window.document.documentElement.clientWidth;
+const isMobile = () => windowSize() <= (phoneUpperBoundary - 1);
+const isTabletPortrait = () => windowSize() >= phoneUpperBoundary
+ && windowSize() <= (tabletPortraitUpperBoundary - 1);
+const isTabletLandscape = () => windowSize() >= tabletPortraitUpperBoundary
+ && windowSize() <= (tabletLandscapeUpperBoundary - 1);
+const isTablet = () => windowSize() >= phoneUpperBoundary
+ && windowSize() <= (tabletLandscapeUpperBoundary - 1);
+const isDesktop = () => windowSize() >= tabletLandscapeUpperBoundary;
+
+const device = {
+ isMobile, isTablet, isTabletPortrait, isTabletLandscape, isDesktop,
+};
+
+export default device;
+export {
+ isMobile, isTablet, isTabletPortrait, isTabletLandscape, isDesktop,
+};
+
+// Array for select component to select diferent layout
+const suportedLayouts = [
+ {
+ layoutKey: LAYOUT_TYPE.SMART_LAYOUT,
+ layoutName: 'Smart Layout',
+ suportedDevices: [
+ DEVICE_TYPE.MOBILE,
+ DEVICE_TYPE.TABLET,
+ DEVICE_TYPE.TABLET_PORTRAIT,
+ DEVICE_TYPE.TABLET_LANDSCAPE,
+ DEVICE_TYPE.DESKTOP,
+ ],
+ },
+ {
+ layoutKey: LAYOUT_TYPE.VIDEO_FOCUS,
+ layoutName: 'Video Focus',
+ suportedDevices: [
+ DEVICE_TYPE.MOBILE,
+ DEVICE_TYPE.TABLET,
+ DEVICE_TYPE.TABLET_PORTRAIT,
+ DEVICE_TYPE.TABLET_LANDSCAPE,
+ DEVICE_TYPE.DESKTOP,
+ ],
+ },
+ {
+ layoutKey: LAYOUT_TYPE.PRESENTATION_FOCUS,
+ layoutName: 'Presentation Focus',
+ suportedDevices: [
+ DEVICE_TYPE.MOBILE,
+ DEVICE_TYPE.TABLET,
+ DEVICE_TYPE.TABLET_PORTRAIT,
+ DEVICE_TYPE.TABLET_LANDSCAPE,
+ DEVICE_TYPE.DESKTOP,
+ ],
+ },
+ {
+ layoutKey: LAYOUT_TYPE.CUSTOM_LAYOUT,
+ layoutName: 'Custom Layout',
+ suportedDevices: [
+ DEVICE_TYPE.DESKTOP,
+ ],
+ },
+];
+export { suportedLayouts };
diff --git a/src/2.7.12/imports/ui/components/learning-dashboard/service.js b/src/2.7.12/imports/ui/components/learning-dashboard/service.js
new file mode 100644
index 00000000..8839ddf2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/learning-dashboard/service.js
@@ -0,0 +1,56 @@
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const isModerator = () => {
+ const user = Users.findOne(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ },
+ { fields: { role: 1 } },
+ );
+
+ if (user && user.role === ROLE_MODERATOR) {
+ return true;
+ }
+
+ return false;
+};
+
+const getLearningDashboardAccessToken = () => ((
+ Meetings.findOne(
+ { meetingId: Auth.meetingID, learningDashboardAccessToken: { $exists: true } },
+ {
+ fields: { learningDashboardAccessToken: 1 },
+ },
+ ) || {}).learningDashboardAccessToken || null);
+
+const setLearningDashboardCookie = () => {
+ const learningDashboardAccessToken = getLearningDashboardAccessToken();
+ if (learningDashboardAccessToken !== null) {
+ const lifetime = new Date();
+ lifetime.setTime(lifetime.getTime() + (3600000)); // 1h (extends 7d when open Dashboard)
+ document.cookie = `ld-${Auth.meetingID}=${getLearningDashboardAccessToken()}; expires=${lifetime.toGMTString()}; path=/`;
+ return true;
+ }
+ return false;
+};
+
+const openLearningDashboardUrl = (lang) => {
+ const APP = Meteor.settings.public.app;
+ if (getLearningDashboardAccessToken() && setLearningDashboardCookie()) {
+ window.open(`${APP.learningDashboardBase}/?meeting=${Auth.meetingID}&lang=${lang}`, '_blank');
+ } else {
+ window.open(`${APP.learningDashboardBase}/?meeting=${Auth.meetingID}&sessionToken=${Auth.sessionToken}&lang=${lang}`, '_blank');
+ }
+};
+
+export default {
+ isModerator,
+ getLearningDashboardAccessToken,
+ setLearningDashboardCookie,
+ openLearningDashboardUrl,
+};
diff --git a/src/2.7.12/imports/ui/components/legacy/component.jsx b/src/2.7.12/imports/ui/components/legacy/component.jsx
new file mode 100644
index 00000000..5a33ce94
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/legacy/component.jsx
@@ -0,0 +1,205 @@
+import React, { Component } from 'react';
+import { IntlProvider, FormattedMessage } from 'react-intl';
+import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
+import './styles.css';
+
+
+// currently supported locales.
+// import ar from 'react-intl/locale-data/ar';
+// import bg from 'react-intl/locale-data/bg';
+// import cs from 'react-intl/locale-data/cs';
+// import de from 'react-intl/locale-data/de';
+// import el from 'react-intl/locale-data/el';
+// import en from 'react-intl/locale-data/en';
+// import es from 'react-intl/locale-data/es';
+// import eu from 'react-intl/locale-data/eu';
+// import fa from 'react-intl/locale-data/fa';
+// import fi from 'react-intl/locale-data/fi';
+// import fr from 'react-intl/locale-data/fr';
+// import he from 'react-intl/locale-data/he';
+// import hi from 'react-intl/locale-data/hi';
+// import hu from 'react-intl/locale-data/hu';
+// import id from 'react-intl/locale-data/id';
+// import it from 'react-intl/locale-data/it';
+// import ja from 'react-intl/locale-data/ja';
+// import km from 'react-intl/locale-data/km';
+// import pl from 'react-intl/locale-data/pl';
+// import pt from 'react-intl/locale-data/pt';
+// import ru from 'react-intl/locale-data/ru';
+// import sv from 'react-intl/locale-data/sv';
+// import tr from 'react-intl/locale-data/tr';
+// import uk from 'react-intl/locale-data/uk';
+// import vi from 'react-intl/locale-data/vi';
+// import zh from 'react-intl/locale-data/zh';
+
+// // This class is the only component loaded on legacy (unsupported) browsers.
+// // What is included here needs to be minimal and carefully considered because some
+// // things can't be polyfilled.
+
+// addLocaleData([
+// ...ar,
+// ...bg,
+// ...cs,
+// ...de,
+// ...el,
+// ...en,
+// ...es,
+// ...eu,
+// ...fa,
+// ...fi,
+// ...fr,
+// ...he,
+// ...hi,
+// ...hu,
+// ...id,
+// ...it,
+// ...ja,
+// ...km,
+// ...pl,
+// ...pt,
+// ...ru,
+// ...sv,
+// ...tr,
+// ...uk,
+// ...vi,
+// ...zh,
+// ]);
+
+const FETCHING = 'fetching';
+const FALLBACK = 'fallback';
+const READY = 'ready';
+const supportedBrowsers = ['Chrome', 'Firefox', 'Safari', 'Opera', 'Microsoft Edge', 'Yandex Browser'];
+const DEFAULT_LANGUAGE = Meteor.settings.public.app.defaultSettings.application.fallbackLocale;
+const CLIENT_VERSION = Meteor.settings.public.app.html5ClientBuild;
+
+export default class Legacy extends Component {
+ constructor(props) {
+ super(props);
+
+ const locale = navigator.languages ? navigator.languages[0] : false
+ || navigator.language;
+
+ const url = `./locale?locale=${locale}`;
+ const localesPath = 'locales';
+
+ const that = this;
+ this.state = { viewState: FETCHING };
+ fetch(url)
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.reject();
+ }
+
+ return response.json();
+ })
+ .then(({ normalizedLocale, regionDefaultLocale }) => {
+ fetch(`${localesPath}/${DEFAULT_LANGUAGE}.json?v=${CLIENT_VERSION}`)
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.reject();
+ }
+ return response.json();
+ })
+ .then((messages) => {
+ if (regionDefaultLocale !== '') {
+ fetch(`${localesPath}/${regionDefaultLocale}.json?v=${CLIENT_VERSION}`)
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.resolve();
+ }
+ return response.json();
+ })
+ .then((regionDefaultMessages) => {
+ messages = Object.assign(messages, regionDefaultMessages);
+ this.setState({ messages});
+ });
+ }
+
+ if (normalizedLocale && normalizedLocale !== DEFAULT_LANGUAGE && normalizedLocale !== regionDefaultLocale) {
+ fetch(`${localesPath}/${normalizedLocale}.json?v=${CLIENT_VERSION}`)
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.reject();
+ }
+ return response.json();
+ })
+ .then((localeMessages) => {
+ messages = Object.assign(messages, localeMessages);
+ this.setState({ messages});
+ })
+ .catch(() => {
+ normalizedLocale = (regionDefaultLocale) || DEFAULT_LANGUAGE;
+ const dasherizedLocale = normalizedLocale.replace('_', '-');
+ this.setState({ messages, normalizedLocale: dasherizedLocale, viewState: READY });
+ });
+ }
+ return messages;
+ })
+ .then((messages) => {
+ const dasherizedLocale = normalizedLocale.replace('_', '-');
+ this.setState({ messages, normalizedLocale: dasherizedLocale, viewState: READY });
+ })
+ .catch(() => {
+ that.setState({ viewState: FALLBACK });
+ });
+ })
+ .catch(() => {
+ that.setState({ viewState: FALLBACK });
+ });
+ }
+
+ render() {
+ const { browserName, isSafari } = browserInfo;
+ const { isIos } = deviceInfo;
+
+ const { messages, normalizedLocale, viewState } = this.state;
+ const isSupportedBrowser = supportedBrowsers.includes(browserName);
+ const isUnsupportedIos = isIos && !isSafari;
+
+ let messageId = isSupportedBrowser ? 'app.legacy.upgradeBrowser' : 'app.legacy.unsupportedBrowser';
+ if (isUnsupportedIos) messageId = 'app.legacy.criosBrowser';
+
+ switch (viewState) {
+ case READY:
+ return (
+
+
+ Chrome
,
+ 1: Firefox ,
+ }}
+ />
+
+
+ );
+ case FALLBACK:
+ return (
+
+ {isUnsupportedIos ? (
+ Please use Safari on iOS for full support.
+ ) : (
+
+
+ It looks like you're using a browser that
+ is not fully supported. Please use either
+ {' '}
+
+ Chrome
+ or
+ Firefox
+ for full support.
+
+ )
+ }
+
+ );
+ case FETCHING:
+ default:
+ return null;
+ }
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/legacy/styles.css b/src/2.7.12/imports/ui/components/legacy/styles.css
new file mode 100644
index 00000000..38b8ad41
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/legacy/styles.css
@@ -0,0 +1,26 @@
+/*
+ The legacy styles can't be scss because it will use Proxy and you can't polyfill it
+*/
+
+.browserWarning {
+ border: 5px solid #F3F6F9;
+ border-radius: 0.5rem;
+ color: #F3F6F9;
+ text-align: center;
+ width:500px;
+ position:fixed;
+ left:50%;
+ margin-left: -250px;
+ top:50%;
+ margin-top: -4rem;
+ font-size: 1.5rem;
+ padding: 10px;
+}
+
+@media only screen and (max-width: 40em) {
+ .browserWarning {
+ width: 95%;
+ left: 2.5%;
+ margin-left: 0;
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/component.jsx b/src/2.7.12/imports/ui/components/lock-viewers/component.jsx
new file mode 100644
index 00000000..ff950d35
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/component.jsx
@@ -0,0 +1,449 @@
+import React, { Fragment, Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Toggle from '/imports/ui/components/common/switch/component';
+import NotesService from '/imports/ui/components/notes/service';
+import Styled from './styles';
+import { isChatEnabled } from '/imports/ui/services/features';
+
+const intlMessages = defineMessages({
+ lockViewersTitle: {
+ id: 'app.lock-viewers.title',
+ description: 'lock-viewers title',
+ },
+ closeLabel: {
+ id: 'app.shortcut-help.closeLabel',
+ description: 'label for close button',
+ },
+ closeDesc: {
+ id: 'app.shortcut-help.closeDesc',
+ description: 'description for close button',
+ },
+ lockViewersDescription: {
+ id: 'app.lock-viewers.description',
+ description: 'description for lock viewers feature',
+ },
+ featuresLable: {
+ id: 'app.lock-viewers.featuresLable',
+ description: 'features label',
+ },
+ lockStatusLabel: {
+ id: 'app.lock-viewers.lockStatusLabel',
+ description: 'description for close button',
+ },
+ webcamLabel: {
+ id: 'app.lock-viewers.webcamLabel',
+ description: 'label for webcam toggle',
+ },
+ otherViewersWebcamLabel: {
+ id: 'app.lock-viewers.otherViewersWebcamLabel',
+ description: 'label for other viewers webcam toggle',
+ },
+ microphoneLable: {
+ id: 'app.lock-viewers.microphoneLable',
+ description: 'label for microphone toggle',
+ },
+ publicChatLabel: {
+ id: 'app.lock-viewers.PublicChatLabel',
+ description: 'label for public chat toggle',
+ },
+ privateChatLable: {
+ id: 'app.lock-viewers.PrivateChatLable',
+ description: 'label for private chat toggle',
+ },
+ notesLabel: {
+ id: 'app.lock-viewers.notesLabel',
+ description: 'label for shared notes toggle',
+ },
+ userListLabel: {
+ id: 'app.lock-viewers.userListLabel',
+ description: 'label for user list toggle',
+ },
+ ariaModalTitle: {
+ id: 'app.lock-viewers.ariaTitle',
+ description: 'aria label for modal title',
+ },
+ buttonApply: {
+ id: 'app.lock-viewers.button.apply',
+ description: 'label for apply button',
+ },
+ buttonCancel: {
+ id: 'app.lock-viewers.button.cancel',
+ description: 'label for cancel button',
+ },
+ lockedLabel: {
+ id: 'app.lock-viewers.locked',
+ description: 'locked element label',
+ },
+ hideCursorsLabel: {
+ id: "app.lock-viewers.hideViewersCursor",
+ description: 'label for other viewers cursor',
+ },
+ hideAnnotationsLabel: {
+ id: "app.lock-viewers.hideAnnotationsLabel",
+ description: 'label for other viewers annotation',
+ }
+});
+
+const propTypes = {
+ closeModal: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ meeting: PropTypes.object.isRequired,
+ showToggleLabel: PropTypes.bool.isRequired,
+ updateLockSettings: PropTypes.func.isRequired,
+ updateWebcamsOnlyForModerator: PropTypes.func.isRequired,
+};
+
+class LockViewersComponent extends Component {
+ constructor(props) {
+ super(props);
+
+ const { meeting: { lockSettingsProps, usersProp } } = this.props;
+
+ this.state = {
+ lockSettingsProps,
+ usersProp,
+ };
+ }
+
+ toggleLockSettings(property) {
+ const { lockSettingsProps } = this.state;
+
+ lockSettingsProps[property] = !lockSettingsProps[property];
+
+ this.setState({
+ lockSettingsProps,
+ });
+ }
+
+ toggleUserProps(property) {
+ const { usersProp } = this.state;
+
+ usersProp[property] = !usersProp[property];
+
+ this.setState({
+ usersProp,
+ });
+ }
+
+ displayLockStatus(status) {
+ const { intl } = this.props;
+ return (
+ status &&
+ {intl.formatMessage(intlMessages.lockedLabel)}
+
+ );
+ }
+
+ componentWillUnmount() {
+ const { closeModal } = this.props;
+
+ closeModal();
+ }
+
+ render() {
+ const {
+ closeModal,
+ intl,
+ showToggleLabel,
+ updateLockSettings,
+ updateWebcamsOnlyForModerator,
+ isOpen,
+ onRequestClose,
+ priority,
+ } = this.props;
+
+ const { lockSettingsProps, usersProp } = this.state;
+
+ const invertColors = true;
+
+ return (
+
+
+
+ {`${intl.formatMessage(intlMessages.lockViewersDescription)}`}
+
+
+
+
+ {intl.formatMessage(intlMessages.featuresLable)}
+ {intl.formatMessage(intlMessages.lockStatusLabel)}
+
+
+
+
+
+ {intl.formatMessage(intlMessages.webcamLabel)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.disableCam)}
+ {
+ this.toggleLockSettings('disableCam');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.webcamLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockShareWebcam"
+ />
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.otherViewersWebcamLabel)}
+
+
+
+
+
+ {this.displayLockStatus(usersProp.webcamsOnlyForModerator)}
+ {
+ this.toggleUserProps('webcamsOnlyForModerator');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.otherViewersWebcamLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockSeeOtherViewersWebcam"
+ />
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.microphoneLable)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.disableMic)}
+ {
+ this.toggleLockSettings('disableMic');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.microphoneLable)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockShareMicrophone"
+ />
+
+
+
+
+ {isChatEnabled() ? (
+
+
+
+
+
+ {intl.formatMessage(intlMessages.publicChatLabel)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.disablePublicChat)}
+ {
+ this.toggleLockSettings('disablePublicChat');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.publicChatLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockPublicChat"
+ />
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.privateChatLable)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.disablePrivateChat)}
+ {
+ this.toggleLockSettings('disablePrivateChat');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.privateChatLable)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockPrivateChat"
+ />
+
+
+
+
+ ) : null
+ }
+ {NotesService.isEnabled()
+ ? (
+
+
+
+
+ {intl.formatMessage(intlMessages.notesLabel)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.disableNotes)}
+ {
+ this.toggleLockSettings('disableNotes');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.notesLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockEditSharedNotes"
+ />
+
+
+
+ )
+ : null
+ }
+
+
+
+
+ {intl.formatMessage(intlMessages.userListLabel)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.hideUserList)}
+ {
+ this.toggleLockSettings('hideUserList');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.userListLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="lockUserList"
+ />
+
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.hideCursorsLabel)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.hideViewersCursor)}
+ {
+ this.toggleLockSettings('hideViewersCursor');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.hideCursorsLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="hideViewersCursor"
+ />
+
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.hideAnnotationsLabel)}
+
+
+
+
+
+ {this.displayLockStatus(lockSettingsProps.hideViewersAnnotation)}
+ {
+ this.toggleLockSettings('hideViewersAnnotation');
+ }}
+ ariaLabel={intl.formatMessage(intlMessages.hideAnnotationsLabel)}
+ showToggleLabel={showToggleLabel}
+ invertColors={invertColors}
+ data-test="hideViewersAnnotation"
+ />
+
+
+
+
+
+
+
+
+ {
+ updateLockSettings(lockSettingsProps);
+ updateWebcamsOnlyForModerator(usersProp.webcamsOnlyForModerator);
+ closeModal();
+ }}
+ data-test="applyLockSettings"
+ />
+
+
+
+ );
+ }
+}
+
+LockViewersComponent.propTypes = propTypes;
+
+export default injectIntl(LockViewersComponent);
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/container.jsx b/src/2.7.12/imports/ui/components/lock-viewers/container.jsx
new file mode 100644
index 00000000..e2f68b40
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/container.jsx
@@ -0,0 +1,26 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Meetings from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import LockViewersComponent from './component';
+import { updateLockSettings, updateWebcamsOnlyForModerator } from './service';
+import { UsersContext } from '../components-data/users-context/context';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const LockViewersContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const amIModerator = currentUser.role === ROLE_MODERATOR;
+
+ return amIModerator &&
+}
+
+export default withTracker(({ setIsOpen }) => ({
+ closeModal: () => setIsOpen(false),
+ meeting: Meetings.findOne({ meetingId: Auth.meetingID }),
+ updateLockSettings,
+ updateWebcamsOnlyForModerator,
+ showToggleLabel: false,
+}))(LockViewersContainer);
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/context/consumer.jsx b/src/2.7.12/imports/ui/components/lock-viewers/context/consumer.jsx
new file mode 100644
index 00000000..82769e2c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/context/consumer.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import lockContext from './context';
+
+
+const contextConsumer = Component => props => (
+
+ { contexts => }
+
+);
+
+export default contextConsumer;
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/context/container.jsx b/src/2.7.12/imports/ui/components/lock-viewers/context/container.jsx
new file mode 100644
index 00000000..1fd1258b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/context/container.jsx
@@ -0,0 +1,32 @@
+import { withTracker } from 'meteor/react-meteor-data';
+import Meetings from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import { LockStruct } from './context';
+import Users from '/imports/api/users';
+import { withLockContext } from './withContext';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const lockContextContainer = (component) => withTracker(() => {
+ const lockSetting = new LockStruct();
+ const Meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { lockSettingsProps: 1 } });
+ const User = Users.findOne({ userId: Auth.userID, meetingId: Auth.meetingID },
+ { fields: { role: 1, locked: 1 } });
+ const userIsLocked = User ? User.locked && User.role !== ROLE_MODERATOR : true;
+ const lockSettings = Meeting.lockSettingsProps;
+
+ lockSetting.isLocked = userIsLocked;
+ lockSetting.lockSettings = lockSettings;
+ lockSetting.userLocks.userWebcam = userIsLocked && lockSettings.disableCam;
+ lockSetting.userLocks.userMic = userIsLocked && lockSettings.disableMic;
+ lockSetting.userLocks.userNotes = userIsLocked && lockSettings.disableNotes;
+ lockSetting.userLocks.userPrivateChat = userIsLocked && lockSettings.disablePrivateChat;
+ lockSetting.userLocks.userPublicChat = userIsLocked && lockSettings.disablePublicChat;
+ lockSetting.userLocks.hideViewersCursor = userIsLocked && lockSettings.hideViewersCursor;
+ lockSetting.userLocks.hideViewersAnnotation = userIsLocked && lockSettings.hideViewersAnnotation;
+
+ return lockSetting;
+})(withLockContext(component));
+
+export default lockContextContainer;
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/context/context.js b/src/2.7.12/imports/ui/components/lock-viewers/context/context.js
new file mode 100644
index 00000000..ef4545f6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/context/context.js
@@ -0,0 +1,29 @@
+import React from 'react';
+
+export function LockStruct() {
+ return ({
+ isLocked: false,
+ lockSettings: {
+ disableCam: false,
+ disableMic: false,
+ disableNotes: false,
+ disablePrivateChat: false,
+ disablePublicChat: false,
+ lockOnJoin: true,
+ lockOnJoinConfigurable: false,
+ hideViewersCursor: false,
+ hideViewersAnnotation: false,
+ },
+ userLocks: {
+ userWebcam: false,
+ userMic: false,
+ userNotes: false,
+ userPrivateChat: false,
+ userPublicChat: false,
+ },
+ });
+}
+
+const lockContext = React.createContext(new LockStruct());
+
+export default lockContext;
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/context/provider.jsx b/src/2.7.12/imports/ui/components/lock-viewers/context/provider.jsx
new file mode 100644
index 00000000..0a681c02
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/context/provider.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import lockContext from './context';
+
+
+const contextProvider = props => (
+
+ { props.children }
+
+);
+
+export default contextProvider;
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/context/withContext.jsx b/src/2.7.12/imports/ui/components/lock-viewers/context/withContext.jsx
new file mode 100644
index 00000000..6d230d8e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/context/withContext.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import LockProvider from './provider';
+import LockConsumer from './consumer';
+
+const withProvider = Component => props => (
+
+
+
+);
+
+const withConsumer = Component => LockConsumer(Component);
+
+const withLockContext = Component => withProvider(withConsumer(Component));
+
+export {
+ withProvider,
+ withConsumer,
+ withLockContext,
+};
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/service.js b/src/2.7.12/imports/ui/components/lock-viewers/service.js
new file mode 100644
index 00000000..6126d27d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/service.js
@@ -0,0 +1,10 @@
+import { makeCall } from '/imports/ui/services/api';
+
+const updateLockSettings = lockProps => makeCall('toggleLockSettings', lockProps);
+
+const updateWebcamsOnlyForModerator = webcamsOnlyForModerator => makeCall('toggleWebcamsOnlyForModerator', webcamsOnlyForModerator);
+
+export {
+ updateLockSettings,
+ updateWebcamsOnlyForModerator,
+};
diff --git a/src/2.7.12/imports/ui/components/lock-viewers/styles.js b/src/2.7.12/imports/ui/components/lock-viewers/styles.js
new file mode 100644
index 00000000..90efe200
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/lock-viewers/styles.js
@@ -0,0 +1,143 @@
+import styled from 'styled-components';
+import {
+ jumboPaddingY,
+ lgPaddingX,
+ smPaddingX,
+ modalMargin,
+ lgPaddingY,
+ titlePositionLeft,
+ mdPaddingX,
+} from '../../stylesheets/styled-components/general';
+import { fontSizeBase, fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+import { colorGray, colorGrayLabel, colorGrayLighter } from '../../stylesheets/styled-components/palette';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import Button from '/imports/ui/components/common/button/component';
+
+const ToggleLabel = styled.span`
+ margin-right: ${smPaddingX};
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+`;
+
+const LockViewersModal = styled(ModalSimple)``;
+
+const Container = styled.div`
+ margin: 0 ${modalMargin} ${lgPaddingX};
+`;
+
+const Description = styled.div`
+ text-align: center;
+ color: ${colorGray};
+ margin-bottom: ${jumboPaddingY};
+`;
+
+const Form = styled.div`
+ display: flex;
+ flex-flow: column;
+ border-bottom: 1px solid ${colorGrayLighter};
+`;
+
+const SubHeader = styled.header`
+ display: flex;
+ flex-flow: row;
+ flex-grow: 1;
+ justify-content: space-between;
+ color: ${colorGrayLabel};
+ font-size: ${fontSizeBase};
+ margin-bottom: ${titlePositionLeft};
+`;
+
+const Bold = styled.div`
+ font-weight: bold;
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-flow: row;
+ flex-grow: 1;
+ justify-content: space-between;
+ margin-bottom: ${mdPaddingX};
+
+ & > :first-child {
+ margin: 0 ${mdPaddingX} 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${mdPaddingX};
+ }
+ }
+`;
+
+const Col = styled.div`
+ display: flex;
+ flex-grow: 1;
+ flex-basis: 0;
+ margin: 0;
+
+ [dir="rtl"] & {
+ margin: 0;
+ }
+`;
+
+const FormElement = styled.div`
+ position: relative;
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+`;
+
+const FormElementRight = styled(FormElement)`
+ display: flex;
+ justify-content: flex-end;
+ flex-flow: row;
+ align-items: center;
+`;
+
+const Label = styled.div`
+ color: ${colorGrayLabel};
+ font-size: ${fontSizeSmall};
+ margin-bottom: ${lgPaddingY};
+`;
+
+const Footer = styled.div`
+ display: flex;
+ margin: ${smPaddingX} ${modalMargin} 0;
+`;
+
+const Actions = styled.div`
+ margin-left: auto;
+ margin-right: 0;
+
+ [dir="rtl"] & {
+ margin-right: auto;
+ margin-left: 3px;
+ }
+`;
+
+const ButtonCancel = styled(Button)`
+ margin: 0 0.25rem;
+`;
+
+const ButtonApply = styled(Button)`
+ margin: 0 0.25rem;
+`;
+
+export default {
+ ToggleLabel,
+ LockViewersModal,
+ Container,
+ Description,
+ Form,
+ SubHeader,
+ Bold,
+ Row,
+ Col,
+ FormElement,
+ FormElementRight,
+ Label,
+ Footer,
+ Actions,
+ ButtonCancel,
+ ButtonApply,
+};
diff --git a/src/2.7.12/imports/ui/components/media/autoplay-overlay/component.jsx b/src/2.7.12/imports/ui/components/media/autoplay-overlay/component.jsx
new file mode 100644
index 00000000..4455fd54
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/media/autoplay-overlay/component.jsx
@@ -0,0 +1,53 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Button from '/imports/ui/components/common/button/component';
+import Styled from './styles';
+
+const propTypes = {
+ autoplayBlockedDesc: PropTypes.string.isRequired,
+ autoplayAllowLabel: PropTypes.string.isRequired,
+ handleAllowAutoplay: PropTypes.func.isRequired,
+ intl: PropTypes.objectOf(Object).isRequired,
+};
+
+const intlMessages = defineMessages({
+ autoplayAlertDesc: {
+ id: 'app.media.autoplayAlertDesc',
+ description: 'Description for the autoplay alert title',
+ },
+});
+
+class AutoplayOverlay extends PureComponent {
+ render() {
+ const {
+ intl,
+ handleAllowAutoplay,
+ autoplayBlockedDesc,
+ autoplayAllowLabel,
+ } = this.props;
+ return (
+
+
+ { intl.formatMessage(intlMessages.autoplayAlertDesc) }
+
+
+
+ {autoplayBlockedDesc}
+
+
+
+
+ );
+ }
+}
+
+AutoplayOverlay.propTypes = propTypes;
+
+export default injectIntl(AutoplayOverlay);
diff --git a/src/2.7.12/imports/ui/components/media/autoplay-overlay/styles.js b/src/2.7.12/imports/ui/components/media/autoplay-overlay/styles.js
new file mode 100644
index 00000000..a9fe1490
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/media/autoplay-overlay/styles.js
@@ -0,0 +1,43 @@
+import styled from 'styled-components';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeLarge, fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const AutoplayOverlay = styled.div`
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ background: rgba(0, 0, 0, 1);
+ height: 100%;
+ width: 100%;
+ color: ${colorWhite};
+ font-size: ${fontSizeLarge};
+ border-radius: 5px;
+ position: absolute;
+ z-index: 999;
+ text-align: center;
+`;
+
+const Title = styled.div`
+ display: block;
+ font-size: ${fontSizeLarge};
+ text-align: center;
+`;
+
+const AutoplayOverlayContent = styled.div`
+ text-align: center;
+ margin-top: 8px;
+`;
+
+const Label = styled.div`
+ display: block;
+ font-size: ${fontSizeBase};
+ text-align: center;
+ margin-bottom: 12px;
+`;
+
+export default {
+ AutoplayOverlay,
+ Title,
+ AutoplayOverlayContent,
+ Label,
+};
diff --git a/src/2.7.12/imports/ui/components/media/service.js b/src/2.7.12/imports/ui/components/media/service.js
new file mode 100644
index 00000000..f3d06700
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/media/service.js
@@ -0,0 +1,92 @@
+import Presentations from '/imports/api/presentations';
+import { isScreenBroadcasting, isCameraAsContentBroadcasting } from '/imports/ui/components/screenshare/service';
+import Settings from '/imports/ui/services/settings';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import {
+ isExternalVideoEnabled, isScreenSharingEnabled, isCameraAsContentEnabled, isPresentationEnabled,
+} from '/imports/ui/services/features';
+import { ACTIONS } from '../layout/enums';
+import UserService from '/imports/ui/components/user-list/service';
+import NotesService from '/imports/ui/components/notes/service';
+import { getVideoUrl } from '/imports/ui/components/external-video-player/service';
+import VideoStreams from '/imports/api/video-streams';
+import Auth from '/imports/ui/services/auth/index';
+
+const LAYOUT_CONFIG = Meteor.settings.public.layout;
+const KURENTO_CONFIG = Meteor.settings.public.kurento;
+const PRESENTATION_CONFIG = Meteor.settings.public.presentation;
+
+const getPresentationInfo = () => {
+ const currentPresentation = Presentations.findOne({
+ current: true,
+ });
+
+ return {
+ current_presentation: (currentPresentation != null),
+ };
+};
+
+function shouldShowWhiteboard() {
+ return true;
+}
+
+function shouldShowScreenshare() {
+ const { viewScreenshare } = Settings.dataSaving;
+ return (isScreenSharingEnabled() || isCameraAsContentEnabled())
+ && (viewScreenshare || UserService.isUserPresenter())
+ && (isScreenBroadcasting() || isCameraAsContentBroadcasting());
+}
+
+function shouldShowExternalVideo() {
+ return isExternalVideoEnabled() && !!getVideoUrl();
+}
+
+function shouldShowSharedNotes() {
+ return NotesService.isSharedNotesPinned();
+}
+
+function shouldShowOverlay() {
+ return getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
+}
+
+const setPresentationIsOpen = (layoutContextDispatch, value) => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value,
+ });
+};
+
+const isThereWebcamOn = (meetingID) => {
+ return VideoStreams.find({
+ meetingId: meetingID
+ }).count() > 0;
+}
+
+const buildLayoutWhenPresentationAreaIsDisabled = (layoutContextDispatch) => {
+ const isSharingVideo = getVideoUrl();
+ const isSharedNotesPinned = NotesService.isSharedNotesPinned();
+ const hasScreenshare = isScreenSharingEnabled();
+ const isThereWebcam = isThereWebcamOn(Auth.meetingID);
+ const isGeneralMediaOff = !hasScreenshare && !isSharedNotesPinned && !isSharingVideo
+ const webcamIsOnlyContent = isThereWebcam && isGeneralMediaOff;
+ const isThereNoMedia = !isThereWebcam && isGeneralMediaOff;
+ const isPresentationDisabled = !isPresentationEnabled();
+
+ if (isPresentationDisabled && (webcamIsOnlyContent || isThereNoMedia)) {
+ setPresentationIsOpen(layoutContextDispatch, false);
+ }
+
+}
+
+export default {
+ buildLayoutWhenPresentationAreaIsDisabled,
+ getPresentationInfo,
+ shouldShowWhiteboard,
+ shouldShowScreenshare,
+ shouldShowExternalVideo,
+ shouldShowOverlay,
+ isScreenBroadcasting,
+ isCameraAsContentBroadcasting,
+ setPresentationIsOpen,
+ shouldShowSharedNotes,
+};
diff --git a/src/2.7.12/imports/ui/components/meeting-ended/component.jsx b/src/2.7.12/imports/ui/components/meeting-ended/component.jsx
new file mode 100644
index 00000000..67b2d7df
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/meeting-ended/component.jsx
@@ -0,0 +1,386 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Meteor } from 'meteor/meteor';
+import Auth from '/imports/ui/services/auth';
+import LearningDashboardService from '../learning-dashboard/service';
+import allowRedirectToLogoutURL from './service';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import logoutRouteHandler from '/imports/utils/logoutRouteHandler';
+import Rating from './rating/component';
+import Styled from './styles';
+import logger from '/imports/startup/client/logger';
+import Users from '/imports/api/users';
+import Meetings from '/imports/api/meetings';
+import AudioManager from '/imports/ui/services/audio-manager';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { isLearningDashboardEnabled } from '/imports/ui/services/features';
+import Storage from '/imports/ui/services/storage/session';
+
+const intlMessage = defineMessages({
+ 410: {
+ id: 'app.meeting.ended',
+ description: 'message when meeting is ended',
+ },
+ 403: {
+ id: 'app.error.removed',
+ description: 'Message to display when user is removed from the conference',
+ },
+ 430: {
+ id: 'app.error.meeting.ended',
+ description: 'user logged conference',
+ },
+ 'acl-not-allowed': {
+ id: 'app.error.removed',
+ description: 'Message to display when user is removed from the conference',
+ },
+ messageEnded: {
+ id: 'app.meeting.endedMessage',
+ description: 'message saying to go back to home screen',
+ },
+ messageEndedByUser: {
+ id: 'app.meeting.endedByUserMessage',
+ description: 'message informing who ended the meeting',
+ },
+ messageEndedByNoModeratorSingular: {
+ id: 'app.meeting.endedByNoModeratorMessageSingular',
+ description: 'message informing that the meeting was ended due to no moderator present (singular)',
+ },
+ messageEndedByNoModeratorPlural: {
+ id: 'app.meeting.endedByNoModeratorMessagePlural',
+ description: 'message informing that the meeting was ended due to no moderator present (plural)',
+ },
+ buttonOkay: {
+ id: 'app.meeting.endNotification.ok.label',
+ description: 'label okay for button',
+ },
+ title: {
+ id: 'app.feedback.title',
+ description: 'title for feedback screen',
+ },
+ subtitle: {
+ id: 'app.feedback.subtitle',
+ description: 'subtitle for feedback screen',
+ },
+ textarea: {
+ id: 'app.feedback.textarea',
+ description: 'placeholder for textarea',
+ },
+ confirmDesc: {
+ id: 'app.leaveConfirmation.confirmDesc',
+ description: 'adds context to confim option',
+ },
+ sendLabel: {
+ id: 'app.feedback.sendFeedback',
+ description: 'send feedback button label',
+ },
+ sendDesc: {
+ id: 'app.feedback.sendFeedbackDesc',
+ description: 'adds context to send feedback option',
+ },
+ duplicate_user_in_meeting_eject_reason: {
+ id: 'app.meeting.logout.duplicateUserEjectReason',
+ description: 'message for duplicate users',
+ },
+ not_enough_permission_eject_reason: {
+ id: 'app.meeting.logout.permissionEjectReason',
+ description: 'message for whom was kicked by doing something without permission',
+ },
+ user_requested_eject_reason: {
+ id: 'app.meeting.logout.ejectedFromMeeting',
+ description: 'message when the user is removed by someone',
+ },
+ max_participants_reason: {
+ id: 'app.meeting.logout.maxParticipantsReached',
+ description: 'message when the user is rejected due to max participants limit',
+ },
+ validate_token_failed_eject_reason: {
+ id: 'app.meeting.logout.validateTokenFailedEjectReason',
+ description: 'invalid auth token',
+ },
+ user_inactivity_eject_reason: {
+ id: 'app.meeting.logout.userInactivityEjectReason',
+ description: 'message to whom was kicked by inactivity',
+ },
+ open_activity_report_btn: {
+ id: 'app.learning-dashboard.clickHereToOpen',
+ description: 'description of link to open activity report',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ code: PropTypes.string.isRequired,
+ ejectedReason: PropTypes.string,
+ endedReason: PropTypes.string,
+};
+
+const defaultProps = {
+ ejectedReason: null,
+ endedReason: null,
+ callback: async () => {},
+};
+
+class MeetingEnded extends PureComponent {
+ static getComment() {
+ const textarea = document.getElementById('feedbackComment');
+ const comment = textarea.value;
+ return comment;
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ selected: 0,
+ dispatched: false,
+ };
+
+ const user = Users.findOne({ userId: Auth.userID });
+ if (user) {
+ this.localUserRole = user.role;
+ }
+
+ const meetingID = user?.meetingId || Auth.meetingID;
+ const meeting = Meetings.findOne({ id: meetingID });
+ if (meeting) {
+ this.endWhenNoModeratorMinutes = meeting.durationProps.endWhenNoModeratorDelayInMinutes;
+
+ const endedBy = Users.findOne({
+ userId: meeting.meetingEndedBy,
+ }, { fields: { name: 1 } });
+
+ if (endedBy) {
+ this.meetingEndedBy = endedBy.name;
+ }
+ }
+
+ this.setSelectedStar = this.setSelectedStar.bind(this);
+ this.confirmRedirect = this.confirmRedirect.bind(this);
+ this.sendFeedback = this.sendFeedback.bind(this);
+ this.shouldShowFeedback = this.shouldShowFeedback.bind(this);
+ this.getEndingMessage = this.getEndingMessage.bind(this);
+
+ AudioManager.exitAudio();
+ Storage.removeItem('getEchoTest');
+ Storage.removeItem('isFirstJoin');
+ this.props.callback().finally(() => {
+ Meteor.disconnect();
+ });
+ }
+
+ setSelectedStar(starNumber) {
+ this.setState({
+ selected: starNumber,
+ });
+ }
+
+ shouldShowFeedback() {
+ const { dispatched } = this.state;
+ return getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout) && !dispatched;
+ }
+
+ confirmRedirect() {
+ if (meetingIsBreakout()) window.close();
+ if (allowRedirectToLogoutURL()) logoutRouteHandler();
+ }
+
+ getEndingMessage() {
+ const { intl, code, ejectedReason, endedReason } = this.props;
+
+ if (endedReason && endedReason === 'ENDED_DUE_TO_NO_MODERATOR') {
+ return this.endWhenNoModeratorMinutes === 1
+ ? intl.formatMessage(intlMessage.messageEndedByNoModeratorSingular)
+ : intl.formatMessage(intlMessage.messageEndedByNoModeratorPlural, { 0: this.endWhenNoModeratorMinutes });
+ }
+
+ if (this.meetingEndedBy) {
+ return intl.formatMessage(intlMessage.messageEndedByUser, { 0: this.meetingEndedBy });
+ }
+
+ if (intlMessage[ejectedReason]) {
+ return intl.formatMessage(intlMessage[ejectedReason]);
+ }
+
+ if (intlMessage[code]) {
+ return intl.formatMessage(intlMessage[code]);
+ }
+
+ return intl.formatMessage(intlMessage[430]);
+ }
+
+ sendFeedback() {
+ const {
+ selected,
+ } = this.state;
+
+ const { fullname } = Auth.credentials;
+
+ const message = {
+ rating: selected,
+ userId: Auth.userID,
+ userName: fullname,
+ authToken: Auth.token,
+ meetingId: Auth.meetingID,
+ comment: MeetingEnded.getComment(),
+ userRole: this.localUserRole,
+ };
+ const url = './feedback';
+ const options = {
+ method: 'POST',
+ body: JSON.stringify(message),
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+
+ // client logger
+ logger.info({ logCode: 'feedback_functionality', extraInfo: { feedback: message } }, 'Feedback component');
+
+ this.setState({
+ dispatched: true,
+ });
+
+ fetch(url, options).then(() => {
+ if (this.localUserRole === 'VIEWER') {
+ const REDIRECT_WAIT_TIME = 5000;
+ setTimeout(() => {
+ logoutRouteHandler();
+ }, REDIRECT_WAIT_TIME);
+ }
+ }).catch((e) => {
+ logger.warn({
+ logCode: 'user_feedback_not_sent_error',
+ extraInfo: {
+ errorName: e.name,
+ errorMessage: e.message,
+ },
+ }, `Unable to send feedback: ${e.message}`);
+ });
+ }
+
+ renderNoFeedback() {
+ const { intl, code, ejectedReason } = this.props;
+
+ const { locale } = intl;
+
+ const logMessage = ejectedReason === 'user_requested_eject_reason' ? 'User removed from the meeting' : 'Meeting ended component, no feedback configured';
+ logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason: ejectedReason } }, logMessage);
+
+ return (
+
+
+
+
+ {this.getEndingMessage()}
+
+ {!allowRedirectToLogoutURL() ? null : (
+
+ {
+ LearningDashboardService.isModerator()
+ && isLearningDashboardEnabled() === true
+ // Always set cookie in case Dashboard is already opened
+ && LearningDashboardService.setLearningDashboardCookie() === true
+ ? (
+
+ LearningDashboardService.openLearningDashboardUrl(locale)}
+ label={intl.formatMessage(intlMessage.open_activity_report_btn)}
+ description={intl.formatMessage(intlMessage.open_activity_report_btn)}
+ />
+
+ ) : null
+ }
+
+ {intl.formatMessage(intlMessage.messageEnded)}
+
+
+
+
+ )}
+
+
+
+ );
+ }
+
+ renderFeedback() {
+ const { intl, code, ejectedReason } = this.props;
+ const {
+ selected,
+ } = this.state;
+
+ const noRating = selected <= 0;
+
+ const logMessage = ejectedReason === 'user_requested_eject_reason' ? 'User removed from the meeting' : 'Meeting ended component, feedback allowed';
+ logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason: ejectedReason } }, logMessage);
+
+ return (
+
+
+
+
+ {this.getEndingMessage()}
+
+
+ {this.shouldShowFeedback()
+ ? intl.formatMessage(intlMessage.subtitle)
+ : intl.formatMessage(intlMessage.messageEnded)}
+
+
+ {this.shouldShowFeedback() ? (
+
+
+ {!noRating ? (
+
+ ) : null}
+
+ ) : null}
+ {noRating ? (
+ this.setState({ dispatched: true })}
+ label={intl.formatMessage(intlMessage.buttonOkay)}
+ description={intl.formatMessage(intlMessage.confirmDesc)}
+ />
+ ) : null}
+ {!noRating ? (
+
+ ) : null}
+
+
+
+ );
+ }
+
+ render() {
+ if (this.shouldShowFeedback()) return this.renderFeedback();
+ return this.renderNoFeedback();
+ }
+}
+
+MeetingEnded.propTypes = propTypes;
+MeetingEnded.defaultProps = defaultProps;
+
+export default injectIntl(MeetingEnded);
diff --git a/src/2.7.12/imports/ui/components/meeting-ended/rating/component.jsx b/src/2.7.12/imports/ui/components/meeting-ended/rating/component.jsx
new file mode 100644
index 00000000..54f38de1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/meeting-ended/rating/component.jsx
@@ -0,0 +1,91 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { range } from '/imports/utils/array-utils';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const intlMessages = defineMessages({
+ legendTitle: {
+ id: 'app.meeting-ended.rating.legendLabel',
+ description: 'label for star feedback legend',
+ },
+ starLabel: {
+ id: 'app.meeting-ended.rating.starLabel',
+ description: 'label for feedback stars',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ onRate: PropTypes.func.isRequired,
+ total: PropTypes.string.isRequired,
+};
+
+class Rating extends Component {
+ constructor(props) {
+ super(props);
+ this.clickStar = this.clickStar.bind(this);
+ }
+
+ shouldComponentUpdate() {
+ // when component re render lost checked item
+ return false;
+ }
+
+ clickStar(e) {
+ const { onRate } = this.props;
+ onRate(e);
+ }
+
+ renderStars(num) {
+ const { intl } = this.props;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.legendTitle)}
+ {
+ range(0, num)
+ .map(i => [
+ (
+ this.clickStar(i + 1)}
+ />
+ ),
+ (
+
+ ),
+ ]).reverse()
+ }
+
+
+ );
+ }
+
+ render() {
+ const {
+ total,
+ } = this.props;
+ return (
+
+ {
+ this.renderStars(total)
+ }
+
+ );
+ }
+}
+
+export default injectIntl(Rating);
+
+Rating.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/meeting-ended/rating/styles.js b/src/2.7.12/imports/ui/components/meeting-ended/rating/styles.js
new file mode 100644
index 00000000..40c1a40c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/meeting-ended/rating/styles.js
@@ -0,0 +1,76 @@
+import styled from 'styled-components';
+import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const StarRating = styled.div`
+ font-family: 'bbb-icons' !important;
+ & > fieldset {
+ border: none;
+ display: inline-block;
+
+ &:not(:checked) {
+ & > input {
+ position: absolute;
+ top: -9999px;
+ clip: rect(0,0,0,0);
+ }
+
+ & > label {
+ float: right;
+ width: 1em;
+ padding: 0 .05em 0 .1rem;
+ overflow: hidden;
+ white-space: nowrap;
+ cursor: pointer;
+ font-size: 2.5rem;
+ color: black;
+ font-weight: 100;
+
+ [dir="rtl"] & {
+ padding: 0 .1rem 0 .05em;
+ }
+
+ @media ${smallOnly} {
+ font-size: 2rem;
+ }
+
+ &:before {
+ content: '\\e951';
+ }
+
+ &:hover,
+ &:hover ~ label {
+ color: ${colorPrimary};
+ text-shadow: 0 0 3px ${colorPrimary};
+ &:before {
+ content: '\\e951';
+ }
+ }
+ }
+ }
+
+ & > input:checked {
+ & ~ label {
+ &:before {
+ content: '\\e952';
+ color: ${colorPrimary};
+ }
+ }
+ }
+
+ & > label:active {
+ position: relative;
+ top: 2px;
+ }
+ }
+`;
+
+const Legend = styled.legend`
+ font-family: Arial, sans-serif;
+ font-weight: normal;
+`;
+
+export default {
+ StarRating,
+ Legend,
+};
diff --git a/src/2.7.12/imports/ui/components/meeting-ended/service.js b/src/2.7.12/imports/ui/components/meeting-ended/service.js
new file mode 100644
index 00000000..794569f7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/meeting-ended/service.js
@@ -0,0 +1,22 @@
+import { Meteor } from 'meteor/meteor';
+import Auth from '/imports/ui/services/auth';
+
+
+export default function allowRedirectToLogoutURL() {
+ const ALLOW_DEFAULT_LOGOUT_URL = Meteor.settings.public.app.allowDefaultLogoutUrl;
+ const protocolPattern = /^((http|https):\/\/)/;
+ if (Auth.logoutURL) {
+ // default logoutURL
+ // compare only the host to ignore protocols
+ const urlWithoutProtocolForAuthLogout = Auth.logoutURL.replace(protocolPattern, '');
+ const urlWithoutProtocolForLocationOrigin = window.location.origin.replace(protocolPattern, '');
+ if (urlWithoutProtocolForAuthLogout === urlWithoutProtocolForLocationOrigin) {
+ return ALLOW_DEFAULT_LOGOUT_URL;
+ }
+
+ // custom logoutURL
+ return true;
+ }
+ // no logout url
+ return false;
+}
diff --git a/src/2.7.12/imports/ui/components/meeting-ended/styles.js b/src/2.7.12/imports/ui/components/meeting-ended/styles.js
new file mode 100644
index 00000000..139d8acd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/meeting-ended/styles.js
@@ -0,0 +1,84 @@
+import styled from 'styled-components';
+import {
+ borderRadius,
+ lgPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorWhite,
+ colorText,
+ colorBackground,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeLarge,
+ headingsFontWeight,
+ lineHeightComputed,
+ fontSizeSmall,
+ fontSizeBase,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+
+const Parent = styled.div`
+ height: 100%;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: ${colorBackground};
+`;
+
+const Modal = styled.div`
+ display: flex;
+ padding: ${lgPaddingX};
+ background-color: ${colorWhite};
+ flex-direction: column;
+ border-radius: ${borderRadius};
+ max-width: 95vw;
+ width: 600px;
+`;
+
+const Content = styled.div`
+ text-align: center;
+`;
+
+const Title = styled.h1`
+ margin: 0;
+ font-size: ${fontSizeLarge};
+ font-weight: ${headingsFontWeight};
+`;
+
+const Text = styled.div`
+ color: ${colorText};
+ font-weight: normal;
+ padding: ${lineHeightComputed} 0;
+
+ @media ${smallOnly} {
+ font-size: ${fontSizeSmall};
+ }
+`;
+
+const MeetingEndedButton = styled(Button)`
+ @media ${smallOnly} {
+ font-size: ${fontSizeBase};
+ }
+`;
+
+const TextArea = styled.textarea`
+ resize: none;
+ margin: 1rem auto;
+ width: 100%;
+
+ &::placeholder {
+ text-align: center;
+ }
+`;
+
+export default {
+ Parent,
+ Modal,
+ Content,
+ Title,
+ Text,
+ MeetingEndedButton,
+ TextArea,
+};
diff --git a/src/2.7.12/imports/ui/components/mobile-app-modal/component.jsx b/src/2.7.12/imports/ui/components/mobile-app-modal/component.jsx
new file mode 100644
index 00000000..f04d86ea
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/mobile-app-modal/component.jsx
@@ -0,0 +1,153 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import Auth from '/imports/ui/services/auth';
+import Button from '/imports/ui/components/common/button/component';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import Meetings from '/imports/api/meetings';
+
+const BBB_TABLET_APP_CONFIG = Meteor.settings.public.app.bbbTabletApp;
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.mobileAppModal.title',
+ description: 'App modal title',
+ },
+ description: {
+ id: 'app.mobileAppModal.description',
+ description: 'App modal description',
+ },
+ openStore: {
+ id: 'app.mobileAppModal.openStore',
+ defaultMessage: (new Date().getFullYear()),
+ description: 'Open Store button label',
+ },
+ openApp: {
+ id: 'app.mobileAppModal.openApp',
+ description: 'Open App button label',
+ },
+ obtainUrlMsg: {
+ id: 'app.mobileAppModal.obtainUrlMsg',
+ description: 'Obtain URL message',
+ },
+ obtainUrlErrorMsg: {
+ id: 'app.mobileAppModal.obtainUrlErrorMsg',
+ description: 'Obtain URL error message',
+ },
+ dismissLabel: {
+ id: 'app.mobileAppModal.dismissLabel',
+ description: 'Dismiss button label',
+ },
+ dismissDesc: {
+ id: 'app.mobileAppModal.dismissDesc',
+ description: 'adds descriptive context to dissmissLabel',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+class MobileAppModal extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ meetingName: '',
+ url: '',
+ urlMessage: '',
+ };
+ }
+
+ componentDidMount() {
+ const { intl } = this.props;
+ const { sessionToken } = Auth;
+ const meetingId = Auth.meetingID;
+ const meetingObject = Meetings.findOne({
+ meetingId,
+ }, { fields: { 'meetingProp.name': 1 } });
+ if (meetingObject != null) {
+ this.setState({ meetingName: meetingObject.meetingProp.name });
+ }
+
+ const url = `/bigbluebutton/api/getJoinUrl?sessionToken=${sessionToken}`;
+ const options = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ };
+ this.setState({ urlMessage: intl.formatMessage(intlMessages.obtainUrlMsg) });
+ fetch(url, options)
+ .then((response) => {
+ if (!response.ok) {
+ return Promise.reject();
+ }
+ return response.json();
+ })
+ .then((messages) => {
+ this.setState({ url: messages.response.url, urlMessage: '' });
+ })
+ .catch(() => {
+ this.setState({ urlMessage: intl.formatMessage(intlMessages.obtainUrlErrorMsg) });
+ });
+ }
+
+ render() {
+ const { intl, isOpen, onRequestClose, priority, } = this.props;
+ const { url, urlMessage, meetingName } = this.state;
+
+ return (
+
+
+ {`${intl.formatMessage(intlMessages.description)}`}
+
+
+ window.open(`${BBB_TABLET_APP_CONFIG.iosAppUrlScheme}://${encodeURIComponent(meetingName)}/${encodeURIComponent(url)}`, '_blank')}
+ role="button"
+ size="lg"
+ />
+ { urlMessage !== '' ? {urlMessage} : null }
+
+
+ {
+ BBB_TABLET_APP_CONFIG.iosAppStoreUrl === '' ? null
+ : (
+
+ window.open(`${BBB_TABLET_APP_CONFIG.iosAppStoreUrl}`, '_blank')}
+ role="button"
+ size="lg"
+ />
+
+ )
+ }
+
+
+ );
+ }
+}
+
+export default injectIntl(MobileAppModal);
+
+MobileAppModal.propTypes = propTypes;
diff --git a/src/2.7.12/imports/ui/components/mobile-app-modal/container.jsx b/src/2.7.12/imports/ui/components/mobile-app-modal/container.jsx
new file mode 100644
index 00000000..00af74d4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/mobile-app-modal/container.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+
+import MobileAppModal from './component';
+
+const MobileAppModalContainer = (props) => ;
+
+export default withTracker(() => {})(MobileAppModalContainer);
diff --git a/src/2.7.12/imports/ui/components/mobile-app-modal/styles.js b/src/2.7.12/imports/ui/components/mobile-app-modal/styles.js
new file mode 100644
index 00000000..23af4c89
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/mobile-app-modal/styles.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+const Center = styled.div`
+ text-align: center;
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+`;
+
+const ButtonContainer = styled.div`
+ margin-top: 1rem;
+`;
+
+const UrlMessage = styled.div`
+ font-style: italic;
+ font-size: 1.0rem;
+`;
+
+export default {
+ Center,
+ ButtonContainer,
+ UrlMessage,
+};
diff --git a/src/2.7.12/imports/ui/components/muted-alert/component.jsx b/src/2.7.12/imports/ui/components/muted-alert/component.jsx
new file mode 100644
index 00000000..0db847e7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/muted-alert/component.jsx
@@ -0,0 +1,156 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import hark from 'hark';
+import Icon from '/imports/ui/components/common/icon/component';
+import Styled from './styles';
+import { defineMessages, injectIntl } from 'react-intl';
+import { notify } from '/imports/ui/services/notification';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+
+const MUTE_ALERT_CONFIG = Meteor.settings.public.app.mutedAlert;
+
+const propTypes = {
+ inputStream: PropTypes.objectOf(PropTypes.any).isRequired,
+ isPresenter: PropTypes.bool.isRequired,
+ isViewer: PropTypes.bool.isRequired,
+ muted: PropTypes.bool.isRequired,
+};
+
+const intlMessages = defineMessages({
+ disableMessage: {
+ id: 'app.muteWarning.disableMessage',
+ description: 'Message used when mute alerts has been disabled',
+ },
+ tooltip: {
+ id: 'app.muteWarning.tooltip',
+ description: 'Tooltip message',
+ },
+ warningLabel: {
+ id: 'app.muteWarning.label',
+ description: 'Warning when someone speaks while muted',
+ },
+});
+
+class MutedAlert extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ visible: false,
+ };
+
+ this.inputStream = null;
+ this.speechEvents = null;
+ this.timer = null;
+
+ this.cloneMediaStream = this.cloneMediaStream.bind(this);
+ this.resetTimer = this.resetTimer.bind(this);
+ this.closeAlert = this.closeAlert.bind(this);
+ }
+
+ componentDidMount() {
+ this._isMounted = true;
+
+ if (!this.hasValidInputStream()) return;
+
+ this.cloneMediaStream();
+ if (this.inputStream) {
+ const { interval, threshold, duration } = MUTE_ALERT_CONFIG;
+ this.speechEvents = hark(this.inputStream, { interval, threshold });
+ this.speechEvents.on('speaking', () => {
+ this.resetTimer();
+ if (this._isMounted) this.setState({ visible: true });
+ });
+ this.speechEvents.on('stopped_speaking', () => {
+ if (this._isMounted) {
+ this.timer = setTimeout(() => this.setState(
+ { visible: false },
+ ), duration);
+ }
+ });
+ }
+ }
+
+ componentDidUpdate() {
+ this.cloneMediaStream();
+ }
+
+ componentWillUnmount() {
+ this._isMounted = false;
+ if (this.speechEvents) this.speechEvents.stop();
+ if (this.inputStream) {
+ this.inputStream.getTracks().forEach((t) => t.stop());
+ }
+ this.resetTimer();
+ }
+
+ cloneMediaStream() {
+ if (this.inputStream) return;
+ const { inputStream } = this.props;
+
+ if (inputStream) {
+ this.inputStream = inputStream.clone();
+ this.enableInputStreamAudioTracks(this.inputStream);
+ }
+ }
+
+ /* eslint-disable no-param-reassign */
+ enableInputStreamAudioTracks() {
+ if (!this.inputStream) return;
+ this.inputStream.getAudioTracks().forEach((t) => { t.enabled = true; });
+ }
+ /* eslint-enable no-param-reassign */
+
+ resetTimer() {
+ if (this.timer) clearTimeout(this.timer);
+ this.timer = null;
+ }
+
+ hasValidInputStream() {
+ const { inputStream } = this.props;
+
+ if (inputStream
+ && (typeof inputStream.getAudioTracks === 'function')
+ && (inputStream.getAudioTracks().length > 0)
+ ) return true;
+
+ return false;
+ }
+
+ closeAlert() {
+ const { intl } = this.props;
+
+ this.setState({ visible: false });
+ this.speechEvents.stop();
+
+ notify(intl.formatMessage(intlMessages.disableMessage), 'info', 'mute');
+ }
+
+ render() {
+ const {
+ isViewer, isPresenter, muted, intl,
+ } = this.props;
+ const { visible } = this.state;
+
+ return visible && muted ? (
+
+ this.closeAlert()}
+ >
+
+ {intl.formatMessage(intlMessages.warningLabel, { 0: })}
+
+
+
+ ) : null;
+ }
+}
+
+MutedAlert.propTypes = propTypes;
+
+export default injectIntl(MutedAlert);
diff --git a/src/2.7.12/imports/ui/components/muted-alert/styles.js b/src/2.7.12/imports/ui/components/muted-alert/styles.js
new file mode 100644
index 00000000..cf4659a6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/muted-alert/styles.js
@@ -0,0 +1,62 @@
+import styled from 'styled-components';
+import { mdPaddingX, borderRadius } from '/imports/ui/stylesheets/styled-components/general';
+import {
+ fontSizeXL,
+ fontSizeMD,
+ fontSizeSmall,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import { colorWhite, colorTipBg } from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly, mediumOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const MuteWarning = styled.div`
+ position: absolute !important;
+ color: ${colorWhite};
+ background-color: ${colorTipBg};
+ text-align: center;
+ line-height: 1;
+ font-size: ${fontSizeXL};
+ padding: ${mdPaddingX};
+ border-radius: ${borderRadius};
+ top: -100%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 100;
+ cursor: pointer;
+
+ > span {
+ white-space: nowrap;
+ }
+
+ @media ${smallOnly} {
+ font-size: ${fontSizeMD};
+ }
+
+ ${({ alignForMod }) => alignForMod && `
+ left: 72.25%;
+
+ [dir="rtl"] & {
+ left: 20%;
+ }
+
+ @media ${mediumOnly} {
+ font-size: ${fontSizeSmall};
+ }
+ `}
+
+ ${({ alignForViewer }) => alignForViewer && `
+ left: 80%;
+
+ [dir="rtl"] & {
+ left: 20%;
+ }
+
+ @media ${mediumOnly} {
+ font-size: ${fontSizeSmall};
+ }
+
+ `}
+`;
+
+export default {
+ MuteWarning,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/component.jsx b/src/2.7.12/imports/ui/components/nav-bar/component.jsx
new file mode 100644
index 00000000..b70a205d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/component.jsx
@@ -0,0 +1,271 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import RecordingIndicator from './recording-indicator/container';
+import TalkingIndicatorContainer from '/imports/ui/components/nav-bar/talking-indicator/container';
+import ConnectionStatusButton from '/imports/ui/components/connection-status/button/container';
+import ConnectionStatusService from '/imports/ui/components/connection-status/service';
+import { addNewAlert } from '/imports/ui/components/screenreader-alert/service';
+import SettingsDropdownContainer from './settings-dropdown/container';
+import TimerIndicatorContainer from '/imports/ui/components/timer/indicator/container';
+import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
+import { PANELS, ACTIONS } from '../layout/enums';
+import { isEqual } from 'radash';
+import LeaveMeetingButtonContainer from './leave-meeting-button/container';
+
+const intlMessages = defineMessages({
+ toggleUserListLabel: {
+ id: 'app.navBar.userListToggleBtnLabel',
+ description: 'Toggle button label',
+ },
+ toggleUserListAria: {
+ id: 'app.navBar.toggleUserList.ariaLabel',
+ description: 'description of the lists inside the userlist',
+ },
+ newMessages: {
+ id: 'app.navBar.toggleUserList.newMessages',
+ description: 'label for toggleUserList btn when showing red notification',
+ },
+ newMsgAria: {
+ id: 'app.navBar.toggleUserList.newMsgAria',
+ description: 'label for new message screen reader alert',
+ },
+ defaultBreakoutName: {
+ id: 'app.createBreakoutRoom.room',
+ description: 'default breakout room name',
+ },
+});
+
+const propTypes = {
+ presentationTitle: PropTypes.string,
+ hasUnreadMessages: PropTypes.bool,
+ shortcuts: PropTypes.string,
+ breakoutNum: PropTypes.number,
+ breakoutName: PropTypes.string,
+ meetingName: PropTypes.string,
+};
+
+const defaultProps = {
+ presentationTitle: 'Default Room Title',
+ hasUnreadMessages: false,
+ shortcuts: '',
+};
+
+class NavBar extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ acs: props.activeChats,
+ }
+
+ this.handleToggleUserList = this.handleToggleUserList.bind(this);
+ }
+
+ componentDidMount() {
+ const {
+ shortcuts: TOGGLE_USERLIST_AK,
+ intl,
+ breakoutNum,
+ breakoutName,
+ meetingName,
+ } = this.props;
+
+ if (breakoutNum && breakoutNum > 0) {
+ if (breakoutName && meetingName) {
+ const defaultBreakoutName = intl.formatMessage(intlMessages.defaultBreakoutName, {
+ 0: breakoutNum,
+ });
+
+ if (breakoutName === defaultBreakoutName) {
+ document.title = `${breakoutNum} - ${meetingName}`;
+ } else {
+ document.title = `${breakoutName} - ${meetingName}`;
+ }
+ }
+ }
+
+ const { isFirefox } = browserInfo;
+ const { isMacos } = deviceInfo;
+
+ // accessKey U does not work on firefox for macOS for some unknown reason
+ if (isMacos && isFirefox && TOGGLE_USERLIST_AK === 'U') {
+ document.addEventListener('keyup', (event) => {
+ const { key, code } = event;
+ const eventKey = key?.toUpperCase();
+ const eventCode = code;
+ if (event?.altKey && (eventKey === TOGGLE_USERLIST_AK || eventCode === `Key${TOGGLE_USERLIST_AK}`)) {
+ this.handleToggleUserList();
+ }
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (!isEqual(prevProps.activeChats, this.props.activeChats)) {
+ this.setState({ acs: this.props.activeChats})
+ }
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval);
+ }
+
+ handleToggleUserList() {
+ const {
+ sidebarNavigation,
+ sidebarContent,
+ layoutContextDispatch,
+ } = this.props;
+
+ if (sidebarNavigation.isOpen) {
+ if (sidebarContent.isOpen) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: '',
+ });
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_PANEL,
+ value: PANELS.NONE,
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_PANEL,
+ value: PANELS.USERLIST,
+ });
+ }
+ }
+
+ render() {
+ const {
+ hasUnreadMessages,
+ hasUnreadNotes,
+ activeChats,
+ intl,
+ shortcuts: TOGGLE_USERLIST_AK,
+ presentationTitle,
+ amIModerator,
+ style,
+ main,
+ isPinned,
+ sidebarNavigation,
+ currentUserId,
+ isDirectLeaveButtonEnabled,
+ isMeteorConnected,
+ } = this.props;
+
+ const hasNotification = hasUnreadMessages || (hasUnreadNotes && !isPinned);
+
+ let ariaLabel = intl.formatMessage(intlMessages.toggleUserListAria);
+ ariaLabel += hasNotification ? (` ${intl.formatMessage(intlMessages.newMessages)}`) : '';
+
+ const isExpanded = sidebarNavigation.isOpen;
+ const { isPhone } = deviceInfo;
+
+
+ const { acs } = this.state;
+
+ activeChats.map((c, i) => {
+ if (c?.unreadCounter > 0 && c?.unreadCounter !== acs[i]?.unreadCounter) {
+ addNewAlert(`${intl.formatMessage(intlMessages.newMsgAria, { 0: c.name })}`);
+ }
+ });
+
+ return (
+
+
+
+ {isExpanded && document.dir === 'ltr'
+ && }
+ {!isExpanded && document.dir === 'rtl'
+ && }
+
+ {!isExpanded && document.dir === 'ltr'
+ && }
+ {isExpanded && document.dir === 'rtl'
+ && }
+
+
+
+ {presentationTitle}
+
+
+
+
+ {ConnectionStatusService.isEnabled() ? : null}
+ {isDirectLeaveButtonEnabled && isMeteorConnected
+ ?
+ : null}
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+NavBar.propTypes = propTypes;
+NavBar.defaultProps = defaultProps;
+export default withShortcutHelper(injectIntl(NavBar), 'toggleUserList');
diff --git a/src/2.7.12/imports/ui/components/nav-bar/container.jsx b/src/2.7.12/imports/ui/components/nav-bar/container.jsx
new file mode 100644
index 00000000..9ad72d49
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/container.jsx
@@ -0,0 +1,134 @@
+import React, { useContext } from 'react';
+import { Meteor } from 'meteor/meteor';
+import { withTracker } from 'meteor/react-meteor-data';
+import Meetings from '/imports/api/meetings';
+import Breakouts from '/imports/api/breakouts';
+import Auth from '/imports/ui/services/auth';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import userListService from '/imports/ui/components/user-list/service';
+import { ChatContext } from '/imports/ui/components/components-data/chat-context/context';
+import { GroupChatContext } from '/imports/ui/components/components-data/group-chat-context/context';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import NotesService from '/imports/ui/components/notes/service';
+import NavBar from './component';
+import { layoutSelectInput, layoutSelectOutput, layoutDispatch } from '../layout/context';
+import { PANELS } from '/imports/ui/components/layout/enums';
+
+const PUBLIC_CONFIG = Meteor.settings.public;
+const ROLE_MODERATOR = PUBLIC_CONFIG.user.role_moderator;
+
+const checkUnreadMessages = ({
+ groupChatsMessages, groupChats, users, idChatOpen,
+}) => {
+ const activeChats = userListService.getActiveChats({ groupChatsMessages, groupChats, users });
+ const hasUnreadMessages = activeChats
+ .filter((chat) => chat.userId !== idChatOpen)
+ .some((chat) => chat.unreadCounter > 0);
+
+ return hasUnreadMessages;
+};
+
+const NavBarContainer = ({ children, ...props }) => {
+ const usingChatContext = useContext(ChatContext);
+ const usingUsersContext = useContext(UsersContext);
+ const usingGroupChatContext = useContext(GroupChatContext);
+ const { chats: groupChatsMessages } = usingChatContext;
+ const { users } = usingUsersContext;
+ const { groupChat: groupChats } = usingGroupChatContext;
+ const activeChats = userListService.getActiveChats({ groupChatsMessages, groupChats, users:users[Auth.meetingID] });
+ const { unread, ...rest } = props;
+
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
+ const navBar = layoutSelectOutput((i) => i.navBar);
+ const layoutContextDispatch = layoutDispatch();
+ const sharedNotes = layoutSelectInput((i) => i.sharedNotes);
+ const { isPinned: notesIsPinned } = sharedNotes;
+
+ const { sidebarContentPanel } = sidebarContent;
+ const { sidebarNavPanel } = sidebarNavigation;
+
+ const hasUnreadNotes = sidebarContentPanel !== PANELS.SHARED_NOTES && unread && !notesIsPinned;
+ const hasUnreadMessages = checkUnreadMessages(
+ { groupChatsMessages, groupChats, users: users[Auth.meetingID] },
+ );
+
+ const isExpanded = !!sidebarContentPanel || !!sidebarNavPanel;
+
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const amIModerator = currentUser.role === ROLE_MODERATOR;
+
+ const hideNavBar = getFromUserSettings('bbb_hide_nav_bar', false);
+
+ if (hideNavBar || navBar.display === false) return null;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default withTracker(() => {
+ const CLIENT_TITLE = getFromUserSettings('bbb_client_title', PUBLIC_CONFIG.app.clientTitle);
+ const unread = NotesService.hasUnreadNotes();
+
+ let meetingTitle, breakoutNum, breakoutName, meetingName;
+ const meetingId = Auth.meetingID;
+ const meetingObject = Meetings.findOne({
+ meetingId,
+ }, { fields: { 'meetingProp.name': 1, 'breakoutProps.sequence': 1, meetingId: 1 } });
+
+ if (meetingObject != null) {
+ meetingTitle = meetingObject.meetingProp.name;
+ let titleString = `${CLIENT_TITLE} - ${meetingTitle}`;
+ document.title = titleString;
+
+ if (meetingObject.breakoutProps) {
+ breakoutNum = meetingObject.breakoutProps.sequence;
+ if (breakoutNum > 0) {
+ const breakoutObject = Breakouts.findOne({
+ breakoutId: meetingObject.meetingId,
+ }, { fields: { shortName: 1 } });
+ if (breakoutObject) {
+ breakoutName = breakoutObject.shortName;
+ meetingName = meetingTitle.replace(`(${breakoutName})`, '').trim();
+ }
+ }
+ }
+ }
+
+ const IS_DIRECT_LEAVE_BUTTON_ENABLED = getFromUserSettings(
+ 'bbb_direct_leave_button',
+ PUBLIC_CONFIG.app.defaultSettings.application.directLeaveButton,
+ );
+
+ return {
+ isPinned: NotesService.isSharedNotesPinned(),
+ currentUserId: Auth.userID,
+ meetingId,
+ presentationTitle: meetingTitle,
+ breakoutNum,
+ breakoutName,
+ meetingName,
+ unread,
+ isDirectLeaveButtonEnabled: IS_DIRECT_LEAVE_BUTTON_ENABLED,
+ isMeteorConnected: Meteor.status().connected,
+ };
+})(NavBarContainer);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/component.jsx b/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/component.jsx
new file mode 100644
index 00000000..8d6a0b2f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/component.jsx
@@ -0,0 +1,185 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import EndMeetingConfirmationContainer from '/imports/ui/components/end-meeting-confirmation/container';
+import { makeCall } from '/imports/ui/services/api';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import { colorDanger, colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ leaveMeetingBtnLabel: {
+ id: 'app.navBar.leaveMeetingBtnLabel',
+ description: 'Leave meeting button label',
+ },
+ leaveMeetingBtnDesc: {
+ id: 'app.navBar.leaveMeetingBtnDesc',
+ description: 'Describes the leave meeting button',
+ },
+ leaveSessionLabel: {
+ id: 'app.navBar.settingsDropdown.leaveSessionLabel',
+ description: 'Leave session button label',
+ },
+ leaveSessionDesc: {
+ id: 'app.navBar.settingsDropdown.leaveSessionDesc',
+ description: 'Describes leave session option',
+ },
+ endMeetingLabel: {
+ id: 'app.navBar.settingsDropdown.endMeetingForAllLabel',
+ description: 'End meeting button label',
+ },
+ endMeetingDesc: {
+ id: 'app.navBar.settingsDropdown.endMeetingDesc',
+ description: 'Describes settings option closing the current meeting',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ amIModerator: PropTypes.bool,
+ isBreakoutRoom: PropTypes.bool,
+ isMeteorConnected: PropTypes.bool.isRequired,
+ isDropdownOpen: PropTypes.bool,
+ isMobile: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ amIModerator: false,
+ isBreakoutRoom: false,
+ isDropdownOpen: false,
+};
+
+class LeaveMeetingButton extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isEndMeetingConfirmationModalOpen: false,
+ };
+
+ // Set the logout code to 680 because it's not a real code and can be matched on the other side
+ this.LOGOUT_CODE = '680';
+
+ this.setEndMeetingConfirmationModalIsOpen = this.setEndMeetingConfirmationModalIsOpen.bind(this);
+ this.leaveSession = this.leaveSession.bind(this);
+ }
+
+ setEndMeetingConfirmationModalIsOpen(value) {
+ this.setState({isEndMeetingConfirmationModalOpen: value})
+ }
+
+ leaveSession() {
+ makeCall('userLeftMeeting');
+ // we don't check askForFeedbackOnLogout here,
+ // it is checked in meeting-ended component
+ Session.set('codeError', this.LOGOUT_CODE);
+ }
+
+ renderMenuItems() {
+ const {
+ intl, amIModerator, isBreakoutRoom, isMeteorConnected,
+ } = this.props;
+
+ const allowedToEndMeeting = amIModerator && !isBreakoutRoom;
+
+ const { allowLogout: allowLogoutSetting } = Meteor.settings.public.app;
+
+ this.menuItems = [];
+
+ if (allowLogoutSetting && isMeteorConnected) {
+ this.menuItems.push(
+ {
+ key: 'list-item-logout',
+ dataTest: 'logout-button',
+ icon: 'logout',
+ label: intl.formatMessage(intlMessages.leaveSessionLabel),
+ description: intl.formatMessage(intlMessages.leaveSessionDesc),
+ onClick: () => this.leaveSession(),
+ },
+ );
+ }
+
+ if (allowedToEndMeeting && isMeteorConnected) {
+ const customStyles = { background: colorDanger, color: colorWhite };
+
+ this.menuItems.push(
+ {
+ key: 'list-item-end-meeting',
+ icon: 'close',
+ label: intl.formatMessage(intlMessages.endMeetingLabel),
+ description: intl.formatMessage(intlMessages.endMeetingDesc),
+ customStyles,
+ onClick: () => this.setEndMeetingConfirmationModalIsOpen(true),
+ },
+ );
+ }
+
+ return this.menuItems;
+ }
+
+ renderModal(isOpen, setIsOpen, priority, Component, otherOptions) {
+ return isOpen ? setIsOpen(false),
+ priority,
+ setIsOpen,
+ isOpen
+ }}
+ /> : null
+ }
+
+ render() {
+ const {
+ intl,
+ isDropdownOpen,
+ isMobile,
+ isRTL,
+ } = this.props;
+
+ const { isEndMeetingConfirmationModalOpen } = this.state;
+
+ const customStyles = { top: '1rem' };
+
+ return (
+ <>
+ null}
+ />
+ )}
+ actions={this.renderMenuItems()}
+ opts={{
+ id: 'app-leave-meeting-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'left' : 'right' },
+ transformorigin: { vertical: 'top', horizontal: isRTL ? 'left' : 'right' },
+ }}
+ />
+ {this.renderModal(isEndMeetingConfirmationModalOpen, this.setEndMeetingConfirmationModalIsOpen,
+ "low", EndMeetingConfirmationContainer)}
+ >
+ );
+ }
+}
+
+LeaveMeetingButton.propTypes = propTypes;
+LeaveMeetingButton.defaultProps = defaultProps;
+export default injectIntl(LeaveMeetingButton);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/container.jsx b/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/container.jsx
new file mode 100644
index 00000000..f175b65e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/container.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import deviceInfo from '/imports/utils/deviceInfo';
+import LeaveMeetingButton from './component';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { layoutSelectInput, layoutSelect } from '../../layout/context';
+import { SMALL_VIEWPORT_BREAKPOINT } from '../../layout/enums';
+
+const LeaveMeetingButtonContainer = (props) => {
+ const { width: browserWidth } = layoutSelectInput((i) => i.browser);
+ const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT;
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return (
+
+ );
+};
+
+export default withTracker((props) => {
+ return {
+ amIModerator: props.amIModerator,
+ isMobile: deviceInfo.isMobile,
+ isMeteorConnected: Meteor.status().connected,
+ isBreakoutRoom: meetingIsBreakout(),
+ isDropdownOpen: Session.get('dropdownOpen'),
+ };
+})(LeaveMeetingButtonContainer);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/styles.js b/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/styles.js
new file mode 100644
index 00000000..d4d43329
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/leave-meeting-button/styles.js
@@ -0,0 +1,25 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+
+const LeaveButton = styled(Button)`
+ ${({ state }) => state === 'open' && `
+ @media ${smallOnly} {
+ display: none;
+ }
+ `}
+
+ ${({ state }) => state === 'closed' && `
+ margin-left: 1.0rem;
+ margin-right: 0.5rem;
+ border-radius: 1.1rem;
+ font-size: 1rem;
+ line-height: 1.1rem;
+ font-weight: 400;
+ z-index: 3;
+ `}
+`;
+
+export default {
+ LeaveButton,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/component.jsx b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/component.jsx
new file mode 100644
index 00000000..4a1fec93
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/component.jsx
@@ -0,0 +1,290 @@
+import React, { PureComponent, Fragment } from 'react';
+import RecordingContainer from '/imports/ui/components/recording/container';
+import humanizeSeconds from '/imports/utils/humanizeSeconds';
+import Tooltip from '/imports/ui/components/common/tooltip/component';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import RecordingNotifyContainer from './notify/container';
+
+const intlMessages = defineMessages({
+ notificationRecordingStart: {
+ id: 'app.notification.recordingStart',
+ description: 'Notification for when the recording starts',
+ },
+ notificationRecordingStop: {
+ id: 'app.notification.recordingStop',
+ description: 'Notification for when the recording stops',
+ },
+ recordingAriaLabel: {
+ id: 'app.notification.recordingAriaLabel',
+ description: 'Notification for when the recording stops',
+ },
+ startTitle: {
+ id: 'app.recording.startTitle',
+ description: 'start recording title',
+ },
+ stopTitle: {
+ id: 'app.recording.stopTitle',
+ description: 'stop recording title',
+ },
+ resumeTitle: {
+ id: 'app.recording.resumeTitle',
+ description: 'resume recording title',
+ },
+ recordingIndicatorOn: {
+ id: 'app.navBar.recording.on',
+ description: 'label for indicator when the session is being recorded',
+ },
+ recordingIndicatorOff: {
+ id: 'app.navBar.recording.off',
+ description: 'label for indicator when the session is not being recorded',
+ },
+ emptyAudioBrdige: {
+ id: 'app.navBar.emptyAudioBrdige',
+ description: 'message for notification when recording starts with no users in audio bridge',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ amIModerator: PropTypes.bool,
+ record: PropTypes.bool,
+ recording: PropTypes.bool,
+ time: PropTypes.number,
+ allowStartStopRecording: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ amIModerator: false,
+ record: false,
+ recording: false,
+ time: 0,
+};
+
+class RecordingIndicator extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ time: (props.time ? props.time : 0),
+ shouldNotify: false,
+ isRecordingNotifyModalOpen: false,
+ isRecordingModalOpen: false,
+ };
+
+ this.incrementTime = this.incrementTime.bind(this);
+ this.toggleShouldNotify = this.toggleShouldNotify.bind(this);
+ this.setRecordingNotifyModalIsOpen = this.setRecordingNotifyModalIsOpen.bind(this);
+ this.setRecordingModalIsOpen = this.setRecordingModalIsOpen.bind(this);
+ }
+
+ toggleShouldNotify() {
+ const { shouldNotify } = this.state;
+ this.setState({shouldNotify: !shouldNotify});
+ }
+
+ componentDidMount() {
+ const { recording, recordingNotificationEnabled } = this.props;
+
+ if (recordingNotificationEnabled && recording) {
+ this.toggleShouldNotify();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const { recording, recordingNotificationEnabled } = this.props;
+ const { shouldNotify } = this.state;
+
+ if (!recording) {
+ clearInterval(this.interval);
+ this.interval = null;
+ } else if (this.interval === null) {
+ this.interval = setInterval(this.incrementTime, 1000);
+ }
+
+ if (recordingNotificationEnabled) {
+ if (!prevProps.recording && recording && !shouldNotify) {
+ return this.setState({shouldNotify: true});
+ }
+
+ // should only display notification modal when other modals are closed
+ if (shouldNotify) {
+ this.setRecordingNotifyModalIsOpen(true);
+ }
+ }
+ }
+
+ incrementTime() {
+ const { time: propTime } = this.props;
+ const { time } = this.state;
+
+ if (propTime > time) {
+ this.setState({ time: propTime + 1 });
+ } else {
+ this.setState({ time: time + 1 });
+ }
+ }
+
+ setRecordingNotifyModalIsOpen(value) {
+ this.setState({ isRecordingNotifyModalOpen: value });
+ }
+
+ setRecordingModalIsOpen(value) {
+ this.setState({ isRecordingModalOpen: value });
+ }
+
+ render() {
+ const {
+ record,
+ recording,
+ amIModerator,
+ intl,
+ allowStartStopRecording,
+ notify,
+ micUser,
+ isPhone,
+ } = this.props;
+
+ const { time, isRecordingNotifyModalOpen, isRecordingModalOpen } = this.state;
+ if (!record) return null;
+
+ if (!this.interval && recording) {
+ this.interval = setInterval(this.incrementTime, 1000);
+ }
+
+ const title = intl.formatMessage(recording ? intlMessages.recordingIndicatorOn
+ : intlMessages.recordingIndicatorOff);
+
+ let recordTitle = '';
+
+ if (!isPhone) {
+ if (!recording) {
+ recordTitle = time > 0
+ ? intl.formatMessage(intlMessages.resumeTitle)
+ : intl.formatMessage(intlMessages.startTitle);
+ } else {
+ recordTitle = intl.formatMessage(intlMessages.stopTitle);
+ }
+ }
+
+ const recordingToggle = () => {
+ if (!micUser && !recording) {
+ notify(intl.formatMessage(intlMessages.emptyAudioBrdige), 'error', 'warning');
+ }
+ this.setRecordingModalIsOpen(true);
+ document.activeElement.blur();
+ };
+
+ const recordingIndicatorIcon = (
+
+
+
+
+
+
+
+
+ );
+
+ const showButton = amIModerator && allowStartStopRecording;
+
+ const recordMeetingButton = (
+
+ {recordingIndicatorIcon}
+
+
+ {`${title} ${recording ? humanizeSeconds(time) : ''}`}
+
+ {recording
+ ? {humanizeSeconds(time)} : {recordTitle} }
+
+
+ );
+
+ const recordMeetingButtonWithTooltip = (
+
+ {recordMeetingButton}
+
+ );
+
+ const recordingButton = recording ? recordMeetingButtonWithTooltip : recordMeetingButton;
+
+ return (
+ <>
+
+ {record
+ ? |
+ : null}
+
+ {showButton
+ ? recordingButton
+ : null}
+
+ {showButton ? null : (
+
+
+ {recordingIndicatorIcon}
+
+ {recording
+ ? {humanizeSeconds(time)} : null}
+
+
+ )}
+
+
+ {isRecordingNotifyModalOpen ? : null}
+ {isRecordingModalOpen ? this.setRecordingModalIsOpen(false),
+ priority: "high",
+ setIsOpen: this.setRecordingModalIsOpen,
+ isOpen: isRecordingModalOpen
+ }}
+ /> : null}
+ >
+ );
+ }
+}
+
+RecordingIndicator.propTypes = propTypes;
+RecordingIndicator.defaultProps = defaultProps;
+
+export default injectIntl(RecordingIndicator);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/container.jsx b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/container.jsx
new file mode 100644
index 00000000..9c9fd486
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/container.jsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { RecordMeetings } from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import { notify } from '/imports/ui/services/notification';
+import VoiceUsers from '/imports/api/voice-users';
+import RecordIndicator from './component';
+import deviceInfo from '/imports/utils/deviceInfo';
+import RecordingIndicatorService from './service';
+
+const RecordIndicatorContainer = (props) => (
+
+);
+
+export default withTracker(({ currentUserId }) => {
+ const meetingId = Auth.meetingID;
+ const recordObject = RecordMeetings.findOne({ meetingId });
+ const recordSetByUser = recordObject?.setBy === currentUserId;
+
+ const micUser = VoiceUsers.findOne({ meetingId, joined: true, listenOnly: false }, {
+ fields: {
+ joined: 1,
+ },
+ });
+
+ return {
+ allowStartStopRecording: !!(recordObject && recordObject.allowStartStopRecording),
+ autoStartRecording: recordObject && recordObject.autoStartRecording,
+ record: recordObject && recordObject.record,
+ recording: recordObject && recordObject.recording,
+ time: recordObject && recordObject.time,
+ notify,
+ micUser,
+ isPhone: deviceInfo.isPhone,
+ recordingNotificationEnabled: !recordSetByUser && RecordingIndicatorService.isRecordingNotificationEnabled(),
+ };
+})(RecordIndicatorContainer);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/component.jsx b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/component.jsx
new file mode 100644
index 00000000..ee83c690
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/component.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { makeCall } from '/imports/ui/services/api';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.recording.notify.title',
+ description: 'Title Modal Con sent',
+ },
+ description: {
+ id: 'app.recording.notify.description',
+ description: 'Question for accept or not meeting be recorded',
+ },
+ continue: {
+ id: 'app.recording.notify.continue',
+ description: 'Button accept',
+ },
+ leave: {
+ id: 'app.recording.notify.leave',
+ description: 'Button leave',
+ },
+ continueAriaLabel: {
+ id: 'app.recording.notify.continueLabel',
+ description: 'provides better context for yes btn label',
+ },
+ leaveAriaLabel: {
+ id: 'app.recording.notify.leaveLabel',
+ description: 'provides better context for no btn label',
+ },
+
+});
+
+const LOGOUT_CODE = '680';
+
+const RecordingNotifyModal = (props) => {
+ const { intl, toggleShouldNotify, closeModal, isOpen, priority, } = props;
+
+ function skipButtonHandle() {
+ makeCall('userLeftMeeting');
+ Session.set('codeError', LOGOUT_CODE);
+ toggleShouldNotify();
+ }
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.description)}
+
+
+
+
+
+
+
+ );
+};
+
+export default injectIntl(RecordingNotifyModal);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/container.jsx b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/container.jsx
new file mode 100644
index 00000000..8cef6fca
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/container.jsx
@@ -0,0 +1,13 @@
+import { withTracker } from 'meteor/react-meteor-data';
+import RecordingNotifyModal from './component';
+
+export default withTracker((props) => {
+ const { toggleShouldNotify, setIsOpen } = props;
+ return {
+ closeModal: () => {
+ toggleShouldNotify();
+ setIsOpen(false);
+ },
+ ...props,
+ };
+})(RecordingNotifyModal);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/styles.js b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/styles.js
new file mode 100644
index 00000000..90e01ab0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/notify/styles.js
@@ -0,0 +1,23 @@
+import styled from 'styled-components';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import ConfirmationModalStyles from '/imports/ui/components/common/modal/confirmation/styles';
+
+const RecordingNotifyModal = styled(ModalSimple)``;
+
+const Container = styled(ConfirmationModalStyles.Container)`
+ padding: 3.625em 0 3.625em 0;
+`;
+
+const Description = styled(ConfirmationModalStyles.Description)``;
+
+const Footer = styled(ConfirmationModalStyles.Footer)``;
+
+const NotifyButton = styled(ConfirmationModalStyles.ConfirmationButton)``;
+
+export default {
+ RecordingNotifyModal,
+ Container,
+ Description,
+ Footer,
+ NotifyButton,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/service.js b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/service.js
new file mode 100644
index 00000000..c68c1ac7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/service.js
@@ -0,0 +1,14 @@
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+
+const isRecordingNotificationEnabled = () => (((
+ Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ {
+ fields: { 'meetingProp.notifyRecordingIsOn': 1 },
+ },
+ ) || {}).meetingProp || {}).notifyRecordingIsOn || false);
+
+export default {
+ isRecordingNotificationEnabled,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/styles.js b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/styles.js
new file mode 100644
index 00000000..8d9a36be
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/recording-indicator/styles.js
@@ -0,0 +1,146 @@
+import styled from 'styled-components';
+import { fontSizeLarge, fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ smPaddingX,
+ borderSize,
+ borderSizeLarge,
+ borderSizeSmall,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { colorWhite, colorPrimary, colorDangerDark, colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+
+const RecordingIndicatorIcon = styled.span`
+ width: ${fontSizeLarge};
+ height: ${fontSizeLarge};
+ font-size: ${fontSizeBase};
+
+ ${({ titleMargin }) => titleMargin && `
+ [dir="ltr"] & {
+ margin-right: ${smPaddingX};
+ }
+ `}
+`;
+
+const RecordingControl = styled.div`
+ display: flex;
+ align-items: center;
+
+ span {
+ border: none;
+ box-shadow: none;
+ background-color: transparent !important;
+ color: ${colorWhite} !important;
+ }
+
+ &:hover {
+ color: ${colorWhite} !important;
+ cursor: pointer;
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 ${borderSize} ${colorPrimary};
+ }
+
+ ${({ recording }) => recording && `
+ padding: 5px;
+ background-color: ${colorDangerDark};
+ border: ${borderSizeLarge} solid ${colorDangerDark};
+ border-radius: 10px;
+
+ &:focus {
+ background-clip: padding-box;
+ border: ${borderSizeLarge} solid transparent;
+ }
+ `}
+
+ ${({ recording }) => !recording && `
+ padding: 7px;
+ border: ${borderSizeSmall} solid ${colorWhite};
+ border-radius: 2em 2em;
+
+ &:focus {
+ padding: 5px;
+ border: ${borderSizeLarge} solid ${colorWhite};
+ box-shadow: none;
+ }
+ `}
+`;
+
+const PresentationTitle = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ font-weight: 400;
+ color: ${colorWhite};
+ font-size: ${fontSizeBase};
+ padding: 0;
+ margin-right: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 30vw;
+
+ [dir="rtl"] & {
+ margin-left: 0;
+ margin-right: ${smPaddingX};
+ }
+
+ & > [class^="icon-bbb-"] {
+ font-size: 75%;
+ }
+
+ span {
+ vertical-align: middle;
+ }
+`;
+
+const VisuallyHidden = styled.span`
+ position: absolute;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ height: 1px; width: 1px;
+ margin: -1px; padding: 0; border: 0;
+`;
+
+const PresentationTitleSeparator = styled.span`
+ color: ${colorGray};
+ font-size: ${fontSizeBase};
+ margin: 0 1rem;
+`;
+
+const RecordingIndicator = styled.div`
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:active,
+ &:focus,
+ &:focus-within {
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ }
+`;
+
+const RecordingStatusViewOnly = styled.div`
+ display: flex;
+
+ ${({ recording }) => recording && `
+ padding: 5px;
+ background-color: ${colorDangerDark};
+ border: ${borderSizeLarge} solid ${colorDangerDark};
+ border-radius: 10px;
+ `}
+`;
+
+export default {
+ RecordingIndicatorIcon,
+ RecordingControl,
+ PresentationTitle,
+ VisuallyHidden,
+ PresentationTitleSeparator,
+ RecordingIndicator,
+ RecordingStatusViewOnly,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/component.jsx b/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/component.jsx
new file mode 100644
index 00000000..b6bdd47c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/component.jsx
@@ -0,0 +1,457 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import EndMeetingConfirmationContainer from '/imports/ui/components/end-meeting-confirmation/container';
+import { makeCall } from '/imports/ui/services/api';
+import AboutContainer from '/imports/ui/components/about/container';
+import MobileAppModal from '/imports/ui/components/mobile-app-modal/container';
+import LayoutModalContainer from '/imports/ui/components/layout/modal/container';
+import SettingsMenuContainer from '/imports/ui/components/settings/container';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import ShortcutHelpComponent from '/imports/ui/components/shortcut-help/component';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import { colorDanger, colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import Styled from './styles';
+import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
+import { isLayoutsEnabled } from '/imports/ui/services/features';
+
+const intlMessages = defineMessages({
+ optionsLabel: {
+ id: 'app.navBar.settingsDropdown.optionsLabel',
+ description: 'Options button label',
+ },
+ fullscreenLabel: {
+ id: 'app.navBar.settingsDropdown.fullscreenLabel',
+ description: 'Make fullscreen option label',
+ },
+ settingsLabel: {
+ id: 'app.navBar.settingsDropdown.settingsLabel',
+ description: 'Open settings option label',
+ },
+ aboutLabel: {
+ id: 'app.navBar.settingsDropdown.aboutLabel',
+ description: 'About option label',
+ },
+ aboutDesc: {
+ id: 'app.navBar.settingsDropdown.aboutDesc',
+ description: 'Describes about option',
+ },
+ leaveSessionLabel: {
+ id: 'app.navBar.settingsDropdown.leaveSessionLabel',
+ description: 'Leave session button label',
+ },
+ fullscreenDesc: {
+ id: 'app.navBar.settingsDropdown.fullscreenDesc',
+ description: 'Describes fullscreen option',
+ },
+ settingsDesc: {
+ id: 'app.navBar.settingsDropdown.settingsDesc',
+ description: 'Describes settings option',
+ },
+ leaveSessionDesc: {
+ id: 'app.navBar.settingsDropdown.leaveSessionDesc',
+ description: 'Describes leave session option',
+ },
+ exitFullscreenDesc: {
+ id: 'app.navBar.settingsDropdown.exitFullscreenDesc',
+ description: 'Describes exit fullscreen option',
+ },
+ exitFullscreenLabel: {
+ id: 'app.navBar.settingsDropdown.exitFullscreenLabel',
+ description: 'Exit fullscreen option label',
+ },
+ hotkeysLabel: {
+ id: 'app.navBar.settingsDropdown.hotkeysLabel',
+ description: 'Hotkeys options label',
+ },
+ hotkeysDesc: {
+ id: 'app.navBar.settingsDropdown.hotkeysDesc',
+ description: 'Describes hotkeys option',
+ },
+ layoutModal: {
+ id: 'app.actionsBar.actionsDropdown.layoutModal',
+ description: 'Label for layouts selection button',
+ },
+ helpLabel: {
+ id: 'app.navBar.settingsDropdown.helpLabel',
+ description: 'Help options label',
+ },
+ openAppLabel: {
+ id: 'app.navBar.settingsDropdown.openAppLabel',
+ description: 'Open mobile app label',
+ },
+ helpDesc: {
+ id: 'app.navBar.settingsDropdown.helpDesc',
+ description: 'Describes help option',
+ },
+ endMeetingForAllLabel: {
+ id: 'app.navBar.settingsDropdown.endMeetingForAllLabel',
+ description: 'End meeting options label',
+ },
+ endMeetingDesc: {
+ id: 'app.navBar.settingsDropdown.endMeetingDesc',
+ description: 'Describes settings option closing the current meeting',
+ },
+ startCaption: {
+ id: 'app.audio.captions.button.start',
+ description: 'Start audio captions',
+ },
+ stopCaption: {
+ id: 'app.audio.captions.button.stop',
+ description: 'Stop audio captions',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handleToggleFullscreen: PropTypes.func.isRequired,
+ noIOSFullscreen: PropTypes.bool,
+ amIModerator: PropTypes.bool,
+ shortcuts: PropTypes.string,
+ isBreakoutRoom: PropTypes.bool,
+ isMeteorConnected: PropTypes.bool.isRequired,
+ isDropdownOpen: PropTypes.bool,
+ audioCaptionsEnabled: PropTypes.bool.isRequired,
+ audioCaptionsActive: PropTypes.bool.isRequired,
+ audioCaptionsSet: PropTypes.func.isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ isDirectLeaveButtonEnabled: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ noIOSFullscreen: true,
+ amIModerator: false,
+ shortcuts: '',
+ isBreakoutRoom: false,
+ isDropdownOpen: false,
+};
+
+const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
+const BBB_TABLET_APP_CONFIG = Meteor.settings.public.app.bbbTabletApp;
+const { isSafari, isTabletApp } = browserInfo;
+const FULLSCREEN_CHANGE_EVENT = isSafari ? 'webkitfullscreenchange' : 'fullscreenchange';
+
+class SettingsDropdown extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isAboutModalOpen: false,
+ isShortcutHelpModalOpen: false,
+ isSettingsMenuModalOpen: false,
+ isEndMeetingConfirmationModalOpen: false,
+ isMobileAppModalOpen:false,
+ isFullscreen: false,
+ isLayoutModalOpen: false,
+ };
+
+ // Set the logout code to 680 because it's not a real code and can be matched on the other side
+ this.LOGOUT_CODE = '680';
+
+ this.leaveSession = this.leaveSession.bind(this);
+ this.onFullscreenChange = this.onFullscreenChange.bind(this);
+ this.setSettingsMenuModalIsOpen = this.setSettingsMenuModalIsOpen.bind(this);
+ this.setEndMeetingConfirmationModalIsOpen = this.setEndMeetingConfirmationModalIsOpen.bind(this);
+ this.setMobileAppModalIsOpen = this.setMobileAppModalIsOpen.bind(this);
+ this.setAboutModalIsOpen = this.setAboutModalIsOpen.bind(this);
+ this.setShortcutHelpModalIsOpen = this.setShortcutHelpModalIsOpen.bind(this);
+ this.setLayoutModalIsOpen = this.setLayoutModalIsOpen.bind(this);
+ }
+
+ componentDidMount() {
+ document.documentElement.addEventListener(FULLSCREEN_CHANGE_EVENT, this.onFullscreenChange);
+ }
+
+ componentWillUnmount() {
+ document.documentElement.removeEventListener(FULLSCREEN_CHANGE_EVENT, this.onFullscreenChange);
+ }
+
+ onFullscreenChange() {
+ const { isFullscreen } = this.state;
+ const newIsFullscreen = FullscreenService.isFullScreen(document.documentElement);
+ if (isFullscreen !== newIsFullscreen) {
+ this.setState({ isFullscreen: newIsFullscreen });
+ }
+ }
+
+ getFullscreenItem(menuItems) {
+ const {
+ intl,
+ noIOSFullscreen,
+ handleToggleFullscreen,
+ } = this.props;
+ const { isFullscreen } = this.state;
+
+ if (noIOSFullscreen || !ALLOW_FULLSCREEN) return null;
+
+ let fullscreenLabel = intl.formatMessage(intlMessages.fullscreenLabel);
+ let fullscreenDesc = intl.formatMessage(intlMessages.fullscreenDesc);
+ let fullscreenIcon = 'fullscreen';
+
+ if (isFullscreen) {
+ fullscreenLabel = intl.formatMessage(intlMessages.exitFullscreenLabel);
+ fullscreenDesc = intl.formatMessage(intlMessages.exitFullscreenDesc);
+ fullscreenIcon = 'exit_fullscreen';
+ }
+
+ return (
+ menuItems.push(
+ {
+ key: 'list-item-fullscreen',
+ icon: fullscreenIcon,
+ label: fullscreenLabel,
+ description: fullscreenDesc,
+ onClick: handleToggleFullscreen,
+ },
+ )
+ );
+ }
+
+ leaveSession() {
+ makeCall('userLeftMeeting');
+ // we don't check askForFeedbackOnLogout here,
+ // it is checked in meeting-ended component
+ Session.set('codeError', this.LOGOUT_CODE);
+ }
+
+ setAboutModalIsOpen(value) {
+ this.setState({isAboutModalOpen: value})
+ }
+
+ setShortcutHelpModalIsOpen(value) {
+ this.setState({isShortcutHelpModalOpen: value})
+ }
+
+ setSettingsMenuModalIsOpen(value) {
+ this.setState({isSettingsMenuModalOpen: value})
+ }
+
+ setEndMeetingConfirmationModalIsOpen(value) {
+ this.setState({isEndMeetingConfirmationModalOpen: value})
+ }
+
+ setMobileAppModalIsOpen(value) {
+ this.setState({isMobileAppModalOpen: value})
+ }
+
+ setLayoutModalIsOpen(value) {
+ this.setState({ isLayoutModalOpen: value });
+ }
+
+ renderMenuItems() {
+ const {
+ intl, amIModerator, isBreakoutRoom, isMeteorConnected, audioCaptionsEnabled,
+ audioCaptionsActive, audioCaptionsSet, isMobile, isDirectLeaveButtonEnabled,
+ } = this.props;
+
+ const { isIos } = deviceInfo;
+
+ const allowedToEndMeeting = amIModerator && !isBreakoutRoom;
+
+ const {
+ showHelpButton: helpButton,
+ helpLink,
+ allowLogout: allowLogoutSetting,
+ } = Meteor.settings.public.app;
+
+ this.menuItems = [];
+
+ this.getFullscreenItem(this.menuItems);
+
+ this.menuItems.push(
+ {
+ key: 'list-item-settings',
+ icon: 'settings',
+ dataTest: 'settings',
+ label: intl.formatMessage(intlMessages.settingsLabel),
+ description: intl.formatMessage(intlMessages.settingsDesc),
+ onClick: () => this.setSettingsMenuModalIsOpen(true),
+ },
+ {
+ key: 'list-item-about',
+ icon: 'about',
+ dataTest: 'aboutModal',
+ label: intl.formatMessage(intlMessages.aboutLabel),
+ description: intl.formatMessage(intlMessages.aboutDesc),
+ onClick: () => this.setAboutModalIsOpen(true),
+ },
+ );
+
+ if (helpButton) {
+ this.menuItems.push(
+ {
+ key: 'list-item-help',
+ icon: 'help',
+ iconRight: 'popout_window',
+ label: intl.formatMessage(intlMessages.helpLabel),
+ dataTest: 'helpButton',
+ description: intl.formatMessage(intlMessages.helpDesc),
+ onClick: () => window.open(`${helpLink}`),
+ },
+ );
+ }
+
+ if (isIos &&
+ !isTabletApp &&
+ BBB_TABLET_APP_CONFIG.enabled == true &&
+ BBB_TABLET_APP_CONFIG.iosAppStoreUrl !== '') {
+ this.menuItems.push(
+ {
+ key: 'list-item-help',
+ icon: 'popout_window',
+ label: intl.formatMessage(intlMessages.openAppLabel),
+ onClick: () => this.setMobileAppModalIsOpen(true),
+ }
+ );
+ }
+
+ if (audioCaptionsEnabled && isMobile) {
+ this.menuItems.push(
+ {
+ key: 'audioCaptions',
+ dataTest: 'audioCaptions',
+ icon: audioCaptionsActive ? 'closed_caption_stop' : 'closed_caption',
+ label: intl.formatMessage(
+ audioCaptionsActive ? intlMessages.stopCaption : intlMessages.startCaption,
+ ),
+ onClick: () => audioCaptionsSet(!audioCaptionsActive),
+ },
+ );
+ }
+
+ const enableLayoutButton = isLayoutsEnabled();
+
+ this.menuItems.push(
+ {
+ key: 'list-item-shortcuts',
+ icon: 'shortcuts',
+ label: intl.formatMessage(intlMessages.hotkeysLabel),
+ description: intl.formatMessage(intlMessages.hotkeysDesc),
+ onClick: () => this.setShortcutHelpModalIsOpen(true),
+ divider: !isDirectLeaveButtonEnabled && !enableLayoutButton,
+ },
+ );
+
+ if (enableLayoutButton) {
+ this.menuItems.push(
+ {
+ key: 'list-item-layout-modal',
+ icon: 'manage_layout',
+ label: intl.formatMessage(intlMessages.layoutModal),
+ onClick: () => this.setLayoutModalIsOpen(true),
+ divider: !isDirectLeaveButtonEnabled,
+ },
+ );
+ }
+
+ if (allowLogoutSetting && isMeteorConnected && !isDirectLeaveButtonEnabled) {
+ this.menuItems.push(
+ {
+ key: 'list-item-logout',
+ dataTest: 'logout',
+ icon: 'logout',
+ label: intl.formatMessage(intlMessages.leaveSessionLabel),
+ description: intl.formatMessage(intlMessages.leaveSessionDesc),
+ onClick: () => this.leaveSession(),
+ },
+ );
+ }
+
+ if (allowedToEndMeeting && isMeteorConnected && !isDirectLeaveButtonEnabled) {
+ const customStyles = { background: colorDanger, color: colorWhite };
+
+ this.menuItems.push(
+ {
+ key: 'list-item-end-meeting',
+ icon: 'close',
+ label: intl.formatMessage(intlMessages.endMeetingForAllLabel),
+ description: intl.formatMessage(intlMessages.endMeetingDesc),
+ customStyles,
+ onClick: () => this.setEndMeetingConfirmationModalIsOpen(true),
+ },
+ );
+ }
+
+ return this.menuItems;
+ }
+
+ renderModal(isOpen, setIsOpen, priority, Component, otherOptions) {
+ return isOpen ? setIsOpen(false),
+ priority,
+ setIsOpen,
+ isOpen
+ }}
+ /> : null
+ }
+
+ render() {
+ const {
+ intl,
+ shortcuts: OPEN_OPTIONS_AK,
+ isDropdownOpen,
+ isMobile,
+ isRTL,
+ } = this.props;
+
+ const { isAboutModalOpen, isShortcutHelpModalOpen, isSettingsMenuModalOpen,
+ isEndMeetingConfirmationModalOpen, isMobileAppModalOpen, isLayoutModalOpen } = this.state;
+
+ const customStyles = { top: '1rem' };
+
+ return (
+ <>
+ null}
+ />
+ )}
+ actions={this.renderMenuItems()}
+ opts={{
+ id: 'app-settings-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'left' : 'right' },
+ transformorigin: { vertical: 'top', horizontal: isRTL ? 'left' : 'right' },
+ }}
+ />
+ {this.renderModal(isAboutModalOpen, this.setAboutModalIsOpen, "low",
+ AboutContainer)}
+ {this.renderModal(isShortcutHelpModalOpen, this.setShortcutHelpModalIsOpen,
+ "low", ShortcutHelpComponent)}
+ {this.renderModal(isSettingsMenuModalOpen, this.setSettingsMenuModalIsOpen,
+ "low", SettingsMenuContainer)}
+ {this.renderModal(isEndMeetingConfirmationModalOpen, this.setEndMeetingConfirmationModalIsOpen,
+ "low", EndMeetingConfirmationContainer)}
+ {this.renderModal(isMobileAppModalOpen, this.setMobileAppModalIsOpen, "low",
+ MobileAppModal)}
+ {this.renderModal(isLayoutModalOpen, this.setLayoutModalIsOpen, 'low', LayoutModalContainer)}
+ >
+ );
+ }
+}
+SettingsDropdown.propTypes = propTypes;
+SettingsDropdown.defaultProps = defaultProps;
+export default withShortcutHelper(injectIntl(SettingsDropdown), 'openOptions');
diff --git a/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/container.jsx b/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/container.jsx
new file mode 100644
index 00000000..388ce1e6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/container.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import deviceInfo from '/imports/utils/deviceInfo';
+import browserInfo from '/imports/utils/browserInfo';
+import SettingsDropdown from './component';
+import audioCaptionsService from '/imports/ui/components/audio/captions/service';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { layoutSelectInput, layoutSelect } from '../../layout/context';
+import { SMALL_VIEWPORT_BREAKPOINT } from '../../layout/enums';
+
+const { isIphone } = deviceInfo;
+const { isSafari, isValidSafariVersion } = browserInfo;
+
+const noIOSFullscreen = !!(((isSafari && !isValidSafariVersion) || isIphone));
+
+const SettingsDropdownContainer = (props) => {
+ const { width: browserWidth } = layoutSelectInput((i) => i.browser);
+ const isMobile = browserWidth <= SMALL_VIEWPORT_BREAKPOINT;
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return (
+
+ );
+};
+
+export default withTracker((props) => {
+ const handleToggleFullscreen = () => FullscreenService.toggleFullScreen();
+ return {
+ amIModerator: props.amIModerator,
+ audioCaptionsEnabled: audioCaptionsService.hasAudioCaptions(),
+ audioCaptionsActive: audioCaptionsService.getAudioCaptions(),
+ audioCaptionsSet: (value) => audioCaptionsService.setAudioCaptions(value),
+ isMobile: deviceInfo.isMobile,
+ handleToggleFullscreen,
+ noIOSFullscreen,
+ isMeteorConnected: Meteor.status().connected,
+ isBreakoutRoom: meetingIsBreakout(),
+ isDropdownOpen: Session.get('dropdownOpen'),
+ };
+})(SettingsDropdownContainer);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/styles.js b/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/styles.js
new file mode 100644
index 00000000..35b8a0e0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/settings-dropdown/styles.js
@@ -0,0 +1,20 @@
+import styled from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+
+const DropdownButton = styled(Button)`
+ ${({ state }) => state === 'open' && `
+ @media ${smallOnly} {
+ display: none;
+ }
+ `}
+
+ ${({ state }) => state === 'closed' && `
+ margin: 0;
+ z-index: 3;
+ `}
+`;
+
+export default {
+ DropdownButton,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/styles.js b/src/2.7.12/imports/ui/components/nav-bar/styles.js
new file mode 100644
index 00000000..06d0359b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/styles.js
@@ -0,0 +1,131 @@
+import styled from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import { barsPadding, borderSize } from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorWhite,
+ colorDanger,
+ colorGrayDark,
+ colorBackground,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import { phoneLandscape, smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+
+const Navbar = styled.header`
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ text-align: center;
+ font-size: 1.5rem;
+ background-color: ${colorBackground};
+ padding: ${barsPadding} ${barsPadding} 0 ${barsPadding};
+`;
+
+const Top = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+const Left = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+`;
+
+const ArrowLeft = styled(Icon)`
+ position: absolute;
+ font-size: 40%;
+ color: ${colorWhite};
+ left: .25rem;
+ @media ${smallOnly} {
+ display: none;
+ }
+`;
+
+const ArrowRight = styled(Icon)`
+ position: absolute;
+ font-size: 40%;
+ color: ${colorWhite};
+ right: .0125rem;
+ @media ${smallOnly} {
+ display: none;
+ }
+`;
+
+const Center = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ width: 70%;
+ flex: 1;
+`;
+
+const PresentationTitle = styled.h1`
+ font-weight: 400;
+ color: ${colorWhite};
+ font-size: ${fontSizeBase};
+ margin: 0;
+ padding: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 30vw;
+
+ > [class^="icon-bbb-"] {
+ font-size: 75%;
+ }
+`;
+
+const Right = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ flex: 0;
+`;
+
+const Bottom = styled.div`
+ display: flex;
+ flex-direction: row;
+
+ @media ${phoneLandscape} {
+ margin-top: .25rem;
+ }
+`;
+
+const NavbarToggleButton = styled(Button)`
+ margin: 0;
+ z-index: 3;
+
+ ${({ hasNotification }) => hasNotification && `
+ position: relative;
+
+ &:after {
+ content: '';
+ position: absolute;
+ border-radius: 50%;
+ width: 12px;
+ height: 12px;
+ bottom: ${borderSize};
+ right: 3px;
+ background-color: ${colorDanger};
+ border: ${borderSize} solid ${colorGrayDark};
+ }
+ `}
+`;
+
+export default {
+ Navbar,
+ Top,
+ Left,
+ ArrowLeft,
+ ArrowRight,
+ Center,
+ PresentationTitle,
+ Right,
+ Bottom,
+ NavbarToggleButton,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/component.jsx b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/component.jsx
new file mode 100644
index 00000000..a39da5e7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/component.jsx
@@ -0,0 +1,163 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import Service from './service';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const intlMessages = defineMessages({
+ wasTalking: {
+ id: 'app.talkingIndicator.wasTalking',
+ description: 'aria label for user who is not talking but still visible',
+ },
+ isTalking: {
+ id: 'app.talkingIndicator.isTalking',
+ description: 'aria label for user currently talking',
+ },
+ ariaMuteDesc: {
+ id: 'app.talkingIndicator.ariaMuteDesc',
+ description: 'aria description for muting a user',
+ },
+ muteLabel: {
+ id: 'app.actionsBar.muteLabel',
+ description: 'indicator mute label for moderators',
+ },
+ moreThanMaxIndicatorsTalking: {
+ id: 'app.talkingIndicator.moreThanMaxIndicatorsTalking',
+ description: 'indicator label for all users who is talking but not visible',
+ },
+ moreThanMaxIndicatorsWereTalking: {
+ id: 'app.talkingIndicator.moreThanMaxIndicatorsWereTalking',
+ description: 'indicator label for all users who is not talking but not visible',
+ },
+});
+
+class TalkingIndicator extends PureComponent {
+ handleMuteUser(id) {
+ const { muteUser, amIModerator, isBreakoutRoom } = this.props;
+ // only allow moderator muting anyone in non-breakout
+ if (!amIModerator || isBreakoutRoom) return;
+ muteUser(id);
+ }
+
+ render() {
+ const {
+ intl,
+ talkers,
+ amIModerator,
+ moreThanMaxIndicators,
+ users,
+ } = this.props;
+ if (!talkers) return null;
+
+ const talkingUserElements = Object.keys(talkers).map((id) => {
+ const {
+ talking,
+ color,
+ transcribing,
+ floor,
+ muted,
+ callerName,
+ } = talkers[`${id}`];
+
+ const user = users[id];
+
+ const name = user?.name ?? callerName;
+
+ const ariaLabel = intl.formatMessage(talking
+ ? intlMessages.isTalking : intlMessages.wasTalking, {
+ 0: name,
+ });
+
+ let icon = talking ? 'unmute' : 'blank';
+ icon = muted ? 'mute' : icon;
+
+ return (
+
+ {transcribing && (
+
+ )}
+ this.handleMuteUser(id)}
+ label={name}
+ tooltipLabel={!muted && amIModerator
+ ? `${intl.formatMessage(intlMessages.muteLabel)} ${name}`
+ : null}
+ data-test={talking ? 'isTalking' : 'wasTalking'}
+ aria-label={ariaLabel}
+ aria-describedby={talking ? 'description' : null}
+ color="primary"
+ icon={icon}
+ size="lg"
+ style={{
+ backgroundColor: color,
+ border: `solid 2px ${color}`,
+ }}
+ >
+ {talking ? (
+
+ {`${intl.formatMessage(intlMessages.ariaMuteDesc)}`}
+
+ ) : null}
+
+
+ );
+ });
+
+ const maxIndicator = () => {
+ if (!moreThanMaxIndicators) return null;
+
+ const nobodyTalking = Service.nobodyTalking(talkers);
+
+ const { moreThanMaxIndicatorsTalking, moreThanMaxIndicatorsWereTalking } = intlMessages;
+
+ const ariaLabel = intl.formatMessage(nobodyTalking
+ ? moreThanMaxIndicatorsWereTalking : moreThanMaxIndicatorsTalking, {
+ 0: Object.keys(talkers).length,
+ });
+
+ return (
+ {}} // maybe add a dropdown to show the rest of the users
+ label="..."
+ tooltipLabel={ariaLabel}
+ aria-label={ariaLabel}
+ color="primary"
+ size="sm"
+ style={{
+ backgroundColor: '#4a148c',
+ border: 'solid 2px #4a148c',
+ cursor: 'default',
+ }}
+ />
+ );
+ };
+
+ return (
+
+
+ {talkingUserElements}
+ {maxIndicator()}
+
+
+ );
+ }
+}
+
+export default injectIntl(TalkingIndicator);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/container.jsx b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/container.jsx
new file mode 100644
index 00000000..72897e4c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/container.jsx
@@ -0,0 +1,99 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import VoiceUsers from '/imports/api/voice-users';
+import Auth from '/imports/ui/services/auth';
+import { debounce } from '/imports/utils/debounce';
+import TalkingIndicator from './component';
+import { makeCall } from '/imports/ui/services/api';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { layoutSelectInput, layoutDispatch } from '../../layout/context';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+
+const APP_CONFIG = Meteor.settings.public.app;
+const { enableTalkingIndicator } = APP_CONFIG;
+const TALKING_INDICATOR_MUTE_INTERVAL = 500;
+const TALKING_INDICATORS_MAX = 8;
+
+const TalkingIndicatorContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+
+ if (!enableTalkingIndicator) return null;
+
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
+ const { sidebarNavPanel } = sidebarNavigation;
+ const layoutContextDispatch = layoutDispatch();
+
+ return (
+
+ );
+};
+
+export default withTracker(() => {
+ const talkers = {};
+ const meetingId = Auth.meetingID;
+ const usersTalking = VoiceUsers.find({ meetingId, joined: true, spoke: true }, {
+ fields: {
+ callerName: 1,
+ talking: 1,
+ floor: 1,
+ color: 1,
+ startTime: 1,
+ muted: 1,
+ intId: 1,
+ },
+ sort: {
+ startTime: 1,
+ },
+ limit: TALKING_INDICATORS_MAX + 1,
+ }).fetch();
+
+ if (usersTalking) {
+ const maxNumberVoiceUsersNotification = usersTalking.length < TALKING_INDICATORS_MAX
+ ? usersTalking.length
+ : TALKING_INDICATORS_MAX;
+
+ for (let i = 0; i < maxNumberVoiceUsersNotification; i += 1) {
+ const {
+ callerName, talking, floor, color, muted, intId,
+ } = usersTalking[i];
+
+ talkers[`${intId}`] = {
+ color,
+ transcribing: SpeechService.hasSpeechLocale(intId),
+ floor,
+ talking,
+ muted,
+ callerName,
+ };
+ }
+ }
+
+ const muteUser = debounce((id) => {
+ const user = VoiceUsers.findOne({ meetingId, intId: id }, {
+ fields: {
+ muted: 1,
+ },
+ });
+ if (user.muted) return;
+ makeCall('toggleVoice', id);
+ }, TALKING_INDICATOR_MUTE_INTERVAL, { leading: true, trailing: false });
+
+ return {
+ talkers,
+ muteUser,
+ isBreakoutRoom: meetingIsBreakout(),
+ moreThanMaxIndicators: usersTalking.length > TALKING_INDICATORS_MAX,
+ };
+})(TalkingIndicatorContainer);
diff --git a/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/service.js b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/service.js
new file mode 100644
index 00000000..921c95cf
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/service.js
@@ -0,0 +1,8 @@
+const nobodyTalking = (talkers) => {
+ const values = Object.values(talkers);
+ return values.every(({ talking }) => talking === false);
+};
+
+export default {
+ nobodyTalking,
+};
diff --git a/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/styles.js b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/styles.js
new file mode 100644
index 00000000..214b7d3c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/nav-bar/talking-indicator/styles.js
@@ -0,0 +1,172 @@
+import styled from 'styled-components';
+import {
+ borderSize,
+ borderRadius,
+ talkerBorderRadius,
+ talkerPaddingXsm,
+ talkerPaddingLg,
+ talkerMaxWidth,
+ talkerMarginSm,
+ spokeOpacity,
+ talkerPaddingXl,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorWhite,
+ colorSuccess,
+ colorDanger,
+ colorBackground,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeBase,
+ talkerFontWeight,
+ fontSizeXS,
+ fontSizeSmaller,
+ fontSizeSmall,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import { phoneLandscape } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+import Icon from '/imports/ui/components/common/icon/component';
+
+const TalkingIndicatorButton = styled(Button)`
+ display: flex;
+ flex-direction: row;
+
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+
+ flex: 0 0 auto;
+ color: ${colorWhite};
+ font-weight: ${talkerFontWeight};
+ border-radius: ${talkerBorderRadius} ${talkerBorderRadius};
+ font-size: ${fontSizeBase};
+ padding: ${talkerPaddingXsm} ${talkerPaddingLg} ${talkerPaddingXsm} ${talkerPaddingLg};
+ box-shadow: none !important;
+
+ @media ${phoneLandscape} {
+ height: 1rem;
+ }
+
+ i,
+ span {
+ position: relative;
+ }
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ margin: 0 0 0 0 !important;
+ max-width: ${talkerMaxWidth};
+
+ @media ${phoneLandscape} {
+ font-size: ${fontSizeXS};
+ }
+
+ [dir='rtl'] & {
+ margin-left: ${talkerMarginSm};
+ }
+ }
+
+ i {
+ font-size: ${fontSizeSmaller};
+ width: 1rem;
+ height: 1rem;
+ line-height: 1rem;
+ background-color: ${colorSuccess};
+ border-radius: 50%;
+ position: relative;
+ right: ${talkerMarginSm};
+
+ @media ${phoneLandscape} {
+ height: ${talkerMarginSm};
+ width: ${talkerMarginSm};
+ font-size: ${fontSizeXS};
+ }
+
+ [dir='rtl'] & {
+ right: calc(${talkerMarginSm} * -1);
+ }
+ }
+
+ span:hover {
+ opacity: 1;
+ }
+
+ ${({ $spoke }) => $spoke
+ && `
+ opacity: ${spokeOpacity};
+
+ [dir="rtl"] & {
+ padding-right: ${talkerPaddingLg}
+ }
+ `}
+
+ ${({ $muted }) => $muted
+ && `
+ cursor: default;
+
+ i {
+ background-color: ${colorDanger};
+ }
+ `}
+
+ ${({ $isViewer }) => $isViewer
+ && `
+ cursor: default;
+ `}
+`;
+
+const CCIcon = styled(Icon)`
+ align-self: center;
+ color: ${colorWhite};
+ margin: 0 ${borderRadius};
+ font-size: calc(${fontSizeSmall} * 0.85);
+ opacity: ${({ muted, talking }) => ((muted || !talking) && `${spokeOpacity};`) || '1;'};
+`;
+
+const TalkingIndicatorWrapper = styled.div`
+ border-radius: ${talkerBorderRadius} ${talkerBorderRadius};
+ display: flex;
+ margin: 0 ${borderRadius};
+ opacity: ${({ muted, talking }) => ((muted || !talking) && `${spokeOpacity};`) || '1;'};
+ background: ${({ muted, talking, floor }) => ((muted || !talking || !floor) && `${colorBackground};`) || `${colorSuccess}`};
+`;
+
+const Hidden = styled.div`
+ display: none;
+`;
+
+const IsTalkingWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ position: relative;
+ overflow: hidden;
+ margin-top: 0.5rem;
+`;
+
+const Speaking = styled.div`
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ max-height: ${talkerPaddingXl};
+ scrollbar-width: 0; // firefox
+ scrollbar-color: transparent;
+
+ &::-webkit-scrollbar {
+ width: 0px;
+ height: 0px;
+ background: transparent;
+ }
+`;
+
+export default {
+ TalkingIndicatorButton,
+ TalkingIndicatorWrapper,
+ CCIcon,
+ Hidden,
+ IsTalkingWrapper,
+ Speaking,
+};
diff --git a/src/2.7.12/imports/ui/components/notes/component.jsx b/src/2.7.12/imports/ui/components/notes/component.jsx
new file mode 100644
index 00000000..97dc0839
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/component.jsx
@@ -0,0 +1,213 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import Service from '/imports/ui/components/notes/service';
+import PadContainer from '/imports/ui/components/pads/container';
+import Styled from './styles';
+import { PANELS, ACTIONS, LAYOUT_TYPE } from '../layout/enums';
+import browserInfo from '/imports/utils/browserInfo';
+import Header from '/imports/ui/components/common/control-header/component';
+import NotesDropdown from '/imports/ui/components/notes/notes-dropdown/container';
+import { isPresentationEnabled } from '../../services/features';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+const DELAY_UNMOUNT_SHARED_NOTES = Meteor.settings.public.app.delayForUnmountOfSharedNote;
+const intlMessages = defineMessages({
+ hide: {
+ id: 'app.notes.hide',
+ description: 'Label for hiding shared notes button',
+ },
+ title: {
+ id: 'app.notes.title',
+ description: 'Title for the shared notes',
+ },
+ unpinNotes: {
+ id: 'app.notes.notesDropdown.unpinNotes',
+ description: 'Label for unpin shared notes button',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isRTL: PropTypes.bool.isRequired,
+ hasPermission: PropTypes.bool.isRequired,
+ isResizing: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ sidebarContent: PropTypes.object.isRequired,
+ sharedNotesOutput: PropTypes.object.isRequired,
+ area: PropTypes.string,
+ layoutType: PropTypes.string,
+};
+
+const defaultProps = {
+ area: 'sidebarContent',
+ layoutType: null,
+};
+
+let timoutRef = null;
+const sidebarContentToIgnoreDelay = ['captions'];
+const Notes = ({
+ hasPermission,
+ intl,
+ isRTL,
+ layoutContextDispatch,
+ isResizing,
+ area,
+ layoutType,
+ sidebarContent,
+ sharedNotesOutput,
+ amIPresenter,
+ isToSharedNotesBeShow,
+ shouldShowSharedNotesOnPresentationArea,
+}) => {
+ const [shouldRenderNotes, setShouldRenderNotes] = useState(false);
+
+ const { isChrome } = browserInfo;
+ const isOnMediaArea = area === 'media';
+ const style = isOnMediaArea ? {
+ position: 'absolute',
+ ...sharedNotesOutput,
+ } : {};
+
+ const isHidden = (isOnMediaArea && (style.width === 0 || style.height === 0))
+ || (!isToSharedNotesBeShow
+ && !sidebarContentToIgnoreDelay.includes(sidebarContent.sidebarContentPanel))
+ || shouldShowSharedNotesOnPresentationArea;
+
+ if (isHidden && !isOnMediaArea) {
+ style.padding = 0;
+ style.display = 'none';
+ }
+ useEffect(() => {
+ if (isToSharedNotesBeShow) {
+ setShouldRenderNotes(true);
+ clearTimeout(timoutRef);
+ } else {
+ timoutRef = setTimeout(() => {
+ setShouldRenderNotes(false);
+ }, (sidebarContentToIgnoreDelay.includes(sidebarContent.sidebarContentPanel)
+ || shouldShowSharedNotesOnPresentationArea)
+ ? 0 : DELAY_UNMOUNT_SHARED_NOTES);
+ }
+ return () => clearTimeout(timoutRef);
+ }, [isToSharedNotesBeShow, sidebarContent.sidebarContentPanel]);
+ useEffect(() => {
+ if (
+ isOnMediaArea
+ && (sidebarContent.isOpen || !isPresentationEnabled())
+ && (sidebarContent.sidebarContentPanel === PANELS.SHARED_NOTES || !isPresentationEnabled())
+ ) {
+ if (layoutType === LAYOUT_TYPE.VIDEO_FOCUS) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: PUBLIC_CHAT_ID,
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_NOTES_IS_PINNED,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: true,
+ });
+
+ return () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_NOTES_IS_PINNED,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: Session.get('presentationLastState'),
+ });
+ };
+ } if (shouldShowSharedNotesOnPresentationArea) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_NOTES_IS_PINNED,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: true,
+ });
+ }
+ }, []);
+
+ const renderHeaderOnMedia = () => {
+ return amIPresenter ? (
+ {
+ Service.pinSharedNotes(false);
+ },
+ }}
+ />
+ ) : null;
+ };
+
+ return (shouldRenderNotes || shouldShowSharedNotesOnPresentationArea) && (
+
+ {!isOnMediaArea ? (
+ {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ },
+ 'data-test': 'hideNotesLabel',
+ 'aria-label': intl.formatMessage(intlMessages.hide),
+ label: intl.formatMessage(intlMessages.title),
+ }}
+ customRightButton={
+
+ }
+ />
+ ) : renderHeaderOnMedia()}
+
+
+ );
+};
+
+Notes.propTypes = propTypes;
+Notes.defaultProps = defaultProps;
+
+export default injectWbResizeEvent(injectIntl(Notes));
diff --git a/src/2.7.12/imports/ui/components/notes/container.jsx b/src/2.7.12/imports/ui/components/notes/container.jsx
new file mode 100644
index 00000000..3c45890a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/container.jsx
@@ -0,0 +1,39 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Notes from './component';
+import Service from './service';
+import Auth from '/imports/ui/services/auth';
+import MediaService from '/imports/ui/components/media/service';
+import { layoutSelectInput, layoutDispatch, layoutSelectOutput } from '../layout/context';
+import { UsersContext } from '../components-data/users-context/context';
+
+const Container = ({ ...props }) => {
+ const cameraDock = layoutSelectInput((i) => i.cameraDock);
+ const sharedNotesOutput = layoutSelectOutput((i) => i.sharedNotes);
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { isResizing } = cameraDock;
+ const layoutContextDispatch = layoutDispatch();
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
+
+ return ;
+};
+
+export default withTracker(() => {
+ const hasPermission = Service.hasPermission();
+ const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
+ const shouldShowSharedNotesOnPresentationArea = MediaService.shouldShowSharedNotes();
+ return {
+ hasPermission,
+ isRTL,
+ shouldShowSharedNotesOnPresentationArea,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/notes/notes-dropdown/component.jsx b/src/2.7.12/imports/ui/components/notes/notes-dropdown/component.jsx
new file mode 100644
index 00000000..ab2378b3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/notes-dropdown/component.jsx
@@ -0,0 +1,137 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Trigger from '/imports/ui/components/common/control-header/right/component';
+import Service from './service';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const DEBOUNCE_TIMEOUT = 15000;
+const NOTES_CONFIG = Meteor.settings.public.notes;
+const NOTES_IS_PINNABLE = NOTES_CONFIG.pinnable;
+
+const intlMessages = defineMessages({
+ convertAndUploadLabel: {
+ id: 'app.notes.notesDropdown.covertAndUpload',
+ description: 'Export shared notes as a PDF and upload to the main room',
+ },
+ pinNotes: {
+ id: 'app.notes.notesDropdown.pinNotes',
+ description: 'Label for pin shared notes button',
+ },
+ options: {
+ id: 'app.notes.notesDropdown.notesOptions',
+ description: 'Label for shared notes options',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ amIPresenter: PropTypes.bool.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+};
+
+class NotesDropdown extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ converterButtonDisabled: false,
+ };
+ }
+
+ setConverterButtonDisabled(value) {
+ return this.setState({ converterButtonDisabled: value });
+ }
+
+ getAvailableActions() {
+ const {
+ intl,
+ amIPresenter,
+ } = this.props;
+
+ const { converterButtonDisabled } = this.state;
+
+ const uploadIcon = 'upload';
+ const pinIcon = 'presentation';
+
+ this.menuItems = [];
+
+ if (amIPresenter) {
+ this.menuItems.push(
+ {
+ key: uniqueId('notes-option-'),
+ icon: uploadIcon,
+ dataTest: 'moveNotesToWhiteboard',
+ label: intl.formatMessage(intlMessages.convertAndUploadLabel),
+ disabled: converterButtonDisabled,
+ onClick: () => {
+ this.setConverterButtonDisabled(true);
+ setTimeout(() => this.setConverterButtonDisabled(false), DEBOUNCE_TIMEOUT);
+ return Service.convertAndUpload();
+ },
+ },
+ );
+ }
+
+ if (amIPresenter && NOTES_IS_PINNABLE) {
+ this.menuItems.push(
+ {
+ key: uniqueId('notes-option-'),
+ icon: pinIcon,
+ dataTest: 'pinNotes',
+ label: intl.formatMessage(intlMessages.pinNotes),
+ onClick: () => {
+ Service.pinSharedNotes();
+ },
+ },
+ );
+ }
+
+ return this.menuItems;
+ }
+
+ render() {
+ const {
+ intl,
+ isRTL,
+ } = this.props;
+
+ const actions = this.getAvailableActions();
+
+ if (actions.length === 0) return null;
+
+ return (
+ <>
+ null}
+ />
+ }
+ opts={{
+ id: 'notes-options-dropdown',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ actions={actions}
+ />
+ >
+ );
+ }
+}
+
+NotesDropdown.propTypes = propTypes;
+
+export default injectIntl(NotesDropdown);
diff --git a/src/2.7.12/imports/ui/components/notes/notes-dropdown/container.jsx b/src/2.7.12/imports/ui/components/notes/notes-dropdown/container.jsx
new file mode 100644
index 00000000..3695ccf6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/notes-dropdown/container.jsx
@@ -0,0 +1,16 @@
+import React, { useContext } from 'react';
+import NotesDropdown from './component';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import Auth from '/imports/ui/services/auth';
+import { layoutSelect } from '/imports/ui/components/layout/context';
+
+const NotesDropdownContainer = ({ ...props }) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return ;
+};
+
+export default NotesDropdownContainer;
diff --git a/src/2.7.12/imports/ui/components/notes/notes-dropdown/service.js b/src/2.7.12/imports/ui/components/notes/notes-dropdown/service.js
new file mode 100644
index 00000000..318d14af
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/notes-dropdown/service.js
@@ -0,0 +1,63 @@
+import Auth from '/imports/ui/services/auth';
+import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service';
+import PadsService from '/imports/ui/components/pads/service';
+import NotesService from '/imports/ui/components/notes/service';
+import { UploadingPresentations } from '/imports/api/presentations';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const PADS_CONFIG = Meteor.settings.public.pads;
+
+async function convertAndUpload() {
+
+ let filename = 'Shared_Notes';
+ const presentations = PresentationUploaderService.getPresentations();
+ const duplicates = presentations.filter((pres) => pres.filename?.startsWith(filename) || pres.name?.startsWith(filename)).length;
+
+ if (duplicates !== 0) { filename = `${filename}(${duplicates})`; }
+
+ const params = PadsService.getParams();
+ const padId = await PadsService.getPadId(NotesService.ID);
+ const extension = 'pdf';
+ filename = `${filename}.${extension}`;
+
+ UploadingPresentations.insert({
+ id: uniqueId(filename),
+ progress: 0,
+ filename,
+ lastModifiedUploader: false,
+ upload: {
+ done: false,
+ error: false
+ },
+ uploadTimestamp: new Date(),
+ });
+
+ const exportUrl = Auth.authenticateURL(`${PADS_CONFIG.url}/p/${padId}/export/${extension}?${params}`);
+ const sharedNotesAsFile = await fetch(exportUrl, { credentials: 'include' });
+
+ const data = await sharedNotesAsFile.blob();
+
+ const sharedNotesData = new File([data], filename, {
+ type: data.type,
+ });
+
+ PresentationUploaderService.handleSavePresentation([], isFromPresentationUploaderInterface = false, {
+ file: sharedNotesData,
+ isDownloadable: false, // by default new presentations are set not to be downloadable
+ isRemovable: true,
+ filename: sharedNotesData.name,
+ isCurrent: true,
+ conversion: { done: false, error: false },
+ upload: { done: false, error: false, progress: 0 },
+ exportation: { isRunning: false, error: false },
+ onConversion: () => { },
+ onUpload: () => { },
+ onProgress: () => { },
+ onDone: () => { },
+ });
+}
+
+export default {
+ convertAndUpload,
+ pinSharedNotes: () => NotesService.pinSharedNotes(true),
+};
diff --git a/src/2.7.12/imports/ui/components/notes/service.js b/src/2.7.12/imports/ui/components/notes/service.js
new file mode 100644
index 00000000..fd566877
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/service.js
@@ -0,0 +1,79 @@
+import Users from '/imports/api/users';
+import Meetings from '/imports/api/meetings';
+import PadsService from '/imports/ui/components/pads/service';
+import Auth from '/imports/ui/services/auth';
+import { Session } from 'meteor/session';
+import { ACTIONS, PANELS } from '/imports/ui/components/layout/enums';
+import { isSharedNotesEnabled } from '/imports/ui/services/features';
+
+const NOTES_CONFIG = Meteor.settings.public.notes;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const hasPermission = () => {
+ const user = Users.findOne(
+ { userId: Auth.userID },
+ {
+ fields: {
+ locked: 1,
+ role: 1,
+ },
+ },
+ );
+
+ if (user.role === ROLE_MODERATOR) return true;
+
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { 'lockSettingsProps.disableNotes': 1 } },
+ );
+
+ if (user.locked) {
+ return !meeting.lockSettingsProps.disableNotes;
+ }
+
+ return true;
+};
+
+const getLastRev = () => (Session.get('notesLastRev') || 0);
+
+const getRev = () => PadsService.getRev(NOTES_CONFIG.id);
+
+const markNotesAsRead = () => Session.set('notesLastRev', getRev());
+
+const hasUnreadNotes = () => (getRev() > getLastRev());
+
+const isEnabled = () => isSharedNotesEnabled();
+
+const toggleNotesPanel = (sidebarContentPanel, layoutContextDispatch) => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: sidebarContentPanel !== PANELS.SHARED_NOTES,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value:
+ sidebarContentPanel === PANELS.SHARED_NOTES
+ ? PANELS.NONE
+ : PANELS.SHARED_NOTES,
+ });
+};
+
+const pinSharedNotes = (pinned) => {
+ PadsService.pinPad(NOTES_CONFIG.id, pinned);
+};
+
+const isSharedNotesPinned = () => {
+ const pinnedPad = PadsService.getPinnedPad();
+ return pinnedPad?.externalId === NOTES_CONFIG.id;
+};
+
+export default {
+ ID: NOTES_CONFIG.id,
+ toggleNotesPanel,
+ hasPermission,
+ isEnabled,
+ markNotesAsRead,
+ hasUnreadNotes,
+ isSharedNotesPinned,
+ pinSharedNotes,
+};
diff --git a/src/2.7.12/imports/ui/components/notes/styles.js b/src/2.7.12/imports/ui/components/notes/styles.js
new file mode 100644
index 00000000..5b3bdd9b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notes/styles.js
@@ -0,0 +1,37 @@
+import styled from 'styled-components';
+import {
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import CommonHeader from '/imports/ui/components/common/control-header/component';
+
+const Notes = styled.div`
+ background-color: ${colorWhite};
+ padding: ${mdPaddingX};
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ overflow: hidden;
+ height: 100%;
+
+ ${({ isChrome }) => isChrome && `
+ transform: translateZ(0);
+ `}
+
+ @media ${smallOnly} {
+ transform: none !important;
+ &.no-padding {
+ padding: 0;
+ }
+ }
+`;
+
+const Header = styled(CommonHeader)`
+ padding-bottom: .2rem;
+`;
+
+export default {
+ Notes,
+ Header,
+};
diff --git a/src/2.7.12/imports/ui/components/notifications-bar/component.jsx b/src/2.7.12/imports/ui/components/notifications-bar/component.jsx
new file mode 100644
index 00000000..c1392be2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications-bar/component.jsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import Styled from './styles';
+
+const COLORS = [
+ 'default', 'primary', 'danger', 'success',
+];
+
+const propTypes = {
+ color: PropTypes.string,
+};
+
+const defaultProps = {
+ color: 'default',
+};
+
+const NotificationsBar = (props) => {
+ const {
+ color,
+ children,
+ alert,
+ } = props;
+
+ const hasColor = COLORS.includes(color);
+
+ return (
+
+ {children}
+
+ );
+};
+
+NotificationsBar.propTypes = propTypes;
+NotificationsBar.defaultProps = defaultProps;
+
+export default injectWbResizeEvent(NotificationsBar);
diff --git a/src/2.7.12/imports/ui/components/notifications-bar/container.jsx b/src/2.7.12/imports/ui/components/notifications-bar/container.jsx
new file mode 100644
index 00000000..570e502f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications-bar/container.jsx
@@ -0,0 +1,213 @@
+import { Meteor } from 'meteor/meteor';
+import { withTracker } from 'meteor/react-meteor-data';
+import React, { useEffect } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import Auth from '/imports/ui/services/auth';
+import { MeetingTimeRemaining } from '/imports/api/meetings';
+import Meetings from '/imports/api/meetings';
+import MeetingRemainingTime from './meeting-remaining-time/container';
+import Styled from './styles';
+import { layoutSelectInput, layoutDispatch } from '../layout/context';
+import { ACTIONS } from '../layout/enums';
+import { isEmpty } from 'radash';
+
+import breakoutService from '/imports/ui/components/breakout-room/service';
+import NotificationsBar from './component';
+
+// disconnected and trying to open a new connection
+const STATUS_CONNECTING = 'connecting';
+
+// permanently failed to connect; e.g., the client and server support different versions of DDP
+const STATUS_FAILED = 'failed';
+
+// failed to connect and waiting to try to reconnect
+const STATUS_WAITING = 'waiting';
+
+const METEOR_SETTINGS_APP = Meteor.settings.public.app;
+
+const REMAINING_TIME_THRESHOLD = METEOR_SETTINGS_APP.remainingTimeThreshold;
+
+const intlMessages = defineMessages({
+ failedMessage: {
+ id: 'app.failedMessage',
+ description: 'Notification for connecting to server problems',
+ },
+ connectingMessage: {
+ id: 'app.connectingMessage',
+ description: 'Notification message for when client is connecting to server',
+ },
+ waitingMessage: {
+ id: 'app.waitingMessage',
+ description: 'Notification message for disconnection with reconnection counter',
+ },
+ retryNow: {
+ id: 'app.retryNow',
+ description: 'Retry now text for reconnection counter',
+ },
+ breakoutTimeRemaining: {
+ id: 'app.breakoutTimeRemainingMessage',
+ description: 'Message that tells how much time is remaining for the breakout room',
+ },
+ breakoutWillClose: {
+ id: 'app.breakoutWillCloseMessage',
+ description: 'Message that tells time has ended and breakout will close',
+ },
+ calculatingBreakoutTimeRemaining: {
+ id: 'app.calculatingBreakoutTimeRemaining',
+ description: 'Message that tells that the remaining time is being calculated',
+ },
+ meetingTimeRemaining: {
+ id: 'app.meeting.meetingTimeRemaining',
+ description: 'Message that tells how much time is remaining for the meeting',
+ },
+ meetingWillClose: {
+ id: 'app.meeting.meetingTimeHasEnded',
+ description: 'Message that tells time has ended and meeting will close',
+ },
+ alertMeetingEndsUnderMinutes: {
+ id: 'app.meeting.alertMeetingEndsUnderMinutes',
+ description: 'Alert that tells that the meeting ends under x minutes',
+ },
+ alertBreakoutEndsUnderMinutes: {
+ id: 'app.meeting.alertBreakoutEndsUnderMinutes',
+ description: 'Alert that tells that the breakout ends under x minutes',
+ },
+});
+
+const NotificationsBarContainer = (props) => {
+ const { message, color } = props;
+
+ const notificationsBar = layoutSelectInput((i) => i.notificationsBar);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { hasNotification } = notificationsBar;
+
+ useEffect(() => {
+ const localHasNotification = !!message;
+
+ if (localHasNotification !== hasNotification) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_HAS_NOTIFICATIONS_BAR,
+ value: localHasNotification,
+ });
+ }
+ }, [message, hasNotification]);
+
+ if (isEmpty(message)) {
+ return null;
+ }
+
+ return (
+
+ {message}
+
+ );
+};
+
+let retrySeconds = 0;
+const retrySecondsDep = new Tracker.Dependency();
+let retryInterval = null;
+
+const getRetrySeconds = () => {
+ retrySecondsDep.depend();
+ return retrySeconds;
+};
+
+const setRetrySeconds = (sec = 0) => {
+ if (sec !== retrySeconds) {
+ retrySeconds = sec;
+ retrySecondsDep.changed();
+ }
+};
+
+const startCounter = (sec, set, get, interval) => {
+ clearInterval(interval);
+ set(sec);
+ return setInterval(() => {
+ set(get() - 1);
+ }, 1000);
+};
+
+const reconnect = () => {
+ Meteor.reconnect();
+};
+
+export default injectIntl(withTracker(({ intl }) => {
+ const { status, connected, retryTime } = Meteor.status();
+ const data = {};
+
+ if (!connected) {
+ data.color = 'primary';
+ switch (status) {
+ case STATUS_FAILED: {
+ data.color = 'danger';
+ data.message = intl.formatMessage(intlMessages.failedMessage);
+ break;
+ }
+ case STATUS_CONNECTING: {
+ data.message = intl.formatMessage(intlMessages.connectingMessage);
+ break;
+ }
+ case STATUS_WAITING: {
+ const sec = Math.round((retryTime - (new Date()).getTime()) / 1000);
+ retryInterval = startCounter(sec, setRetrySeconds, getRetrySeconds, retryInterval);
+ data.message = (
+ <>
+ {intl.formatMessage(intlMessages.waitingMessage, { 0: getRetrySeconds() })}
+
+ {intl.formatMessage(intlMessages.retryNow)}
+
+ >
+ );
+ break;
+ }
+ default:
+ break;
+ }
+
+ return data;
+ }
+
+ const meetingId = Auth.meetingID;
+ const breakouts = breakoutService.getBreakouts();
+
+ if (breakouts.length > 0) {
+ const currentBreakout = breakouts.find((b) => b.breakoutId === meetingId);
+
+ if (currentBreakout) {
+ data.message = (
+
+ );
+ }
+ }
+
+ const meetingTimeRemaining = MeetingTimeRemaining.findOne({ meetingId });
+ const Meeting = Meetings.findOne({ meetingId },
+ { fields: { 'meetingProp.isBreakout': 1 } });
+
+ if (meetingTimeRemaining && Meeting) {
+ const { timeRemaining } = meetingTimeRemaining;
+ const { isBreakout } = Meeting.meetingProp;
+ const underThirtyMin = timeRemaining && timeRemaining <= (REMAINING_TIME_THRESHOLD * 60);
+
+ if (underThirtyMin && !isBreakout) {
+ data.message = (
+
+ );
+ }
+ }
+
+ data.alert = true;
+ data.color = 'primary';
+ return data;
+})(NotificationsBarContainer));
diff --git a/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/component.jsx b/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/component.jsx
new file mode 100644
index 00000000..c15d0850
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/component.jsx
@@ -0,0 +1,9 @@
+import React from 'react';
+
+const MeetingRemainingTime = (props) => (
+
+ { props.children }
+
+);
+
+export default MeetingRemainingTime;
diff --git a/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/container.jsx b/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/container.jsx
new file mode 100644
index 00000000..8d18972e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/container.jsx
@@ -0,0 +1,165 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { defineMessages, injectIntl } from 'react-intl';
+import injectNotify from '/imports/ui/components/common/toast/inject-notify/component';
+import humanizeSeconds from '/imports/utils/humanizeSeconds';
+import MeetingRemainingTimeComponent from './component';
+import BreakoutService from '/imports/ui/components/breakout-room/service';
+import { Text, Time } from './styles';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { isEmpty } from 'radash';
+
+const intlMessages = defineMessages({
+ failedMessage: {
+ id: 'app.failedMessage',
+ description: 'Notification for connecting to server problems',
+ },
+ connectingMessage: {
+ id: 'app.connectingMessage',
+ description: 'Notification message for when client is connecting to server',
+ },
+ waitingMessage: {
+ id: 'app.waitingMessage',
+ description: 'Notification message for disconnection with reconnection counter',
+ },
+ breakoutTimeRemaining: {
+ id: 'app.breakoutTimeRemainingMessage',
+ description: 'Message that tells how much time is remaining for the breakout room',
+ },
+ breakoutWillClose: {
+ id: 'app.breakoutWillCloseMessage',
+ description: 'Message that tells time has ended and breakout will close',
+ },
+ calculatingBreakoutTimeRemaining: {
+ id: 'app.calculatingBreakoutTimeRemaining',
+ description: 'Message that tells that the remaining time is being calculated',
+ },
+ alertBreakoutEndsUnderMinutes: {
+ id: 'app.meeting.alertBreakoutEndsUnderMinutes',
+ description: 'Alert that tells that the breakout ends under x minutes',
+ },
+ alertMeetingEndsUnderMinutes: {
+ id: 'app.meeting.alertMeetingEndsUnderMinutes',
+ description: 'Alert that tells that the meeting ends under x minutes',
+ },
+});
+
+let timeRemaining = 0;
+let prevTimeRemaining = 0;
+let lastAlertTime = null;
+
+const METEOR_SETTINGS_APP = Meteor.settings.public.app;
+const REMAINING_TIME_ALERT_THRESHOLD_ARRAY = METEOR_SETTINGS_APP.remainingTimeAlertThresholdArray;
+
+const timeRemainingDep = new Tracker.Dependency();
+let timeRemainingInterval = null;
+
+class breakoutRemainingTimeContainer extends React.Component {
+ componentWillUnmount() {
+ clearInterval(timeRemainingInterval);
+ timeRemainingInterval = null;
+ timeRemaining = null;
+ }
+
+ render() {
+ const { message, bold } = this.props;
+ if (isEmpty(message)) {
+ return null;
+ }
+ if (bold) {
+ const words = message.split(' ');
+ const time = words.pop();
+ const text = words.join(' ');
+ return (
+
+ {text}
+
+ {time}
+
+ );
+ }
+ return (
+
+ {message}
+
+ );
+ }
+}
+
+const getTimeRemaining = () => {
+ timeRemainingDep.depend();
+ return timeRemaining;
+};
+
+const setTimeRemaining = (sec) => {
+ if (sec !== timeRemaining) {
+ timeRemaining = sec;
+ timeRemainingDep.changed();
+ }
+};
+
+const startCounter = (sec, set, get, interval) => {
+ clearInterval(interval);
+ if (!sec) return;
+ set(sec);
+ return setInterval(() => set(get() - 1), 1000);
+};
+
+
+export default injectNotify(injectIntl(withTracker(({
+ breakoutRoom,
+ intl,
+ notify,
+ messageDuration,
+ timeEndedMessage,
+ fromBreakoutPanel,
+ displayAlerts,
+}) => {
+ const data = {};
+ if (breakoutRoom) {
+ const roomRemainingTime = breakoutRoom.timeRemaining;
+ const localRemainingTime = getTimeRemaining();
+ const shouldResync = prevTimeRemaining !== roomRemainingTime && roomRemainingTime !== localRemainingTime;
+
+ if ((!timeRemainingInterval || shouldResync) && roomRemainingTime) {
+ prevTimeRemaining = roomRemainingTime;
+
+ timeRemainingInterval = startCounter(
+ roomRemainingTime,
+ setTimeRemaining,
+ getTimeRemaining,
+ timeRemainingInterval,
+ );
+ }
+ } else if (timeRemainingInterval) {
+ clearInterval(timeRemainingInterval);
+ }
+
+ if (timeRemaining >= 0 && timeRemainingInterval) {
+ if (timeRemaining > 0) {
+ const time = getTimeRemaining();
+ const alertsInSeconds = REMAINING_TIME_ALERT_THRESHOLD_ARRAY.map((item) => item * 60);
+
+ if (alertsInSeconds.includes(time) && time !== lastAlertTime && displayAlerts) {
+ const timeInMinutes = time / 60;
+ const message = meetingIsBreakout()
+ ? intlMessages.alertBreakoutEndsUnderMinutes
+ : intlMessages.alertMeetingEndsUnderMinutes;
+ const msg = { id: `${message.id}${timeInMinutes === 1 ? 'Singular' : 'Plural'}` };
+ const alertMessage = intl.formatMessage(msg, { 0: timeInMinutes });
+
+ lastAlertTime = time;
+ notify(alertMessage, 'info', 'rooms');
+ }
+ data.message = intl.formatMessage(messageDuration, { 0: humanizeSeconds(time) });
+ if (fromBreakoutPanel) data.bold = true;
+ } else {
+ clearInterval(timeRemainingInterval);
+ BreakoutService.setCapturedContentUploading();
+ data.message = intl.formatMessage(timeEndedMessage || intlMessages.breakoutWillClose);
+ }
+ } else if (breakoutRoom) {
+ data.message = intl.formatMessage(intlMessages.calculatingBreakoutTimeRemaining);
+ }
+ return data;
+})(breakoutRemainingTimeContainer)));
diff --git a/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/styles.js b/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/styles.js
new file mode 100644
index 00000000..8fe01381
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications-bar/meeting-remaining-time/styles.js
@@ -0,0 +1,26 @@
+import styled from 'styled-components';
+import {
+ colorGray,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeSmaller,
+ fontSizeXL,
+} from '/imports/ui/stylesheets/styled-components/typography';
+
+const Text = styled.span`
+ text-transform: uppercase;
+ color: ${colorGray};
+ font-size: ${fontSizeSmaller};
+ font-weight: 600;
+`;
+
+const Time = styled.span`
+ font-size: ${fontSizeXL};
+ font-weight: 600;
+ color: ${colorGray};
+`;
+
+export {
+ Text,
+ Time,
+};
diff --git a/src/2.7.12/imports/ui/components/notifications-bar/styles.js b/src/2.7.12/imports/ui/components/notifications-bar/styles.js
new file mode 100644
index 00000000..45c3c54d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications-bar/styles.js
@@ -0,0 +1,58 @@
+import styled from 'styled-components';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorGray,
+ colorWhite,
+ colorPrimary,
+ colorSuccess,
+ colorDanger,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const NotificationsBar = styled.div`
+ padding: calc(${lineHeightComputed} / 2);
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ font-weight: 600;
+
+ ${({ color }) => color === 'default' && `
+ color: ${colorGray};
+ background-color: ${colorWhite};
+ border-color: ${colorWhite};
+ `}
+
+ ${({ color }) => color === 'primary' && `
+ color: ${colorWhite};
+ background-color: ${colorPrimary};
+ border-color: ${colorPrimary};
+ `}
+
+ ${({ color }) => color === 'success' && `
+ color: ${colorWhite};
+ background-color: ${colorSuccess};
+ border-color: ${colorSuccess};
+ `}
+
+ ${({ color }) => color === 'danger' && `
+ color: ${colorWhite};
+ background-color: ${colorDanger};
+ border-color: ${colorDanger};
+ `}
+`;
+
+const RetryButton = styled.button`
+ background-color: transparent;
+ border: none;
+ cursor: pointer;
+ text-decoration: underline;
+ display: inline;
+ margin: 0;
+ padding: 0;
+ color: ${colorWhite};
+`;
+
+export default {
+ NotificationsBar,
+ RetryButton,
+};
diff --git a/src/2.7.12/imports/ui/components/notifications/container.jsx b/src/2.7.12/imports/ui/components/notifications/container.jsx
new file mode 100644
index 00000000..028ef3d3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/notifications/container.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { Notifications as NotificationsCollection } from '/imports/api/meetings';
+import { notify } from '/imports/ui/services/notification';
+import { withTracker } from 'meteor/react-meteor-data';
+import WaitingUsersAlertService from '/imports/ui/components/waiting-users/alert/service';
+import UserService from '/imports/ui/components/user-list/service';
+
+export default injectIntl(withTracker(({ intl }) => {
+ NotificationsCollection.find({}).observe({
+ added: (obj) => {
+ NotificationsCollection.remove(obj);
+
+ if (obj.messageId === 'app.userList.guest.pendingGuestAlert') {
+ return WaitingUsersAlertService.alert(obj, intl);
+ }
+
+ if (obj.messageId === 'app.notification.userJoinPushAlert') {
+ return UserService.UserJoinedMeetingAlert(obj);
+ }
+
+ if (obj.messageId === 'app.notification.userLeavePushAlert') {
+ return UserService.UserLeftMeetingAlert(obj);
+ }
+ if (obj.messageId === 'app.layoutUpdate.label') {
+ const last = new Date(Session.get('lastLayoutUpdateNotification'));
+ const now = new Date();
+ if (now - last < 1000) {
+ return {};
+ }
+ Session.set('lastLayoutUpdateNotification', now);
+ }
+
+ notify(
+ ,
+ obj.notificationType,
+ obj.icon,
+ );
+ },
+ });
+ return {};
+})(() => null));
diff --git a/src/2.7.12/imports/ui/components/pads/component.jsx b/src/2.7.12/imports/ui/components/pads/component.jsx
new file mode 100644
index 00000000..b92f7471
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/component.jsx
@@ -0,0 +1,66 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import PadContent from './content/container';
+import Service from './service';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ hint: {
+ id: 'app.pads.hint',
+ description: 'Label for hint on how to escape iframe',
+ },
+});
+
+const propTypes = {
+ externalId: PropTypes.string.isRequired,
+ hasSession: PropTypes.bool.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isResizing: PropTypes.bool.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+};
+
+const Pad = ({
+ externalId,
+ hasSession,
+ intl,
+ isResizing,
+ isRTL,
+}) => {
+ const [padURL, setPadURL] = useState();
+
+ useEffect(() => {
+ Service.getPadId(externalId).then((response) => {
+ setPadURL(Service.buildPadURL(response));
+ });
+ }, [isRTL, hasSession]);
+
+ if (!hasSession) {
+ return ;
+ }
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.hint)}
+
+
+ );
+};
+
+Pad.propTypes = propTypes;
+
+export default injectIntl(Pad);
diff --git a/src/2.7.12/imports/ui/components/pads/container.jsx b/src/2.7.12/imports/ui/components/pads/container.jsx
new file mode 100644
index 00000000..d617d5d9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/container.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Pad from './component';
+import Service from './service';
+import SessionsService from './sessions/service';
+
+const Container = ({ ...props }) => ;
+
+export default withTracker((props) => {
+ const {
+ externalId,
+ hasPermission,
+ } = props;
+
+ const hasPad = Service.hasPad(externalId);
+ const hasSession = SessionsService.hasSession(externalId);
+
+ if (hasPad && !hasSession && hasPermission) {
+ Service.createSession(externalId);
+ }
+
+ return {
+ hasSession,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/pads/content/component.jsx b/src/2.7.12/imports/ui/components/pads/content/component.jsx
new file mode 100644
index 00000000..37e43a5c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/content/component.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import Styled from './styles';
+
+const PadContent = ({
+ content,
+}) => {
+ const contentSplit = content.split('');
+ const contentStyle = `
+
+
+ `;
+ const contentWithStyle = [contentSplit[0], contentStyle, contentSplit[1]].join('');
+ return (
+
+
+
+ );
+};
+
+export default PadContent;
diff --git a/src/2.7.12/imports/ui/components/pads/content/container.jsx b/src/2.7.12/imports/ui/components/pads/content/container.jsx
new file mode 100644
index 00000000..c6b2087d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/content/container.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import PadContent from './component';
+import Service from '/imports/ui/components/pads/service';
+
+const Container = (props) => ;
+
+export default withTracker(({ externalId }) => {
+ const content = Service.getPadContent(externalId);
+
+ return {
+ content,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/pads/content/styles.js b/src/2.7.12/imports/ui/components/pads/content/styles.js
new file mode 100644
index 00000000..b1e1d571
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/content/styles.js
@@ -0,0 +1,54 @@
+import styled from 'styled-components';
+import {
+ colorGray,
+ colorGrayLightest
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const Wrapper = styled.div`
+ display: flex;
+ height: 100%;
+ position: relative;
+ width: 100%;
+`;
+
+const contentText = `
+font-family: Verdana, Arial, Helvetica, sans-serif;
+font-size: 15px;
+color: ${colorGray};
+bottom: 0;
+box-sizing: border-box;
+display: block;
+overflow-x: hidden;
+overflow-wrap: break-word;
+overflow-y: auto;
+padding-top: 1rem;
+position: absolute;
+right: 0;
+left:0;
+top: 0;
+white-space: normal;
+
+
+[dir="ltr"] & {
+ padding-left: 1rem;
+ padding-right: .5rem;
+}
+
+[dir="rtl"] & {
+ padding-left: .5rem;
+ padding-right: 1rem;
+}
+`;
+
+const Iframe = styled.iframe`
+ border-width: 0;
+ width: 100%;
+ border-top: 1px solid ${colorGrayLightest};
+ border-bottom: 1px solid ${colorGrayLightest};
+`;
+
+export default {
+ Wrapper,
+ Iframe,
+ contentText,
+};
diff --git a/src/2.7.12/imports/ui/components/pads/service.js b/src/2.7.12/imports/ui/components/pads/service.js
new file mode 100644
index 00000000..3847ae81
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/service.js
@@ -0,0 +1,143 @@
+import { throttle } from 'radash';
+import Pads, { PadsSessions, PadsUpdates } from '/imports/api/pads';
+import { makeCall } from '/imports/ui/services/api';
+import Auth from '/imports/ui/services/auth';
+import Settings from '/imports/ui/services/settings';
+import {
+ getVideoUrl,
+ stopWatching,
+} from '/imports/ui/components/external-video-player/service';
+import {
+ screenshareHasEnded,
+ isScreenBroadcasting,
+} from '/imports/ui/components/screenshare/service';
+
+const PADS_CONFIG = Meteor.settings.public.pads;
+const THROTTLE_TIMEOUT = 2000;
+
+const getLang = () => {
+ const { locale } = Settings.application;
+ return locale ? locale.toLowerCase() : '';
+};
+
+const getParams = () => {
+ const config = {};
+ config.lang = getLang();
+ config.rtl = document.documentElement.getAttribute('dir') === 'rtl';
+
+ const params = Object.keys(config)
+ .map((key) => `${key}=${encodeURIComponent(config[key])}`)
+ .join('&');
+ return params;
+};
+
+const getPadId = (externalId) => makeCall('getPadId', externalId);
+
+const createGroup = (externalId, model, name) => makeCall('createGroup', externalId, model, name);
+
+const hasPad = (externalId) => {
+ const pad = Pads.findOne(
+ {
+ meetingId: Auth.meetingID,
+ externalId,
+ },
+ );
+
+ return pad !== undefined;
+};
+
+const createSession = (externalId) => makeCall('createSession', externalId);
+
+const throttledCreateSession = throttle({ interval: THROTTLE_TIMEOUT }, createSession);
+
+const buildPadURL = (padId) => {
+ if (padId) {
+ const padsSessions = PadsSessions.findOne({});
+ if (padsSessions && padsSessions.sessions) {
+ const params = getParams();
+ const sessionIds = padsSessions.sessions.map((session) => Object.values(session)).join(',');
+ const url = Auth.authenticateURL(`${PADS_CONFIG.url}/auth_session?padName=${padId}&sessionID=${sessionIds}&${params}`);
+ return url;
+ }
+ }
+
+ return null;
+};
+
+const getRev = (externalId) => {
+ const updates = PadsUpdates.findOne(
+ {
+ meetingId: Auth.meetingID,
+ externalId,
+ }, { fields: { rev: 1 } },
+ );
+
+ return updates ? updates.rev : 0;
+};
+
+const getPadTail = (externalId) => {
+ const updates = PadsUpdates.findOne(
+ {
+ meetingId: Auth.meetingID,
+ externalId,
+ }, { fields: { tail: 1 } },
+ );
+
+ if (updates && updates.tail) return updates.tail;
+
+ return '';
+};
+
+const getPadContent = (externalId) => {
+ const updates = PadsUpdates.findOne(
+ {
+ meetingId: Auth.meetingID,
+ externalId,
+ }, { fields: { content: 1 } },
+ );
+
+ if (updates && updates.content) return updates.content;
+
+ return '';
+};
+
+const getPinnedPad = () => {
+ const pad = Pads.findOne({
+ meetingId: Auth.meetingID,
+ pinned: true,
+ }, {
+ fields: {
+ externalId: 1,
+ },
+ });
+
+ return pad;
+};
+
+const pinPad = (externalId, pinned) => {
+ if (pinned) {
+ // Stop external video sharing if it's running.
+ if (getVideoUrl()) stopWatching();
+
+ // Stop screen sharing if it's running.
+ if (isScreenBroadcasting()) screenshareHasEnded();
+ }
+
+ makeCall('pinPad', externalId, pinned);
+};
+
+const throttledPinPad = throttle({ interval: 1000 }, pinPad);
+
+export default {
+ getPadId,
+ createGroup,
+ hasPad,
+ createSession: (externalId) => throttledCreateSession(externalId),
+ buildPadURL,
+ getRev,
+ getPadTail,
+ getPadContent,
+ getParams,
+ getPinnedPad,
+ pinPad: throttledPinPad,
+};
diff --git a/src/2.7.12/imports/ui/components/pads/sessions/container.jsx b/src/2.7.12/imports/ui/components/pads/sessions/container.jsx
new file mode 100644
index 00000000..756d5345
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/sessions/container.jsx
@@ -0,0 +1,16 @@
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from './service';
+
+const Container = ({ sessions }) => {
+ Service.setCookie(sessions);
+
+ return null;
+};
+
+export default withTracker(() => {
+ const sessions = Service.getSessions();
+
+ return {
+ sessions,
+ };
+})(Container);
diff --git a/src/2.7.12/imports/ui/components/pads/sessions/service.js b/src/2.7.12/imports/ui/components/pads/sessions/service.js
new file mode 100644
index 00000000..d7504a4a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/sessions/service.js
@@ -0,0 +1,37 @@
+import { PadsSessions } from '/imports/api/pads';
+
+const COOKIE_CONFIG = Meteor.settings.public.pads.cookie;
+const PATH = COOKIE_CONFIG.path;
+const SAME_SITE = COOKIE_CONFIG.sameSite;
+const SECURE = COOKIE_CONFIG.secure;
+
+const getSessions = () => {
+ const padsSessions = PadsSessions.findOne({});
+
+ if (padsSessions) {
+ return padsSessions.sessions;
+ }
+
+ return [];
+};
+
+const hasSession = (externalId) => {
+ const padsSessions = PadsSessions.findOne({});
+
+ if (padsSessions && padsSessions.sessions) {
+ return padsSessions.sessions.some(session => session[externalId]);
+ }
+
+ return false;
+};
+
+const setCookie = (sessions) => {
+ const sessionIds = sessions.map(session => Object.values(session)).join(',');
+ document.cookie = `sessionID=${sessionIds}; path=${PATH}; SameSite=${SAME_SITE}; ${SECURE ? 'Secure' : ''}`;
+};
+
+export default {
+ getSessions,
+ hasSession,
+ setCookie,
+};
diff --git a/src/2.7.12/imports/ui/components/pads/styles.js b/src/2.7.12/imports/ui/components/pads/styles.js
new file mode 100644
index 00000000..9394b4fe
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/pads/styles.js
@@ -0,0 +1,50 @@
+import styled from 'styled-components';
+import {
+ smPaddingX,
+ lgPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorGray,
+ colorGrayLightest,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+
+const Hint = styled.span`
+ visibility: hidden;
+ position: absolute;
+ @media (pointer: none) {
+ visibility: visible;
+ position: relative;
+ color: ${colorGray};
+ font-size: ${fontSizeSmall};
+ font-style: italic;
+ padding: ${smPaddingX} 0 0 ${smPaddingX};
+ text-align: left;
+ [dir="rtl"] & {
+ padding-right: ${lgPaddingY} ${lgPaddingY} 0 0;
+ text-align: right;
+ }
+ }
+`;
+
+const Pad = styled.div`
+ display: flex;
+ height: 100%;
+ width: 100%;
+`;
+
+const IFrame = styled.iframe`
+ width: 100%;
+ height: auto;
+ overflow: hidden;
+ border-style: none;
+ border-bottom: 1px solid ${colorGrayLightest};
+
+ padding-bottom: 5px;
+`;
+
+export default {
+ Hint,
+ Pad,
+ IFrame,
+};
diff --git a/src/2.7.12/imports/ui/components/poll/component.jsx b/src/2.7.12/imports/ui/components/poll/component.jsx
new file mode 100644
index 00000000..f8d86a8d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/component.jsx
@@ -0,0 +1,1033 @@
+import React, { Component, createRef } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Session } from 'meteor/session';
+import Checkbox from '/imports/ui/components/common/checkbox/component';
+import DraggableTextArea from '/imports/ui/components/poll/dragAndDrop/component';
+import LiveResult from '/imports/ui/components/poll/live-result/component';
+import Styled from './styles';
+import Toggle from '/imports/ui/components/common/switch/component';
+import { PANELS, ACTIONS } from '../layout/enums';
+import { addNewAlert } from '../screenreader-alert/service';
+import Header from '/imports/ui/components/common/control-header/component';
+
+const intlMessages = defineMessages({
+ pollPaneTitle: {
+ id: 'app.poll.pollPaneTitle',
+ description: 'heading label for the poll menu',
+ },
+ closeLabel: {
+ id: 'app.poll.closeLabel',
+ description: 'label for poll pane close button',
+ },
+ hidePollDesc: {
+ id: 'app.poll.hidePollDesc',
+ description: 'aria label description for hide poll button',
+ },
+ quickPollInstruction: {
+ id: 'app.poll.quickPollInstruction',
+ description: 'instructions for using pre configured polls',
+ },
+ activePollInstruction: {
+ id: 'app.poll.activePollInstruction',
+ description: 'instructions displayed when a poll is active',
+ },
+ dragDropPollInstruction: {
+ id: 'app.poll.dragDropPollInstruction',
+ description: 'instructions for upload poll options via drag and drop',
+ },
+ ariaInputCount: {
+ id: 'app.poll.ariaInputCount',
+ description: 'aria label for custom poll input field',
+ },
+ customPlaceholder: {
+ id: 'app.poll.customPlaceholder',
+ description: 'custom poll input field placeholder text',
+ },
+ noPresentationSelected: {
+ id: 'app.poll.noPresentationSelected',
+ description: 'no presentation label',
+ },
+ clickHereToSelect: {
+ id: 'app.poll.clickHereToSelect',
+ description: 'open uploader modal button label',
+ },
+ questionErr: {
+ id: 'app.poll.questionErr',
+ description: 'question text area error label',
+ },
+ questionAndOptionsPlaceholder: {
+ id: 'app.poll.questionAndoptions.label',
+ description: 'poll input questions and options label',
+ },
+ customInputToggleLabel: {
+ id: 'app.poll.customInput.label',
+ description: 'poll custom input toogle button label',
+ },
+ customInputInstructionsLabel: {
+ id: 'app.poll.customInputInstructions.label',
+ description: 'poll custom input instructions label',
+ },
+ maxOptionsWarning: {
+ id: 'app.poll.maxOptionsWarning.label',
+ description: 'poll max options error',
+ },
+ optionErr: {
+ id: 'app.poll.optionErr',
+ description: 'poll input error label',
+ },
+ tf: {
+ id: 'app.poll.tf',
+ description: 'label for true / false poll',
+ },
+ a4: {
+ id: 'app.poll.a4',
+ description: 'label for A / B / C / D poll',
+ },
+ delete: {
+ id: 'app.poll.optionDelete.label',
+ description: '',
+ },
+ questionLabel: {
+ id: 'app.poll.question.label',
+ description: '',
+ },
+ optionalQuestionLabel: {
+ id: 'app.poll.optionalQuestion.label',
+ description: '',
+ },
+ userResponse: {
+ id: 'app.poll.userResponse.label',
+ description: '',
+ },
+ responseChoices: {
+ id: 'app.poll.responseChoices.label',
+ description: '',
+ },
+ typedResponseDesc: {
+ id: 'app.poll.typedResponse.desc',
+ description: '',
+ },
+ responseTypesLabel: {
+ id: 'app.poll.responseTypes.label',
+ description: '',
+ },
+ addOptionLabel: {
+ id: 'app.poll.addItem.label',
+ description: '',
+ },
+ startPollLabel: {
+ id: 'app.poll.start.label',
+ description: '',
+ },
+ secretPollLabel: {
+ id: 'app.poll.secretPoll.label',
+ description: '',
+ },
+ isSecretPollLabel: {
+ id: 'app.poll.secretPoll.isSecretLabel',
+ description: '',
+ },
+ true: {
+ id: 'app.poll.answer.true',
+ description: '',
+ },
+ false: {
+ id: 'app.poll.answer.false',
+ description: '',
+ },
+ a: {
+ id: 'app.poll.answer.a',
+ description: '',
+ },
+ b: {
+ id: 'app.poll.answer.b',
+ description: '',
+ },
+ c: {
+ id: 'app.poll.answer.c',
+ description: '',
+ },
+ d: {
+ id: 'app.poll.answer.d',
+ description: '',
+ },
+ e: {
+ id: 'app.poll.answer.e',
+ description: '',
+ },
+ yna: {
+ id: 'app.poll.yna',
+ description: '',
+ },
+ yes: {
+ id: 'app.poll.y',
+ description: '',
+ },
+ no: {
+ id: 'app.poll.n',
+ description: '',
+ },
+ abstention: {
+ id: 'app.poll.abstention',
+ description: '',
+ },
+ enableMultipleResponseLabel: {
+ id: 'app.poll.enableMultipleResponseLabel',
+ description: 'label for checkbox to enable multiple choice',
+ },
+ startPollDesc: {
+ id: 'app.poll.startPollDesc',
+ description: '',
+ },
+ showRespDesc: {
+ id: 'app.poll.showRespDesc',
+ description: '',
+ },
+ addRespDesc: {
+ id: 'app.poll.addRespDesc',
+ description: '',
+ },
+ deleteRespDesc: {
+ id: 'app.poll.deleteRespDesc',
+ description: '',
+ },
+ on: {
+ id: 'app.switch.onLabel',
+ description: 'label for toggle switch on state',
+ },
+ off: {
+ id: 'app.switch.offLabel',
+ description: 'label for toggle switch off state',
+ },
+ removePollOpt: {
+ id: 'app.poll.removePollOpt',
+ description: 'screen reader alert for removed poll option',
+ },
+ emptyPollOpt: {
+ id: 'app.poll.emptyPollOpt',
+ description: 'screen reader for blank poll option',
+ },
+ pollingQuestion: {
+ id: 'app.polling.pollQuestionTitle',
+ description: 'polling question header',
+ }
+});
+
+const POLL_SETTINGS = Meteor.settings.public.poll;
+
+const ALLOW_CUSTOM_INPUT = POLL_SETTINGS.allowCustomResponseInput;
+const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom;
+const MAX_INPUT_CHARS = POLL_SETTINGS.maxTypedAnswerLength;
+const MIN_OPTIONS_LENGTH = 2;
+const QUESTION_MAX_INPUT_CHARS = 1200;
+
+class Poll extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isPolling: false,
+ question: '',
+ questionAndOptions: '',
+ optList: [],
+ error: null,
+ isMultipleResponse: false,
+ secretPoll: false,
+ customInput: false,
+ warning: null,
+ isPasting: false,
+ type: null,
+ };
+
+ this.textarea = createRef();
+
+ this.handleBackClick = this.handleBackClick.bind(this);
+ this.handleAddOption = this.handleAddOption.bind(this);
+ this.handleRemoveOption = this.handleRemoveOption.bind(this);
+ this.handleTextareaChange = this.handleTextareaChange.bind(this);
+ this.handleInputChange = this.handleInputChange.bind(this);
+ this.toggleIsMultipleResponse = this.toggleIsMultipleResponse.bind(this);
+ this.displayToggleStatus = this.displayToggleStatus.bind(this);
+ this.displayAutoOptionToggleStatus = this.displayAutoOptionToggleStatus.bind(this);
+ this.setQuestionAndOptions = this.setQuestionAndOptions.bind(this);
+ }
+
+ componentDidMount() {
+ if (this.textarea.current) {
+ this.textarea.current.focus();
+ }
+ }
+
+ componentDidUpdate() {
+ const { amIPresenter, layoutContextDispatch, sidebarContentPanel } = this.props;
+
+ if (Session.equals('resetPollPanel', true)) {
+ this.handleBackClick();
+ }
+
+ if (!amIPresenter && sidebarContentPanel === PANELS.POLL) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ Session.set('secretPoll', false);
+ }
+
+ handleBackClick() {
+ const { stopPoll } = this.props;
+
+ this.setState({
+ isPolling: false,
+ error: null,
+ }, () => {
+ stopPoll();
+ Session.set('resetPollPanel', false);
+ document.activeElement.blur();
+ });
+ }
+
+ /**
+ *
+ * @param {Event} e
+ * @param {Number} index
+ */
+ handleInputChange(e, index) {
+ const { optList, type, error, questionAndOptions } = this.state;
+ const { pollTypes, validateInput } = this.props;
+ const list = [...optList];
+ const validatedVal = validateInput(e.target.value).replace(/\s{2,}/g, ' ');
+ const charsRemovedCount = e.target.value.length - validatedVal.length;
+ const clearError = validatedVal.length > 0 && type !== pollTypes.Response;
+ const input = e.target;
+ const caretStart = e.target.selectionStart;
+ const caretEnd = e.target.selectionEnd;
+ let questionAndOptionsList = [];
+ list[index] = { val: validatedVal };
+
+ if (questionAndOptions.length > 0) {
+ questionAndOptionsList = questionAndOptions.split('\n');
+ questionAndOptionsList[index + 1] = validatedVal;
+ }
+
+ this.setState({
+ optList: list,
+ questionAndOptions: questionAndOptionsList.length > 0
+ ? questionAndOptionsList.join('\n') : '',
+ error: clearError ? null : error,
+ }, () => {
+ input.focus();
+ input.selectionStart = caretStart - charsRemovedCount;
+ input.selectionEnd = caretEnd - charsRemovedCount;
+ });
+ }
+
+ /**
+ *
+ * @param {Event} e
+ * @returns {void}
+ */
+ handleTextareaChange(e) {
+ const { type, error, customInput } = this.state;
+ const { pollTypes, validateInput } = this.props;
+ const validatedInput = validateInput(e.target.value);
+ const clearError = validatedInput.length > 0 && type === pollTypes.Response;
+
+ if (!customInput) {
+ this.setState({
+ question: validatedInput,
+ error: clearError ? null : error,
+ });
+ } else {
+ this.setQuestionAndOptions(validatedInput);
+ }
+ }
+
+ /**
+ *
+ * @param {String} input Validated string containing question and options.
+ * @returns {void}
+ */
+ setQuestionAndOptions(input) {
+ const { intl, pollTypes, getSplittedQuestionAndOptions } = this.props;
+ const { warning, optList, isPasting, type, error } = this.state;
+ const { splittedQuestion, optionsList } = getSplittedQuestionAndOptions(input);
+ const optionsListLength = optionsList.length;
+ let maxOptionsWarning = warning;
+ const clearWarning = maxOptionsWarning && optionsListLength <= MAX_CUSTOM_FIELDS;
+ const clearError = input.length > 0 && type === pollTypes.Response;
+
+ if (optionsListLength > MAX_CUSTOM_FIELDS && optList[MAX_CUSTOM_FIELDS] === undefined) {
+ this.setState({ warning: intl.formatMessage(intlMessages.maxOptionsWarning) });
+ if (!isPasting) return null;
+ maxOptionsWarning = intl.formatMessage(intlMessages.maxOptionsWarning);
+ this.setState({ isPasting: false });
+ }
+
+ this.setState({
+ questionAndOptions: input,
+ optList: optionsList,
+ question: splittedQuestion,
+ error: clearError ? null : error,
+ warning: clearWarning ? null : maxOptionsWarning,
+ });
+ }
+
+ handlePollValuesText(text) {
+ const { validateInput } = this.props;
+ if (text && text.length > 0) {
+ const validatedInput = validateInput(text);
+ this.setQuestionAndOptions(validatedInput);
+ }
+ }
+
+ /**
+ *
+ * @param {Number} index
+ */
+ handleRemoveOption(index) {
+ const { intl } = this.props;
+ const { optList, questionAndOptions, customInput, warning } = this.state;
+ const list = [...optList];
+ const removed = list[index];
+ let questionAndOptionsList = [];
+ let clearWarning = false;
+
+ list.splice(index, 1);
+
+ // If customInput then removing text from input field.
+ if (customInput) {
+ questionAndOptionsList = questionAndOptions.split('\n');
+ delete questionAndOptionsList[index + 1];
+ questionAndOptionsList = questionAndOptionsList.filter((val) => val !== undefined);
+ clearWarning = warning && list.length <= MAX_CUSTOM_FIELDS;
+ }
+
+ this.setState({
+ optList: list,
+ questionAndOptions: questionAndOptionsList.length > 0
+ ? questionAndOptionsList.join('\n')
+ : [],
+ warning: clearWarning ? null : warning,
+ }, () => {
+ addNewAlert(`${intl.formatMessage(intlMessages.removePollOpt,
+ { 0: removed.val || intl.formatMessage(intlMessages.emptyPollOpt) })}`);
+ });
+ }
+
+ handleAddOption() {
+ const { optList } = this.state;
+ this.setState({ optList: [...optList, { val: '' }] });
+ }
+
+ handleToggle() {
+ const { secretPoll } = this.state;
+ const toggledValue = !secretPoll;
+ Session.set('secretPoll', toggledValue);
+ this.setState({ secretPoll: toggledValue });
+ }
+
+ handleAutoOptionToogle() {
+ const { customInput, questionAndOptions, question } = this.state;
+ const { intl, removeEmptyLineSpaces, getSplittedQuestionAndOptions } = this.props;
+ const toggledValue = !customInput;
+
+ if (customInput === true && toggledValue === false) {
+ const questionAndOptionsList = removeEmptyLineSpaces(questionAndOptions);
+ this.setState({
+ question: questionAndOptionsList.join('\n'),
+ customInput: toggledValue,
+ optList: [],
+ type: null,
+ });
+ } else {
+ const inputList = removeEmptyLineSpaces(question);
+ const { splittedQuestion, optionsList } = getSplittedQuestionAndOptions(inputList);
+ const clearWarning = optionsList.length > MAX_CUSTOM_FIELDS
+ ? intl.formatMessage(intlMessages.maxOptionsWarning) : null;
+ this.handlePollLetterOptions();
+ this.setState({
+ questionAndOptions: inputList.join('\n'),
+ optList: optionsList,
+ customInput: toggledValue,
+ question: splittedQuestion,
+ warning: clearWarning,
+ });
+ }
+ }
+
+ handlePollLetterOptions() {
+ const { pollTypes } = this.props;
+ const { optList } = this.state;
+
+ if (optList.length === 0) {
+ this.setState({
+ type: pollTypes.Letter,
+ optList: [
+ { val: '' },
+ { val: '' },
+ { val: '' },
+ { val: '' },
+ ],
+ });
+ }
+ }
+
+ toggleIsMultipleResponse() {
+ const { isMultipleResponse } = this.state;
+ return this.setState({ isMultipleResponse: !isMultipleResponse });
+ }
+
+ /**
+ *
+ * @param {Boolean} status
+ * @returns
+ */
+ displayToggleStatus(status) {
+ const { intl } = this.props;
+
+ return (
+
+ {status
+ ? intl.formatMessage(intlMessages.on)
+ : intl.formatMessage(intlMessages.off)}
+
+ );
+ }
+
+ displayAutoOptionToggleStatus(status) {
+ const { intl } = this.props;
+
+ return (
+
+ {status
+ ? intl.formatMessage(intlMessages.on)
+ : intl.formatMessage(intlMessages.off)}
+
+ );
+ }
+
+ renderInputs() {
+ const { intl, pollTypes } = this.props;
+ const { optList, type, error } = this.state;
+ let hasVal = false;
+ return optList.slice(0, MAX_CUSTOM_FIELDS).map((o, i) => {
+ const pollOptionKey = `poll-option-${i}`;
+ if (o.val && o.val.length > 0) hasVal = true;
+ return (
+
+
+ this.handleInputChange(e, i)}
+ maxLength={MAX_INPUT_CHARS}
+ onPaste={(e) => { e.stopPropagation(); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ />
+ {optList.length > MIN_OPTIONS_LENGTH && (
+ {
+ this.handleRemoveOption(i);
+ }}
+ />
+ )}
+
+ {intl.formatMessage(
+ intlMessages.deleteRespDesc,
+ { 0: o.val || intl.formatMessage(intlMessages.emptyPollOpt) },
+ )}
+
+
+ {!hasVal && type !== pollTypes.Response && error ? (
+ {error}
+ ) : (
+
+ )}
+
+ );
+ });
+ }
+
+ renderActivePollOptions() {
+ const {
+ intl,
+ isMeteorConnected,
+ stopPoll,
+ currentPoll,
+ pollAnswerIds,
+ usernames,
+ isDefaultPoll,
+ } = this.props;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.activePollInstruction)}
+
+
+
+ );
+ }
+
+ renderStartPollButton() {
+ const {
+ startPoll, startCustomPoll, intl, pollTypes, checkPollType,
+ } = this.props;
+ const {
+ type, secretPoll, optList, isMultipleResponse, question,
+ } = this.state;
+ return (
+ {
+ const optionsList = optList.slice(0, MAX_CUSTOM_FIELDS);
+ let hasVal = false;
+ optionsList.forEach((o) => {
+ if (o.val.trim().length > 0) hasVal = true;
+ });
+
+ let err = null;
+ if (type === pollTypes.Response && question.length === 0) {
+ err = intl.formatMessage(intlMessages.questionErr);
+ }
+ if (!hasVal && type !== pollTypes.Response) {
+ err = intl.formatMessage(intlMessages.optionErr);
+ }
+ if (err) return this.setState({ error: err });
+
+ return this.setState({ isPolling: true }, () => {
+ const verifiedPollType = checkPollType(
+ type,
+ optionsList,
+ intl.formatMessage(intlMessages.yes),
+ intl.formatMessage(intlMessages.no),
+ intl.formatMessage(intlMessages.abstention),
+ intl.formatMessage(intlMessages.true),
+ intl.formatMessage(intlMessages.false),
+ );
+ const verifiedOptions = optionsList.map((o) => {
+ if (o.val.trim().length > 0) return o.val;
+ return null;
+ });
+ if (verifiedPollType === pollTypes.Custom) {
+ startCustomPoll(
+ verifiedPollType,
+ secretPoll,
+ question,
+ isMultipleResponse,
+ verifiedOptions.filter(Boolean),
+ );
+ } else {
+ startPoll(verifiedPollType, secretPoll, question, isMultipleResponse);
+ }
+ });
+ }}
+ />
+ );
+ }
+
+ renderResponseArea() {
+ const { intl, pollTypes, isDefaultPoll } = this.props;
+ const { type, secretPoll, optList, isMultipleResponse } = this.state;
+ const defaultPoll = isDefaultPoll(type);
+ if (defaultPoll || type === pollTypes.Response) return (
+
+ {defaultPoll && (
+
+
+
+
+
+ {intl.formatMessage(intlMessages.enableMultipleResponseLabel)}
+
+
+ )}
+ {defaultPoll && this.renderInputs()}
+ {defaultPoll && (
+ = MAX_CUSTOM_FIELDS}
+ onClick={() => this.handleAddOption()}
+ />
+ )}
+
+
+
+ {intl.formatMessage(intlMessages.secretPollLabel)}
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {this.displayToggleStatus(secretPoll)}
+ this.handleToggle()}
+ ariaLabel={intl.formatMessage(intlMessages.secretPollLabel)}
+ showToggleLabel={false}
+ data-test="anonymousPollBtn"
+ />
+
+
+
+ {secretPoll && (
+
+ {intl.formatMessage(intlMessages.isSecretPollLabel)}
+
+ )}
+ {this.renderStartPollButton()}
+
+ );
+ return null;
+ }
+
+ renderCustomInputRow() {
+ const { intl } = this.props;
+ const { customInput } = this.state;
+ return (
+ <>
+
+
+
+ {intl.formatMessage(intlMessages.customInputToggleLabel)}
+
+
+
+
+ {this.displayAutoOptionToggleStatus(customInput)}
+ this.handleAutoOptionToogle()}
+ ariaLabel={intl.formatMessage(intlMessages.customInputToggleLabel)}
+ showToggleLabel={false}
+ data-test="autoOptioningPollBtn"
+ />
+
+
+
+ {customInput && (
+
+ {intl.formatMessage(intlMessages.customInputInstructionsLabel)}
+
+ )}
+ >
+ );
+ }
+
+ renderPollQuestionArea() {
+ const { intl, pollTypes } = this.props;
+ const {
+ type, optList, questionAndOptions, error,
+ question, customInput, warning,
+ } = this.state;
+ const hasOptionError = (customInput && optList.length === 0 && error);
+ const hasWarning = (customInput && warning);
+ const hasQuestionError = (type === pollTypes.Response
+ && questionAndOptions.length === 0 && error);
+ const questionsAndOptionsPlaceholder = intlMessages.questionAndOptionsPlaceholder;
+ const questionPlaceholder = (type === pollTypes.Response)
+ ? intlMessages.questionLabel
+ : intlMessages.optionalQuestionLabel;
+ return (
+
+ this.handleTextareaChange(e)}
+ onPaste={(e) => { e.stopPropagation(); this.setState({ isPasting: true }); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ onKeyPress={(event) => {
+ if (event.key === 'Enter' && customInput) {
+ this.handlePollLetterOptions();
+ }
+ }}
+ rows="5"
+ cols="35"
+ maxLength={QUESTION_MAX_INPUT_CHARS}
+ aria-label={intl.formatMessage(customInput ? questionsAndOptionsPlaceholder
+ : questionPlaceholder)}
+ placeholder={intl.formatMessage(customInput ? questionsAndOptionsPlaceholder
+ : questionPlaceholder)}
+ {...{ MAX_INPUT_CHARS }}
+ handlePollValuesText={(e) => this.handlePollValuesText(e)}
+ as={customInput ? DraggableTextArea : 'textarea'}
+ ref={this.textarea}
+ />
+ {hasQuestionError || hasOptionError ? (
+ {error}
+ ) : (
+
+ )}
+ {hasWarning ? (
+ {warning}
+ ) : (
+
+ )}
+
+ );
+ }
+
+ renderResponseTypes() {
+ const { intl, pollTypes, smallSidebar } = this.props;
+ const { type, customInput } = this.state;
+ if (!customInput) return (
+
+
+ {intl.formatMessage(intlMessages.responseTypesLabel)}
+
+
+ {
+ this.setState({
+ type: pollTypes.TrueFalse,
+ optList: [
+ { val: intl.formatMessage(intlMessages.true) },
+ { val: intl.formatMessage(intlMessages.false) },
+ ],
+ });
+ }}
+ />
+ {
+ if (!customInput) {
+ this.setState({
+ type: pollTypes.Letter,
+ optList: [
+ { val: intl.formatMessage(intlMessages.a) },
+ { val: intl.formatMessage(intlMessages.b) },
+ { val: intl.formatMessage(intlMessages.c) },
+ { val: intl.formatMessage(intlMessages.d) },
+ ],
+ });
+ }
+ }}
+ />
+ {
+ this.setState({
+ type: pollTypes.YesNoAbstention,
+ optList: [
+ { val: intl.formatMessage(intlMessages.yes) },
+ { val: intl.formatMessage(intlMessages.no) },
+ { val: intl.formatMessage(intlMessages.abstention) },
+ ],
+ });
+ }}
+ />
+ { this.setState({ type: pollTypes.Response }); }}
+ />
+
+
+ );
+ return null;
+ }
+
+ renderResponseChoices() {
+ const { intl, pollTypes } = this.props;
+ const { type, questionAndOptions, question, customInput } = this.state;
+ if ((!customInput && type) || (questionAndOptions && customInput)) return (
+
+ {customInput && questionAndOptions && (
+
+
+ {intl.formatMessage(intlMessages.pollingQuestion)}
+
+
+ {question}
+
+
+ )}
+
+ {intl.formatMessage(intlMessages.responseChoices)}
+
+ {type === pollTypes.Response && (
+
+ {intl.formatMessage(intlMessages.typedResponseDesc)}
+
+ )}
+ {this.renderResponseArea()}
+
+ );
+ return null;
+ }
+
+ renderPollOptions() {
+ return (
+
+ {ALLOW_CUSTOM_INPUT && this.renderCustomInputRow()}
+ {this.renderPollQuestionArea()}
+ {this.renderResponseTypes()}
+ {this.renderResponseChoices()}
+
+ );
+ }
+
+ renderNoSlidePanel() {
+ const { intl } = this.props;
+ return (
+
+
+ {intl.formatMessage(intlMessages.noPresentationSelected)}
+
+ Session.set('showUploadPresentationView', true)}
+ />
+
+ );
+ }
+
+ renderPollPanel() {
+ const { isPolling } = this.state;
+ const {
+ currentPoll,
+ currentSlide,
+ } = this.props;
+
+ if (!currentSlide) return this.renderNoSlidePanel();
+ if (isPolling || currentPoll) {
+ return this.renderActivePollOptions();
+ }
+
+ return this.renderPollOptions();
+ }
+
+ render() {
+ const {
+ intl,
+ stopPoll,
+ currentPoll,
+ layoutContextDispatch,
+ } = this.props;
+
+ return (
+
+ {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ },
+ }}
+ rightButtonProps={{
+ 'aria-label': `${intl.formatMessage(intlMessages.closeLabel)} ${intl.formatMessage(intlMessages.pollPaneTitle)}`,
+ 'data-test': "closePolling",
+ icon: "close",
+ label: intl.formatMessage(intlMessages.closeLabel),
+ onClick: () => {
+ if (currentPoll) stopPoll();
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ Session.set('forcePollOpen', false);
+ Session.set('pollInitiated', false);
+ },
+ }}
+ />
+ {this.renderPollPanel()}
+ {intl.formatMessage(intlMessages.showRespDesc)}
+ {intl.formatMessage(intlMessages.addRespDesc)}
+ {intl.formatMessage(intlMessages.startPollDesc)}
+
+ );
+ }
+}
+
+export default injectIntl(Poll);
+
+Poll.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ amIPresenter: PropTypes.bool.isRequired,
+ pollTypes: PropTypes.instanceOf(Object).isRequired,
+ startPoll: PropTypes.func.isRequired,
+ startCustomPoll: PropTypes.func.isRequired,
+ stopPoll: PropTypes.func.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/poll/container.jsx b/src/2.7.12/imports/ui/components/poll/container.jsx
new file mode 100644
index 00000000..d5fab90e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/container.jsx
@@ -0,0 +1,76 @@
+import React, { useContext } from 'react';
+import { makeCall } from '/imports/ui/services/api';
+import { withTracker } from 'meteor/react-meteor-data';
+import Presentations from '/imports/api/presentations';
+import PresentationService from '/imports/ui/components/presentation/service';
+import Poll from '/imports/ui/components/poll/component';
+import { Session } from 'meteor/session';
+import Service from './service';
+import Auth from '/imports/ui/services/auth';
+import { UsersContext } from '../components-data/users-context/context';
+import { layoutDispatch, layoutSelectInput } from '../layout/context';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
+
+const PollContainer = ({ ...props }) => {
+ const layoutContextDispatch = layoutDispatch();
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+
+ const usernames = {};
+
+ Object.values(users[Auth.meetingID]).forEach((user) => {
+ usernames[user.userId] = { userId: user.userId, name: user.name };
+ });
+
+ return (
+
+ );
+};
+
+export default withTracker(({ amIPresenter }) => {
+ const isPollSecret = Session.get('secretPoll') || false;
+ const currentPresentation = Presentations.findOne({
+ current: true,
+ }, { fields: { podId: 1 } }) || {};
+
+ Meteor.subscribe('current-poll', isPollSecret, amIPresenter);
+
+ const currentSlide = PresentationService.getCurrentSlide(currentPresentation.podId);
+
+ const pollId = currentSlide ? currentSlide.id : PUBLIC_CHAT_KEY;
+
+ const { pollTypes } = Service;
+
+ const startPoll = (type, secretPoll, question = '', isMultipleResponse) => makeCall('startPoll', pollTypes, type, pollId, secretPoll, question, isMultipleResponse);
+
+ const startCustomPoll = (type, secretPoll, question = '', isMultipleResponse, answers) => makeCall('startPoll', pollTypes, type, pollId, secretPoll, question, isMultipleResponse, answers);
+
+ const stopPoll = () => makeCall('stopPoll');
+
+ return {
+ isPollSecret,
+ currentSlide,
+ pollTypes,
+ startPoll,
+ startCustomPoll,
+ stopPoll,
+ publishPoll: Service.publishPoll,
+ currentPoll: Service.currentPoll(),
+ isDefaultPoll: Service.isDefaultPoll,
+ checkPollType: Service.checkPollType,
+ resetPollPanel: Session.get('resetPollPanel') || false,
+ pollAnswerIds: Service.pollAnswerIds,
+ isMeteorConnected: Meteor.status().connected,
+ validateInput: Service.validateInput,
+ removeEmptyLineSpaces: Service.removeEmptyLineSpaces,
+ getSplittedQuestionAndOptions: Service.getSplittedQuestionAndOptions,
+ };
+})(PollContainer);
diff --git a/src/2.7.12/imports/ui/components/poll/dragAndDrop/component.jsx b/src/2.7.12/imports/ui/components/poll/dragAndDrop/component.jsx
new file mode 100644
index 00000000..e87b240c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/dragAndDrop/component.jsx
@@ -0,0 +1,122 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+// src: https://medium.com/@650egor/simple-drag-and-drop-file-upload-in-react-2cb409d88929
+
+class DragAndDrop extends Component {
+ static handleDrag(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ drag: false,
+ pollValueText: '',
+ };
+
+ this.dropRef = React.createRef();
+ }
+
+ componentDidMount() {
+ this.dragCounter = 0;
+ const div = this.dropRef.current;
+ div.addEventListener('dragenter', e => this.handleDragIn(e));
+ div.addEventListener('dragleave', e => this.handleDragOut(e));
+ div.addEventListener('dragover', e => DragAndDrop.handleDrag(e));
+ div.addEventListener('drop', e => this.handleDrop(e));
+ }
+
+ componentWillUnmount() {
+ const div = this.dropRef.current;
+ div.removeEventListener('dragenter', e => this.handleDragIn(e));
+ div.removeEventListener('dragleave', e => this.handleDragOut(e));
+ div.removeEventListener('dragover', e => DragAndDrop.handleDrag(e));
+ div.removeEventListener('drop', e => this.handleDrop(e));
+ }
+
+ setPollValues() {
+ const { pollValueText } = this.state;
+ const { handlePollValuesText } = this.props;
+ if (pollValueText) {
+ handlePollValuesText(pollValueText);
+ }
+ }
+
+ setPollValuesFromFile(file) {
+ const reader = new FileReader();
+ reader.onload = async (e) => {
+ const text = e.target.result;
+ this.setPollValueText(text);
+ this.setPollValues();
+ };
+ reader.readAsText(file);
+ }
+
+ setPollValueText(pollText) {
+ const { MAX_INPUT_CHARS } = this.props;
+ const arr = pollText.split('\n');
+ const text = arr.map(line => (line.length > MAX_INPUT_CHARS ? line.substring(0, MAX_INPUT_CHARS) : line)).join('\n');
+ this.setState({ pollValueText: text });
+ }
+
+ handleTextInput(e) {
+ this.setPollValueText(e.target.value);
+ }
+
+ handleDragIn(e) {
+ DragAndDrop.handleDrag(e);
+ this.dragCounter += 1;
+ if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
+ this.setState({ drag: true });
+ }
+ }
+
+ handleDragOut(e) {
+ DragAndDrop.handleDrag(e);
+ this.dragCounter -= 1;
+ if (this.dragCounter > 0) return;
+ this.setState({ drag: false });
+ }
+
+ handleDrop(e) {
+ DragAndDrop.handleDrag(e);
+ this.setState({ drag: false });
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
+ this.setPollValuesFromFile(e.dataTransfer.files[0]);
+ this.dragCounter = 0;
+ }
+ }
+
+ getCleanProps() {
+ const props = Object.assign({}, this.props);
+ const propsToDelete = ['MAX_INPUT_CHARS', 'handlePollValuesText'];
+
+ propsToDelete.forEach((prop) => {
+ delete props[prop];
+ });
+
+ return props;
+ }
+
+ render() {
+ const { drag } = this.state;
+ return (
+
+ );
+ }
+}
+
+DragAndDrop.propTypes = {
+ MAX_INPUT_CHARS: PropTypes.number.isRequired,
+ handlePollValuesText: PropTypes.func.isRequired,
+};
+
+export default DragAndDrop;
diff --git a/src/2.7.12/imports/ui/components/poll/dragAndDrop/styles.js b/src/2.7.12/imports/ui/components/poll/dragAndDrop/styles.js
new file mode 100644
index 00000000..fae9ff5e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/dragAndDrop/styles.js
@@ -0,0 +1,19 @@
+import styled from 'styled-components';
+import {
+ colorGrayLighter,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const DndTextArea = styled.textarea`
+ ${({ active }) => active && `
+ background: ${colorGrayLighter};
+ `}
+
+ ${({ active }) => !active && `
+ background: ${colorWhite};
+ `}
+`;
+
+export default {
+ DndTextArea,
+};
diff --git a/src/2.7.12/imports/ui/components/poll/live-result/component.jsx b/src/2.7.12/imports/ui/components/poll/live-result/component.jsx
new file mode 100644
index 00000000..ba41a4ff
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/live-result/component.jsx
@@ -0,0 +1,286 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import caseInsensitiveReducer from '/imports/utils/caseInsensitiveReducer';
+import { Session } from 'meteor/session';
+import Styled from './styles';
+import Service from './service';
+import Settings from '/imports/ui/services/settings';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const intlMessages = defineMessages({
+ usersTitle: {
+ id: 'app.poll.liveResult.usersTitle',
+ description: 'heading label for poll users',
+ },
+ responsesTitle: {
+ id: 'app.poll.liveResult.responsesTitle',
+ description: 'heading label for poll responses',
+ },
+ publishLabel: {
+ id: 'app.poll.publishLabel',
+ description: 'label for the publish button',
+ },
+ cancelPollLabel: {
+ id: 'app.poll.cancelPollLabel',
+ description: 'label for cancel poll button',
+ },
+ backLabel: {
+ id: 'app.poll.backLabel',
+ description: 'label for the return to poll options button',
+ },
+ doneLabel: {
+ id: 'app.createBreakoutRoom.doneLabel',
+ description: 'label shown when all users have responded',
+ },
+ waitingLabel: {
+ id: 'app.poll.waitingLabel',
+ description: 'label shown while waiting for responses',
+ },
+ secretPollLabel: {
+ id: 'app.poll.liveResult.secretLabel',
+ description: 'label shown instead of users in poll responses if poll is secret',
+ },
+});
+
+const getResponseString = (obj) => {
+ const { children } = obj.props;
+ if (typeof children !== 'string') {
+ return getResponseString(children[1]);
+ }
+
+ return children;
+};
+
+class LiveResult extends PureComponent {
+ static getDerivedStateFromProps(nextProps) {
+ const {
+ currentPoll, intl, pollAnswerIds, usernames, isDefaultPoll,
+ } = nextProps;
+
+ if (!currentPoll) return null;
+
+ const {
+ answers, responses, users, numResponders, pollType
+ } = currentPoll;
+
+ const defaultPoll = isDefaultPoll(pollType);
+
+ const currentPollQuestion = (currentPoll.question) ? currentPoll.question : '';
+
+ let userAnswers = responses
+ ? [...users, ...responses.map(u => u.userId)]
+ : [...users];
+
+ userAnswers = userAnswers.map(id => usernames[id])
+ .filter((user) => user)
+ .map((user) => {
+ let answer = '';
+
+ if (responses) {
+ const response = responses.find(r => r.userId === user.userId);
+ if (response) {
+ const formattedAnswers = [];
+ response.answerIds.forEach((answerId) => {
+ const formattedMessageIndex = answers[answerId]?.key?.toLowerCase();
+ const formattedAnswer = defaultPoll && pollAnswerIds[formattedMessageIndex]
+ ? intl.formatMessage(pollAnswerIds[formattedMessageIndex])
+ : answers[answerId].key;
+
+ formattedAnswers.push(formattedAnswer);
+ });
+ answer = formattedAnswers.join(', ');
+ }
+ }
+
+ return {
+ name: user.name,
+ answer,
+ };
+ })
+ .sort(Service.sortUsers)
+ .reduce((acc, user) => {
+ return ([
+ ...acc,
+ (
+
+ {user.name}
+
+ {user.answer}
+
+
+ ),
+ ]);
+ }, []);
+
+ const pollStats = [];
+
+ answers.reduce(caseInsensitiveReducer, []).map((obj) => {
+ const formattedMessageIndex = obj?.key?.toLowerCase();
+ const pct = Math.round(obj.numVotes / numResponders * 100);
+ const pctFotmatted = `${Number.isNaN(pct) ? 0 : pct}%`;
+
+ const calculatedWidth = {
+ width: pctFotmatted,
+ };
+
+ return pollStats.push(
+
+
+ {
+ defaultPoll && pollAnswerIds[formattedMessageIndex]
+ ? intl.formatMessage(pollAnswerIds[formattedMessageIndex])
+ : obj.key
+ }
+
+
+
+ {obj.numVotes || 0}
+
+
+ {pctFotmatted}
+
+ ,
+ );
+ });
+
+ return {
+ userAnswers,
+ pollStats,
+ currentPollQuestion,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ userAnswers: null,
+ pollStats: null,
+ currentPollQuestion: null,
+ };
+ }
+
+ render() {
+ const {
+ isMeteorConnected,
+ intl,
+ stopPoll,
+ handleBackClick,
+ currentPoll,
+ } = this.props;
+
+ const { userAnswers, pollStats, currentPollQuestion } = this.state;
+ const { animations } = Settings.application;
+
+ let waiting;
+ let userCount = 0;
+ let respondedCount = 0;
+
+ if (userAnswers) {
+ userCount = userAnswers.length;
+ userAnswers.map((user) => {
+ const response = getResponseString(user);
+ if (response === '') return user;
+ respondedCount += 1;
+ return user;
+ });
+
+ waiting = respondedCount !== userAnswers.length && currentPoll;
+ }
+
+ return (
+
+
+ {currentPollQuestion ? {currentPollQuestion} : null}
+
+ {waiting
+ ? (
+
+ {`${intl.formatMessage(intlMessages.waitingLabel, {
+ 0: respondedCount,
+ 1: userCount,
+ })} `}
+
+ )
+ : {intl.formatMessage(intlMessages.doneLabel)} }
+ {waiting
+ ? : null}
+
+ {pollStats}
+
+ {currentPoll && currentPoll.answers.length >= 0
+ ? (
+
+ {
+ Session.set('pollInitiated', false);
+ Service.publishPoll();
+ stopPoll();
+ }}
+ label={intl.formatMessage(intlMessages.publishLabel)}
+ data-test="publishPollingLabel"
+ color="primary"
+ />
+ {
+ Session.set('pollInitiated', false);
+ Session.set('resetPollPanel', true);
+ stopPoll();
+ }}
+ label={intl.formatMessage(intlMessages.cancelPollLabel)}
+ data-test="cancelPollLabel"
+ />
+
+ ) : (
+
{
+ handleBackClick();
+ }}
+ label={intl.formatMessage(intlMessages.backLabel)}
+ color="primary"
+ data-test="restartPoll"
+ />
+ )
+ }
+
+ { currentPoll && !currentPoll.secretPoll
+ ? (
+
+
+
+ {intl.formatMessage(intlMessages.usersTitle)}
+ {intl.formatMessage(intlMessages.responsesTitle)}
+
+ {userAnswers}
+
+
+ ) : (
+ currentPoll ? ({intl.formatMessage(intlMessages.secretPollLabel)}
) : null
+ )}
+
+ );
+ }
+}
+
+export default injectIntl(LiveResult);
+
+LiveResult.defaultProps = { currentPoll: null };
+
+LiveResult.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ currentPoll: PropTypes.oneOfType([
+ PropTypes.arrayOf(Object),
+ PropTypes.shape({
+ answers: PropTypes.arrayOf(PropTypes.object),
+ users: PropTypes.arrayOf(PropTypes.string),
+ }),
+ ]),
+ stopPoll: PropTypes.func.isRequired,
+ handleBackClick: PropTypes.func.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/poll/live-result/service.js b/src/2.7.12/imports/ui/components/poll/live-result/service.js
new file mode 100644
index 00000000..d5fca752
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/live-result/service.js
@@ -0,0 +1,38 @@
+import { makeCall } from '/imports/ui/services/api';
+
+const sortUsers = (a, b) => {
+ const sortByResponse = (a, b) => {
+ const DEFAULT_CHAR = '-';
+ const _a = a.answer.toLowerCase();
+ const _b = b.answer.toLowerCase();
+ const isDefault = (_a === DEFAULT_CHAR || _b === DEFAULT_CHAR);
+
+ if (_a < _b || isDefault) {
+ return -1;
+ } if (_a > _b) {
+ return 1;
+ }
+ return 0;
+ };
+
+ const sortByName = (a, b) => {
+ const _a = a.name.toLowerCase();
+ const _b = b.name.toLowerCase();
+
+ if (_a < _b) {
+ return -1;
+ } if (_a > _b) {
+ return 1;
+ }
+ return 0;
+ };
+
+ let sort = sortByResponse(a, b);
+ if (sort === 0) sort = sortByName(a, b);
+ return sort;
+};
+
+export default {
+ sortUsers,
+ publishPoll: () => makeCall('publishPoll'),
+};
diff --git a/src/2.7.12/imports/ui/components/poll/live-result/styles.js b/src/2.7.12/imports/ui/components/poll/live-result/styles.js
new file mode 100644
index 00000000..3ade99da
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/live-result/styles.js
@@ -0,0 +1,228 @@
+import styled, { css, keyframes } from 'styled-components';
+import {
+ colorGrayLightest,
+ colorText,
+ colorGrayLighter,
+ pollStatsBorderColor,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ smPaddingX,
+ smPaddingY,
+ mdPaddingX,
+ pollStatsElementWidth,
+ pollSmMargin,
+ pollResultWidth,
+ borderSizeLarge,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import Button from '/imports/ui/components/common/button/component';
+
+const ResultLeft = styled.td`
+ padding: 0 .5rem 0 0;
+ border-bottom: 1px solid ${colorGrayLightest};
+
+ [dir="rtl"] & {
+ padding: 0 0 0 .5rem;
+ }
+ padding-bottom: .25rem;
+ word-break: break-all;
+`;
+
+const ResultRight = styled.td`
+ padding-bottom: .25rem;
+ word-break: break-all;
+`;
+
+const Main = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const Left = styled.div`
+ font-weight: bold;
+ max-width: ${pollResultWidth};
+ min-width: ${pollStatsElementWidth};
+ word-wrap: break-word;
+ flex: 6;
+
+ padding: ${smPaddingY};
+ margin-top: ${pollSmMargin};
+ margin-bottom: ${pollSmMargin};
+ color: ${colorText};
+
+ position: relative;
+`;
+
+const Center = styled.div`
+ position: relative;
+ flex: 3;
+ border-left: 1px solid ${colorGrayLighter};
+ border-right : none;
+ width: 100%;
+ height: 100%;
+
+ [dir="rtl"] & {
+ border-left: none;
+ border-right: 1px solid ${colorGrayLighter};
+ }
+
+ padding: ${smPaddingY};
+ margin-top: ${pollSmMargin};
+ margin-bottom: ${pollSmMargin};
+ color: ${colorText};
+`;
+
+const Right = styled.div`
+ text-align: right;
+ max-width: ${pollStatsElementWidth};
+ min-width: ${pollStatsElementWidth};
+ flex: 1;
+
+ [dir="rtl"] & {
+ text-align: left;
+ }
+
+ padding: ${smPaddingY};
+ margin-top: ${pollSmMargin};
+ margin-bottom: ${pollSmMargin};
+ color: ${colorText};
+
+ position: relative;
+`;
+
+const BarShade = styled.div`
+ background-color: ${colorGrayLighter};
+ height: 100%;
+ min-height: 100%;
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ right: 0;
+`;
+
+const BarVal = styled.div`
+ position: inherit;
+`;
+
+const Stats = styled.div`
+ margin-bottom: ${smPaddingX};
+ display: flex;
+ flex-direction: column;
+ border: 1px solid ${pollStatsBorderColor};
+ border-radius: ${borderSizeLarge};
+ padding: ${mdPaddingX};
+
+ & > div {
+ display: flex;
+ flex-direction: row;
+
+ & > div:nth-child(even) {
+ position: relative;
+ height: 75%;
+ width: 50%;
+ text-align: center;
+ }
+ }
+`;
+
+const Title = styled.span`
+ font-weight: bold;
+ word-break: break-all;
+ white-space: pre-wrap;
+`;
+
+const Status = styled.div`
+ margin-bottom: .5rem;
+`;
+
+const ellipsis = keyframes`
+ to {
+ width: 1.25em;
+ margin-right: 0;
+ margin-left: 0;
+ }
+`
+
+const ConnectingAnimation = styled.span`
+ &:after {
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: bottom;
+ content: "\\2026"; /* ascii code for the ellipsis character */
+ width: 0;
+ margin: 0 1.25em 0 0;
+
+ [dir="rtl"] & {
+ margin: 0 0 0 1.25em;
+ }
+
+ ${({ animations }) => animations && css`
+ animation: ${ellipsis} steps(4, end) 900ms infinite;
+ `}
+ }
+`;
+
+const ButtonsActions = styled.div`
+ display: flex;
+ width: 100%;
+ justify-content: space-between;
+`;
+
+const PublishButton = styled(Button)`
+ width: 48%;
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+`;
+
+const CancelButton = styled(PublishButton)``;
+
+const LiveResultButton = styled(Button)`
+ width: 100%;
+ margin-top: ${smPaddingY};
+ margin-bottom: ${smPaddingY};
+ font-size: ${fontSizeBase};
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+`;
+
+const Separator = styled.div`
+ display: flex;
+ flex: 1 1 100%;
+ height: 1px;
+ min-height: 1px;
+ background-color: ${colorGrayLightest};
+ padding: 0;
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+`;
+
+const THeading = styled.th`
+ text-align: left;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+`;
+
+export default {
+ ResultLeft,
+ ResultRight,
+ Main,
+ Left,
+ Center,
+ Right,
+ BarShade,
+ BarVal,
+ Stats,
+ Title,
+ Status,
+ ConnectingAnimation,
+ ButtonsActions,
+ PublishButton,
+ CancelButton,
+ LiveResultButton,
+ Separator,
+ THeading,
+};
diff --git a/src/2.7.12/imports/ui/components/poll/service.js b/src/2.7.12/imports/ui/components/poll/service.js
new file mode 100644
index 00000000..93911d77
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/service.js
@@ -0,0 +1,314 @@
+import Auth from '/imports/ui/services/auth';
+import { CurrentPoll } from '/imports/api/polls';
+import { escapeHtml } from '/imports/utils/string-utils';
+import { defineMessages } from 'react-intl';
+
+const POLL_AVATAR_COLOR = '#3B48A9';
+const MAX_POLL_RESULT_BARS = 20;
+
+// 'YN' = Yes,No
+// 'YNA' = Yes,No,Abstention
+// 'TF' = True,False
+// 'A-2' = A,B
+// 'A-3' = A,B,C
+// 'A-4' = A,B,C,D
+// 'A-5' = A,B,C,D,E
+const pollTypes = {
+ YesNo: 'YN',
+ YesNoAbstention: 'YNA',
+ TrueFalse: 'TF',
+ Letter: 'A-',
+ A2: 'A-2',
+ A3: 'A-3',
+ A4: 'A-4',
+ A5: 'A-5',
+ Custom: 'CUSTOM',
+ Response: 'R-',
+};
+
+const pollAnswerIds = {
+ true: {
+ id: 'app.poll.answer.true',
+ description: 'label for poll answer True',
+ },
+ false: {
+ id: 'app.poll.answer.false',
+ description: 'label for poll answer False',
+ },
+ yes: {
+ id: 'app.poll.answer.yes',
+ description: 'label for poll answer Yes',
+ },
+ no: {
+ id: 'app.poll.answer.no',
+ description: 'label for poll answer No',
+ },
+ abstention: {
+ id: 'app.poll.answer.abstention',
+ description: 'label for poll answer Abstention',
+ },
+ a: {
+ id: 'app.poll.answer.a',
+ description: 'label for poll answer A',
+ },
+ b: {
+ id: 'app.poll.answer.b',
+ description: 'label for poll answer B',
+ },
+ c: {
+ id: 'app.poll.answer.c',
+ description: 'label for poll answer C',
+ },
+ d: {
+ id: 'app.poll.answer.d',
+ description: 'label for poll answer D',
+ },
+ e: {
+ id: 'app.poll.answer.e',
+ description: 'label for poll answer E',
+ },
+};
+
+const intlMessages = defineMessages({
+ legendTitle: {
+ id: 'app.polling.pollingTitle',
+ description: 'heading for chat poll legend',
+ },
+ pollQuestionTitle: {
+ id: 'app.polling.pollQuestionTitle',
+ description: 'title displayed before poll question',
+ },
+});
+
+const getUsedLabels = (listOfAnswers, possibleLabels) => listOfAnswers.map(
+ (answer) => {
+ if (answer.key.length >= 2) {
+ const formattedLabel = answer.key.slice(0, 2).toUpperCase();
+ if (possibleLabels.includes(formattedLabel)) {
+ return formattedLabel;
+ }
+ }
+ return undefined;
+ },
+);
+
+const getFormattedAnswerValue = (answerText) => {
+ // In generatePossibleLabels there is a check to see if the
+ // answer's length is greater than 2
+ const newText = answerText.slice(2).trim();
+ return newText;
+};
+
+const generateAlphabetList = () => Array.from(Array(26))
+ .map((e, i) => i + 65).map((x) => String.fromCharCode(x));
+
+const generatePossibleLabels = (alphabetCharacters) => {
+ // Remove the Letter from the beginning and the following sign, if any, like so:
+ // "A- the answer is" -> Remove "A-" -> "the answer is"
+ const listOfForbiddenSignsToStart = ['.', ':', '-'];
+
+ const possibleLabels = [];
+ for (let i = 0; i < alphabetCharacters.length; i += 1) {
+ for (let j = 0; j < listOfForbiddenSignsToStart.length; j += 1) {
+ possibleLabels.push(alphabetCharacters[i] + listOfForbiddenSignsToStart[j]);
+ }
+ }
+ return possibleLabels;
+};
+
+const getPollResultsText = (isDefaultPoll, answers, numRespondents, intl) => {
+ let responded = 0;
+ let resultString = '';
+ let optionsString = '';
+
+ const alphabetCharacters = generateAlphabetList();
+ const possibleLabels = generatePossibleLabels(alphabetCharacters);
+
+ // We need to guarantee that the labels are in the correct order, and that all options have label
+ const pollAnswerMatchLabeledFormat = getUsedLabels(answers, possibleLabels);
+ const isPollAnswerMatchFormat = !isDefaultPoll
+ ? pollAnswerMatchLabeledFormat.reduce(
+ (acc, label, index) => acc && !!label && label[0] === alphabetCharacters[index][0], true,
+ )
+ : false;
+
+ answers.map((item) => {
+ responded += item.numVotes;
+ return item;
+ }).forEach((item, index) => {
+ const numResponded = responded === numRespondents ? numRespondents : responded;
+ const pct = Math.round((item.numVotes / numResponded) * 100);
+ const pctBars = '|'.repeat((pct * MAX_POLL_RESULT_BARS) / 100);
+ const pctFotmatted = `${Number.isNaN(pct) ? 0 : pct}%`;
+ if (isDefaultPoll) {
+ const translatedKey = pollAnswerIds[item.key.toLowerCase()]
+ ? intl.formatMessage(pollAnswerIds[item.key.toLowerCase()])
+ : item.key;
+ resultString += `${translatedKey}: ${item.numVotes || 0} |${pctBars} ${pctFotmatted}\n`;
+ } else {
+ if (isPollAnswerMatchFormat) {
+ resultString += `${pollAnswerMatchLabeledFormat[index][0]}`;
+ const formattedAnswerValue = getFormattedAnswerValue(item.key);
+ optionsString += `${pollAnswerMatchLabeledFormat[index][0]}: ${formattedAnswerValue}\n`;
+ } else {
+ resultString += `${item.id + 1}`;
+ optionsString += `${item.id + 1}: ${item.key}\n`;
+ }
+ resultString += `: ${item.numVotes || 0} |${pctBars} ${pctFotmatted}\n`;
+ }
+ });
+
+ return { resultString, optionsString };
+};
+
+const isDefaultPoll = (pollType) => pollType !== pollTypes.Custom
+ && pollType !== pollTypes.Response;
+
+const getPollResultString = (pollResultData, intl) => {
+ const formatBoldBlack = (s) => s.bold().fontcolor('black');
+
+ const sanitize = (value) => escapeHtml(value);
+
+ const { answers, numRespondents, questionType } = pollResultData;
+ const ísDefault = isDefaultPoll(questionType);
+ let {
+ resultString,
+ optionsString,
+ } = getPollResultsText(ísDefault, answers, numRespondents, intl);
+ resultString = sanitize(resultString);
+ optionsString = sanitize(optionsString);
+
+ let pollText = formatBoldBlack(resultString);
+ if (!ísDefault) {
+ pollText += formatBoldBlack(` ${intl.formatMessage(intlMessages.legendTitle)} `);
+ pollText += optionsString;
+ }
+
+ const pollQuestion = pollResultData.questionText;
+ if (pollQuestion.trim() !== '') {
+ const sanitizedPollQuestion = sanitize(pollQuestion.split(' ').join(' '));
+
+ pollText = `${formatBoldBlack(intl.formatMessage(intlMessages.pollQuestionTitle))} ${sanitizedPollQuestion} ${pollText}`;
+ }
+
+ return pollText;
+};
+
+const matchYesNoPoll = (yesValue, noValue, contentString) => {
+ const ynPollString = `(${yesValue}\\s*\\/\\s*${noValue})|(${noValue}\\s*\\/\\s*${yesValue})`;
+ const ynOptionsRegex = new RegExp(ynPollString, 'gi');
+ const ynPoll = contentString.replace(/\n/g, '').match(ynOptionsRegex) || [];
+ return ynPoll;
+};
+
+const matchYesNoAbstentionPoll = (yesValue, noValue, abstentionValue, contentString) => {
+ const ynaPollString = `(${yesValue}\\s*\\/\\s*${noValue}\\s*\\/\\s*${abstentionValue})|(${yesValue}\\s*\\/\\s*${abstentionValue}\\s*\\/\\s*${noValue})|(${abstentionValue}\\s*\\/\\s*${yesValue}\\s*\\/\\s*${noValue})|(${abstentionValue}\\s*\\/\\s*${noValue}\\s*\\/\\s*${yesValue})|(${noValue}\\s*\\/\\s*${yesValue}\\s*\\/\\s*${abstentionValue})|(${noValue}\\s*\\/\\s*${abstentionValue}\\s*\\/\\s*${yesValue})`;
+ const ynaOptionsRegex = new RegExp(ynaPollString, 'gi');
+ const ynaPoll = contentString.replace(/\n/g, '').match(ynaOptionsRegex) || [];
+ return ynaPoll;
+};
+
+const matchTrueFalsePoll = (trueValue, falseValue, contentString) => {
+ const tfPollString = `(${trueValue}\\s*\\/\\s*${falseValue})|(${falseValue}\\s*\\/\\s*${trueValue})`;
+ const tgOptionsRegex = new RegExp(tfPollString, 'gi');
+ const tfPoll = contentString.match(tgOptionsRegex) || [];
+ return tfPoll;
+};
+
+const checkPollType = (
+ type,
+ optList,
+ yesValue,
+ noValue,
+ abstentionValue,
+ trueValue,
+ falseValue,
+) => {
+ let _type = type;
+ let pollString = '';
+ let defaultMatch = null;
+ let isDefault = null;
+
+ switch (_type) {
+ case pollTypes.Letter:
+ pollString = optList.map((x) => x.val.toUpperCase()).sort().join('');
+ defaultMatch = pollString.match(/^(ABCDEF)|(ABCDE)|(ABCD)|(ABC)|(AB)$/gi);
+ isDefault = defaultMatch && pollString.length === defaultMatch[0].length;
+ _type = isDefault ? `${_type}${defaultMatch[0].length}` : pollTypes.Custom;
+ break;
+ case pollTypes.TrueFalse:
+ pollString = optList.map((x) => x.val).join('/');
+ defaultMatch = matchTrueFalsePoll(trueValue, falseValue, pollString);
+ isDefault = defaultMatch.length > 0 && pollString.length === defaultMatch[0].length;
+ if (!isDefault) _type = pollTypes.Custom;
+ break;
+ case pollTypes.YesNoAbstention:
+ pollString = optList.map((x) => x.val).join('/');
+ defaultMatch = matchYesNoAbstentionPoll(yesValue, noValue, abstentionValue, pollString);
+ isDefault = defaultMatch.length > 0 && pollString.length === defaultMatch[0].length;
+ if (!isDefault) {
+ // also try to match only yes/no
+ defaultMatch = matchYesNoPoll(yesValue, noValue, pollString);
+ isDefault = defaultMatch.length > 0 && pollString.length === defaultMatch[0].length;
+ _type = isDefault ? pollTypes.YesNo : _type = pollTypes.Custom;
+ }
+ break;
+ default:
+ break;
+ }
+ return _type;
+};
+
+/**
+ *
+ * @param {String} input
+ */
+ const validateInput = (input) => {
+ let _input = input;
+ while (/^\s/.test(_input)) _input = _input.substring(1);
+ return _input;
+};
+
+/**
+ *
+ * @param {String} input
+ */
+const removeEmptyLineSpaces = (input) => {
+ const filteredInput = input.split('\n').filter((val) => val.trim() !== '');
+ return filteredInput;
+};
+
+/**
+ *
+ * @param {String|Array} questionAndOptions
+ */
+const getSplittedQuestionAndOptions = (questionAndOptions) => {
+ const inputList = Array.isArray(questionAndOptions)
+ ? questionAndOptions
+ : questionAndOptions.split('\n').filter((val) => val !== '');
+ const splittedQuestion = inputList.length > 0 ? inputList[0] : questionAndOptions;
+ const optionsList = inputList.slice(1);
+
+ optionsList.forEach((val, i) => { optionsList[i] = { val }; });
+
+ return {
+ splittedQuestion,
+ optionsList,
+ };
+};
+
+export default {
+ pollTypes,
+ currentPoll: () => CurrentPoll.findOne({ meetingId: Auth.meetingID }),
+ pollAnswerIds,
+ POLL_AVATAR_COLOR,
+ isDefaultPoll,
+ getPollResultString,
+ matchYesNoPoll,
+ matchYesNoAbstentionPoll,
+ matchTrueFalsePoll,
+ checkPollType,
+ validateInput,
+ removeEmptyLineSpaces,
+ getSplittedQuestionAndOptions,
+};
diff --git a/src/2.7.12/imports/ui/components/poll/styles.js b/src/2.7.12/imports/ui/components/poll/styles.js
new file mode 100644
index 00000000..c170b0cc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/poll/styles.js
@@ -0,0 +1,340 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ jumboPaddingY,
+ smPaddingX,
+ smPaddingY,
+ lgPaddingX,
+ borderRadius,
+ borderSize,
+ pollInputHeight,
+ pollSmMargin,
+ pollMdMargin,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorText,
+ colorBlueLight,
+ colorGrayLight,
+ colorGrayLighter,
+ colorGrayLightest,
+ colorDanger,
+ colorWarning,
+ colorHeading,
+ colorPrimary,
+ colorGrayDark,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeBase, fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+
+const ToggleLabel = styled.span`
+ margin-right: ${smPaddingX};
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+`;
+
+const PollOptionInput = styled.input`
+ margin-right: 1rem;
+
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: 1rem;
+ }
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ width: 100%;
+ color: ${colorText};
+ -webkit-appearance: none;
+ padding: calc(${smPaddingY} * 2) ${smPaddingX};
+ border-radius: ${borderRadius};
+ font-size: ${fontSizeBase};
+ border: 1px solid ${colorGrayLighter};
+ box-shadow: 0 0 0 1px ${colorGrayLighter};
+`;
+
+const DeletePollOptionButton = styled(Button)`
+ font-size: ${fontSizeBase};
+ flex: none;
+ width: 40px;
+ position: relative;
+ & > i {
+ font-size: 150%;
+ }
+`;
+
+const ErrorSpacer = styled.div`
+ position: relative;
+ height: 1.25rem;
+`;
+
+const InputError = styled(ErrorSpacer)`
+ color: ${colorDanger};
+ font-size: ${fontSizeSmall};
+`;
+
+const Instructions = styled.div`
+ margin-bottom: ${lgPaddingX};
+ color: ${colorText};
+`;
+
+const PollQuestionArea = styled.textarea`
+ resize: none;
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ width: 100%;
+ color: ${colorText};
+ -webkit-appearance: none;
+ padding: calc(${smPaddingY} * 2) ${smPaddingX};
+ border-radius: ${borderRadius};
+ font-size: ${fontSizeBase};
+ border: 1px solid ${colorGrayLighter};
+ box-shadow: 0 0 0 1px ${colorGrayLighter};
+
+ ${({ hasError }) => hasError && `
+ border-color: ${colorDanger};
+ box-shadow: 0 0 0 1px ${colorDanger};
+ `}
+`;
+
+const SectionHeading = styled.h4`
+ margin-top: 0;
+ font-weight: 600;
+ color: ${colorHeading};
+`;
+
+const ResponseType = styled.div`
+ display: flex;
+ justify-content: space-between;
+ flex-flow: wrap;
+ overflow-wrap: break-word;
+ position: relative;
+ width: 100%;
+ margin-bottom: ${lgPaddingX};
+
+ & > button {
+ position: relative;
+ width: 100%;
+ }
+`;
+
+const PollConfigButton = styled(Button)`
+ border: solid ${colorGrayLight} 1px;
+ min-height: ${pollInputHeight};
+ font-size: ${fontSizeBase};
+ white-space: pre-wrap;
+ width: 100%;
+ margin-bottom: 1rem;
+
+ & > span {
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ ${({ selected }) => selected && `
+ background-color: ${colorGrayLightest};
+ font-size: ${fontSizeBase};
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: ${colorGrayLightest} !important;
+ box-shadow: none !important;
+ }
+ `}
+
+ ${({ small }) => small && `
+ width: 49% !important;
+ `}
+
+ ${({ full }) => full && `
+ width: 100%;
+ `}
+`;
+
+const PollParagraph = styled.div`
+ color: ${colorText};
+ margin-bottom: 0.9rem;
+`;
+
+const PollCheckbox = styled.div`
+ display: inline-block;
+ margin-right: ${pollSmMargin};
+ margin-bottom: ${pollMdMargin};
+`;
+
+const AddItemButton = styled(Button)`
+ top: 1px;
+ position: relative;
+ display: block;
+ width: 100%;
+ text-align: left;
+ color: ${colorPrimary};
+ padding-left: 0;
+ padding-right: 0;
+ font-size: ${fontSizeBase};
+ white-space: pre-wrap;
+
+ &:hover {
+ & > span {
+ opacity: 1;
+ }
+ }
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-flow: wrap;
+ flex-grow: 1;
+ justify-content: space-between;
+ margin-top: 0.7rem;
+ margin-bottom: 0.7rem;
+`;
+
+const Warning = styled.div`
+ color: ${colorWarning};
+ font-size: ${fontSizeSmall};
+`;
+
+const CustomInputRow = styled.div`
+ display: flex;
+ flex-flow: nowrap;
+ flex-grow: 1;
+ justify-content: space-between;
+`;
+
+const Col = styled.div`
+ display: flex;
+ position: relative;
+ flex-flow: column;
+ flex-grow: 1;
+
+ &:last-child {
+ padding-right: 0;
+ padding-left: 1rem;
+
+ [dir="rtl"] & {
+ padding-right: 0.1rem;
+ padding-left: 0;
+ }
+ }
+`;
+
+const Toggle = styled.label`
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+`;
+
+const StartPollBtn = styled(Button)`
+ position: relative;
+ width: 100%;
+ min-height: ${pollInputHeight};
+ margin-top: 1rem;
+ font-size: ${fontSizeBase};
+ overflow-wrap: break-word;
+ white-space: pre-wrap;
+
+ &:hover {
+ & > span {
+ opacity: 1;
+ }
+ }
+`;
+
+const NoSlidePanelContainer = styled.div`
+ color: ${colorGrayDark};
+ text-align: center;
+`;
+
+const PollButton = styled(Button)``;
+
+const DragAndDropPollContainer = styled.div`
+ width: 200px !important;
+ height: 200px !important;
+`;
+
+const Question = styled.div`
+ margin-bottom: ${lgPaddingX};
+`;
+
+const OptionWrapper = styled.div`
+ display: flex;
+ justify-content: space-between;
+`;
+
+const ResponseArea = styled.div`
+ display: flex;
+ flex-flow: column wrap;
+`;
+
+const CustomInputHeading = styled(SectionHeading)`
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ padding-bottom: ${jumboPaddingY};
+`;
+
+const CustomInputHeadingCol = styled(Col)`
+ overflow: hidden;
+`;
+
+const CustomInputToggleCol = styled(Col)`
+ flex-shrink: 0;
+`;
+
+const AnonymousHeading = styled(CustomInputHeading)``;
+
+const AnonymousHeadingCol = styled(CustomInputHeadingCol)``;
+
+const AnonymousToggleCol = styled(CustomInputToggleCol)``;
+
+const AnonymousRow = styled(Row)`
+ flex-flow: nowrap;
+ width: 100%;
+`;
+
+export default {
+ ToggleLabel,
+ PollOptionInput,
+ DeletePollOptionButton,
+ ErrorSpacer,
+ InputError,
+ Instructions,
+ PollQuestionArea,
+ SectionHeading,
+ ResponseType,
+ PollConfigButton,
+ PollParagraph,
+ PollCheckbox,
+ AddItemButton,
+ Row,
+ Col,
+ Toggle,
+ StartPollBtn,
+ NoSlidePanelContainer,
+ PollButton,
+ DragAndDropPollContainer,
+ Warning,
+ CustomInputRow,
+ Question,
+ OptionWrapper,
+ ResponseArea,
+ CustomInputHeading,
+ CustomInputHeadingCol,
+ CustomInputToggleCol,
+ AnonymousHeading,
+ AnonymousHeadingCol,
+ AnonymousToggleCol,
+ AnonymousRow,
+};
diff --git a/src/2.7.12/imports/ui/components/polling/component.jsx b/src/2.7.12/imports/ui/components/polling/component.jsx
new file mode 100644
index 00000000..ebf64ea7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/polling/component.jsx
@@ -0,0 +1,343 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import { Meteor } from 'meteor/meteor';
+import Styled from './styles';
+import AudioService from '/imports/ui/components/audio/service';
+import Checkbox from '/imports/ui/components/common/checkbox/component';
+
+const MAX_INPUT_CHARS = Meteor.settings.public.poll.maxTypedAnswerLength;
+
+const intlMessages = defineMessages({
+ pollingTitleLabel: {
+ id: 'app.polling.pollingTitle',
+ },
+ pollAnswerLabel: {
+ id: 'app.polling.pollAnswerLabel',
+ },
+ pollAnswerDesc: {
+ id: 'app.polling.pollAnswerDesc',
+ },
+ pollQuestionTitle: {
+ id: 'app.polling.pollQuestionTitle',
+ },
+ responseIsSecret: {
+ id: 'app.polling.responseSecret',
+ },
+ responseNotSecret: {
+ id: 'app.polling.responseNotSecret',
+ },
+ submitLabel: {
+ id: 'app.polling.submitLabel',
+ },
+ submitAriaLabel: {
+ id: 'app.polling.submitAriaLabel',
+ },
+ responsePlaceholder: {
+ id: 'app.polling.responsePlaceholder',
+ },
+});
+
+const validateInput = (i) => {
+ let _input = i;
+ if (/^\s/.test(_input)) _input = '';
+ return _input;
+};
+
+class Polling extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ typedAns: '',
+ checkedAnswers: [],
+ };
+
+ this.pollingContainer = null;
+ this.play = this.play.bind(this);
+ this.handleUpdateResponseInput = this.handleUpdateResponseInput.bind(this);
+ this.renderButtonAnswers = this.renderButtonAnswers.bind(this);
+ this.handleCheckboxChange = this.handleCheckboxChange.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.renderCheckboxAnswers = this.renderCheckboxAnswers.bind(this);
+ this.handleMessageKeyDown = this.handleMessageKeyDown.bind(this);
+ }
+
+ componentDidMount() {
+ this.play();
+ this.pollingContainer && this.pollingContainer?.focus();
+ }
+
+ play() {
+ AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId}`
+ + '/resources/sounds/Poll.mp3');
+ }
+
+ handleUpdateResponseInput(e) {
+ this.responseInput.value = validateInput(e.target.value);
+ this.setState({ typedAns: this.responseInput.value });
+ }
+
+ handleSubmit(pollId) {
+ const { handleVote } = this.props;
+ const { checkedAnswers } = this.state;
+ handleVote(pollId, checkedAnswers);
+ }
+
+ handleCheckboxChange(pollId, answerId) {
+ const { checkedAnswers } = this.state;
+ if (checkedAnswers.includes(answerId)) {
+ checkedAnswers.splice(checkedAnswers.indexOf(answerId), 1);
+ } else {
+ checkedAnswers.push(answerId);
+ }
+ checkedAnswers.sort();
+ this.setState({ checkedAnswers });
+ }
+
+ handleMessageKeyDown(e) {
+ const {
+ poll,
+ handleTypedVote,
+ } = this.props;
+
+ const {
+ typedAns,
+ } = this.state;
+
+ if (e.keyCode === 13 && typedAns.length > 0) {
+ handleTypedVote(poll.pollId, typedAns);
+ }
+ }
+
+ renderButtonAnswers() {
+ const {
+ isMeteorConnected,
+ intl,
+ poll,
+ handleVote,
+ handleTypedVote,
+ pollAnswerIds,
+ pollTypes,
+ isDefaultPoll,
+ } = this.props;
+
+ const {
+ typedAns,
+ } = this.state;
+
+ if (!poll) return null;
+
+ const { stackOptions, answers, question, pollType } = poll;
+ const defaultPoll = isDefaultPoll(pollType);
+
+ return (
+
+ {
+ poll.pollType !== pollTypes.Response && (
+
+ {
+ question.length === 0 && (
+
+ {intl.formatMessage(intlMessages.pollingTitleLabel)}
+
+ )
+ }
+
+ {answers.map((pollAnswer) => {
+ const formattedMessageIndex = pollAnswer?.key?.toLowerCase();
+ let label = pollAnswer.key;
+ if ((defaultPoll || pollType.includes('CUSTOM')) && pollAnswerIds[formattedMessageIndex]) {
+ label = intl.formatMessage(pollAnswerIds[formattedMessageIndex]);
+ }
+
+ return (
+
+ handleVote(poll.pollId, [pollAnswer.id])}
+ aria-labelledby={`pollAnswerLabel${pollAnswer.key}`}
+ aria-describedby={`pollAnswerDesc${pollAnswer.key}`}
+ data-test="pollAnswerOption"
+ />
+
+ {intl.formatMessage(intlMessages.pollAnswerLabel, { 0: label })}
+
+
+ {intl.formatMessage(intlMessages.pollAnswerDesc, { 0: label })}
+
+
+ );
+ })}
+
+
+ )
+ }
+ {
+ poll.pollType === pollTypes.Response
+ && (
+
+ {
+ this.handleUpdateResponseInput(e);
+ }}
+ onKeyDown={(e) => {
+ this.handleMessageKeyDown(e);
+ }}
+ type="text"
+ placeholder={intl.formatMessage(intlMessages.responsePlaceholder)}
+ maxLength={MAX_INPUT_CHARS}
+ ref={(r) => { this.responseInput = r; }}
+ onPaste={(e) => { e.stopPropagation(); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ />
+ {
+ handleTypedVote(poll.pollId, typedAns);
+ }}
+ />
+
+ )
+ }
+
+ {intl.formatMessage(poll.secretPoll ? intlMessages.responseIsSecret : intlMessages.responseNotSecret)}
+
+
+ );
+ }
+
+ renderCheckboxAnswers() {
+ const {
+ isMeteorConnected,
+ intl,
+ poll,
+ pollAnswerIds,
+ } = this.props;
+ const { checkedAnswers } = this.state;
+ const { question } = poll;
+ return (
+
+ {question.length === 0
+ && (
+
+ {intl.formatMessage(intlMessages.pollingTitleLabel)}
+
+ )}
+
+ {poll.answers.map((pollAnswer) => {
+ const formattedMessageIndex = pollAnswer?.key?.toLowerCase();
+ let label = pollAnswer?.key;
+ if (pollAnswerIds[formattedMessageIndex]) {
+ label = intl.formatMessage(pollAnswerIds[formattedMessageIndex]);
+ }
+
+ return (
+
+
+
+ this.handleCheckboxChange(poll.pollId, pollAnswer.id)}
+ checked={checkedAnswers.includes(pollAnswer.id)}
+ ariaLabelledBy={`pollAnswerLabel${pollAnswer.key}`}
+ ariaDescribedBy={`pollAnswerDesc${pollAnswer.key}`}
+ />
+
+
+
+
+ {label}
+
+
+ {intl.formatMessage(intlMessages.pollAnswerDesc, { 0: label })}
+
+
+
+ );
+ })}
+
+
+ this.handleSubmit(poll.pollId)}
+ data-test="submitAnswersMultiple"
+ />
+
+
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ poll,
+ } = this.props;
+
+ if (!poll) return null;
+
+ const { stackOptions, question } = poll;
+
+ return (
+
+ this.pollingContainer = el}
+ tabIndex={-1}
+ >
+ {
+ question.length > 0 && (
+
+
+ {intl.formatMessage(intlMessages.pollQuestionTitle)}
+
+ {question}
+
+ )
+ }
+ {poll.isMultipleResponse ? this.renderCheckboxAnswers() : this.renderButtonAnswers()}
+
+
+ );
+ }
+}
+
+export default injectIntl(injectWbResizeEvent(Polling));
+
+Polling.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handleVote: PropTypes.func.isRequired,
+ handleTypedVote: PropTypes.func.isRequired,
+ poll: PropTypes.shape({
+ pollId: PropTypes.string.isRequired,
+ answers: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number.isRequired,
+ key: PropTypes.string,
+ }).isRequired).isRequired,
+ }).isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/polling/container.jsx b/src/2.7.12/imports/ui/components/polling/container.jsx
new file mode 100644
index 00000000..506c2ff6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/polling/container.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import { createPortal } from 'react-dom';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import PollingService from './service';
+import PollService from '/imports/ui/components/poll/service';
+import PollingComponent from './component';
+import { isPollingEnabled } from '/imports/ui/services/features';
+
+const propTypes = {
+ pollExists: PropTypes.bool.isRequired,
+ presentationIsFullscreen: PropTypes.bool.isRequired,
+};
+
+const PollingContainer = ({ pollExists, presentationIsFullscreen, ...props }) => {
+ const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { presenter: 1 } });
+ const showPolling = pollExists && !currentUser.presenter && isPollingEnabled();
+
+ if (showPolling) {
+ if (presentationIsFullscreen) {
+ return createPortal(
+ ,
+ document.getElementById('presentation-polling-placeholder'),
+ );
+ }
+ return (
+
+ );
+ }
+ return null;
+};
+
+PollingContainer.propTypes = propTypes;
+
+export default withTracker(() => {
+ const {
+ pollExists, handleVote, poll, handleTypedVote,
+ } = PollingService.mapPolls();
+ const { pollTypes } = PollService;
+ const presentationIsFullscreen = Session.get('presentationIsFullscreen');
+
+ if (poll && poll?.pollType) {
+ const isResponse = poll.pollType === pollTypes.Response;
+ Meteor.subscribe('polls', isResponse);
+ }
+
+ return ({
+ pollExists,
+ handleVote,
+ handleTypedVote,
+ poll,
+ pollAnswerIds: PollService.pollAnswerIds,
+ pollTypes,
+ isDefaultPoll: PollService.isDefaultPoll,
+ isMeteorConnected: Meteor.status().connected,
+ presentationIsFullscreen,
+ });
+})(PollingContainer);
diff --git a/src/2.7.12/imports/ui/components/polling/service.js b/src/2.7.12/imports/ui/components/polling/service.js
new file mode 100644
index 00000000..b2a23882
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/polling/service.js
@@ -0,0 +1,51 @@
+import { makeCall } from '/imports/ui/services/api';
+import Polls from '/imports/api/polls';
+import { debounce } from '/imports/utils/debounce';
+
+const MAX_CHAR_LENGTH = 5;
+
+const handleVote = (pollId, answerIds) => {
+ makeCall('publishVote', pollId, answerIds);
+};
+
+const handleTypedVote = (pollId, answer) => {
+ makeCall('publishTypedVote', pollId, answer);
+};
+
+const mapPolls = () => {
+ const poll = Polls.findOne({});
+ if (!poll) {
+ return { pollExists: false };
+ }
+
+ const { answers } = poll;
+ let stackOptions = false;
+
+ answers.map((obj) => {
+ if (stackOptions) return obj;
+ if (obj.key && obj.key.length > MAX_CHAR_LENGTH) {
+ stackOptions = true;
+ }
+ return obj;
+ });
+
+ const amIRequester = poll.requester !== 'userId';
+
+ return {
+ poll: {
+ answers: poll.answers,
+ pollId: poll.id,
+ isMultipleResponse: poll.isMultipleResponse,
+ pollType: poll.pollType,
+ stackOptions,
+ question: poll.question,
+ secretPoll: poll.secretPoll,
+ },
+ pollExists: true,
+ amIRequester,
+ handleVote: debounce(handleVote, 500, { leading: true, trailing: false }),
+ handleTypedVote: debounce(handleTypedVote, 500, { leading: true, trailing: false }),
+ };
+};
+
+export default { mapPolls };
diff --git a/src/2.7.12/imports/ui/components/polling/styles.js b/src/2.7.12/imports/ui/components/polling/styles.js
new file mode 100644
index 00000000..ee88d6e9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/polling/styles.js
@@ -0,0 +1,245 @@
+import styled from 'styled-components';
+import {
+ mdPaddingY,
+ smPaddingY,
+ jumboPaddingY,
+ smPaddingX,
+ borderRadius,
+ pollWidth,
+ pollSmMargin,
+ overlayIndex,
+ overlayOpacity,
+ pollIndex,
+ lgPaddingY,
+ pollBottomOffset,
+ jumboPaddingX,
+ pollColAmount,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ fontSizeSmall,
+ fontSizeBase,
+ fontSizeLarge,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorText,
+ colorBlueLight,
+ colorGrayLighter,
+ colorOffWhite,
+ colorGrayDark,
+ colorWhite,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { hasPhoneDimentions } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+
+const PollingTitle = styled.h1`
+ white-space: nowrap;
+ padding-bottom: ${mdPaddingY};
+ padding-top: ${mdPaddingY};
+ font-size: ${fontSizeSmall};
+ margin: 0;
+ padding: 0;
+ font-weight: 600;
+`;
+
+const PollButtonWrapper = styled.div`
+ text-align: center;
+ padding: ${smPaddingY};
+ width: 100%;
+`;
+
+const PollingButton = styled(Button)`
+ width: 100%;
+ max-width: 9em;
+
+ @media ${hasPhoneDimentions} {
+ max-width: none;
+ }
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const Hidden = styled.div`
+ display: none;
+`;
+
+const TypedResponseWrapper = styled.div`
+ margin: ${jumboPaddingY} .5rem .5rem .5rem;
+ display: flex;
+ flex-flow: column;
+`;
+
+const TypedResponseInput = styled.input`
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ color: ${colorText};
+ -webkit-appearance: none;
+ padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25);
+ border-radius: ${borderRadius};
+ font-size: ${fontSizeBase};
+ border: 1px solid ${colorGrayLighter};
+ box-shadow: 0 0 0 1px ${colorGrayLighter};
+ margin-bottom: 1rem;
+`;
+
+const SubmitVoteButton = styled(Button)`
+ font-size: ${fontSizeBase};
+`;
+
+const PollingSecret = styled.div`
+ font-size: ${fontSizeSmall};
+ max-width: ${pollWidth};
+`;
+
+const MultipleResponseAnswersTable = styled.table`
+ margin-left: auto;
+ margin-right: auto;
+`;
+
+const PollingCheckbox = styled.div`
+ display: inline-block;
+ margin-right: ${pollSmMargin};
+`;
+
+const CheckboxContainer = styled.tr`
+ margin-bottom: ${pollSmMargin};
+`;
+
+const MultipleResponseAnswersTableAnswerText = styled.td`
+ text-align: left;
+`;
+
+const Overlay = styled.div`
+ position: absolute;
+ height: 100vh;
+ width: 100vw;
+ z-index: ${overlayIndex};
+ pointer-events: none;
+
+ @media ${hasPhoneDimentions} {
+ pointer-events: auto;
+ background-color: rgba(0, 0, 0, ${overlayOpacity});
+ }
+`;
+
+const QHeader = styled.span`
+ text-align: left;
+ position: relative;
+ left: ${smPaddingY};
+`;
+
+const QTitle = styled.h1`
+ font-size: ${fontSizeSmall};
+ margin: 0;
+ padding: 0;
+ font-weight: 600;
+`;
+
+const QText = styled.div`
+ color: ${colorText};
+ word-break: break-word;
+ white-space: pre-wrap;
+ font-size: ${fontSizeLarge};
+ max-width: ${pollWidth};
+ padding-right: ${smPaddingX};
+`;
+
+const PollingContainer = styled.aside`
+ pointer-events:auto;
+ min-width: ${pollWidth};
+ position: absolute;
+
+ z-index: ${pollIndex};
+ border: 1px solid ${colorOffWhite};
+ border-radius: ${borderRadius};
+ box-shadow: ${colorGrayDark} 0px 0px ${lgPaddingY};
+ align-items: center;
+ text-align: center;
+ font-weight: 600;
+ padding: ${mdPaddingY};
+ background-color: ${colorWhite};
+ bottom: ${pollBottomOffset};
+ right: ${jumboPaddingX};
+
+ &:focus {
+ border: 1px solid ${colorPrimary};
+ }
+
+ [dir="rtl"] & {
+ left: ${jumboPaddingX};
+ right: auto;
+ }
+
+ @media ${hasPhoneDimentions} {
+ bottom: auto;
+ right: auto;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ max-height: 95%;
+ overflow-y: auto;
+
+ [dir="rtl"] & {
+ left: 50%;
+ }
+ }
+
+ ${({ autoWidth }) => autoWidth && `
+ width: auto;
+ `}
+`;
+
+const PollingAnswers = styled.div`
+ display: grid;
+ grid-template-columns: repeat(${pollColAmount}, 1fr);
+
+ @media ${hasPhoneDimentions} {
+ grid-template-columns: repeat(1, 1fr);
+
+ & div button {
+ grid-column: 1;
+ }
+ }
+
+ z-index: 1;
+
+ ${({ removeColumns }) => removeColumns && `
+ grid-template-columns: auto;
+ `}
+
+ ${({ stacked }) => stacked && `
+ grid-template-columns: repeat(1, 1fr);
+
+ & div button {
+ max-width: none !important;
+ }
+ `}
+
+`;
+
+export default {
+ PollingTitle,
+ PollButtonWrapper,
+ PollingButton,
+ Hidden,
+ TypedResponseWrapper,
+ TypedResponseInput,
+ SubmitVoteButton,
+ PollingSecret,
+ MultipleResponseAnswersTable,
+ PollingCheckbox,
+ CheckboxContainer,
+ MultipleResponseAnswersTableAnswerText,
+ Overlay,
+ QHeader,
+ QTitle,
+ QText,
+ PollingContainer,
+ PollingAnswers,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation-pod/component.jsx b/src/2.7.12/imports/ui/components/presentation-pod/component.jsx
new file mode 100644
index 00000000..bb26cf1f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation-pod/component.jsx
@@ -0,0 +1,24 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import PresentationContainer from '../presentation/container';
+
+class PresentationPods extends PureComponent {
+ render() {
+ /*
+ filtering/sorting presentation pods goes here
+ all the future UI for the pods also goes here
+ PresentationContainer should fill any empty box provided by us
+ */
+ return (
+
+ );
+ }
+}
+
+export default PresentationPods;
+
+PresentationPods.propTypes = {
+ presentationPodIds: PropTypes.arrayOf(PropTypes.shape({
+ podId: PropTypes.string.isRequired,
+ })).isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation-pod/container.jsx b/src/2.7.12/imports/ui/components/presentation-pod/container.jsx
new file mode 100644
index 00000000..f18fa8f5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation-pod/container.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import ErrorBoundary from '/imports/ui/components/common/error-boundary/component';
+import FallbackView from '/imports/ui/components/common/fallback-errors/fallback-view/component';
+import PresentationPodService from './service';
+import PresentationPods from './component';
+
+// PresentationPods component will be the place to go once we have the presentation pods designs
+// it should give each PresentationContainer some space
+// which it will fill with the uploaded presentation
+const PresentationPodsContainer = ({ presentationPodIds, ...props }) => {
+ if (presentationPodIds && presentationPodIds.length > 0) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+};
+
+export default withTracker(() => ({
+ presentationPodIds: PresentationPodService.getPresentationPodIds(),
+}))(PresentationPodsContainer);
+
+PresentationPodsContainer.propTypes = {
+ presentationPodIds: PropTypes.arrayOf(PropTypes.shape({
+ podId: PropTypes.string.isRequired,
+ })).isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation-pod/service.js b/src/2.7.12/imports/ui/components/presentation-pod/service.js
new file mode 100644
index 00000000..5e485dbd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation-pod/service.js
@@ -0,0 +1,21 @@
+import PresentationPods from '/imports/api/presentation-pods';
+import Auth from '/imports/ui/services/auth';
+
+const getPresentationPodIds = () => {
+ const podIds = PresentationPods.find(
+ {
+ meetingId: Auth.meetingID,
+ },
+ {
+ fields: {
+ podId: 1,
+ },
+ },
+ ).fetch();
+
+ return podIds;
+};
+
+export default {
+ getPresentationPodIds,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/component.jsx b/src/2.7.12/imports/ui/components/presentation/component.jsx
new file mode 100644
index 00000000..2c003e1f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/component.jsx
@@ -0,0 +1,995 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import WhiteboardContainer from '/imports/ui/components/whiteboard/container';
+import { HUNDRED_PERCENT, MAX_PERCENT } from '/imports/utils/slideCalcUtils';
+import { SPACE } from '/imports/utils/keyCodes';
+import { defineMessages, injectIntl } from 'react-intl';
+import { toast } from 'react-toastify';
+import { Session } from 'meteor/session';
+import PresentationToolbarContainer from './presentation-toolbar/container';
+import PresentationMenu from './presentation-menu/container';
+import DownloadPresentationButton from './download-presentation-button/component';
+import Styled from './styles';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import Icon from '/imports/ui/components/common/icon/component';
+import PollingContainer from '/imports/ui/components/polling/container';
+import { ACTIONS, LAYOUT_TYPE } from '../layout/enums';
+import DEFAULT_VALUES from '../layout/defaultValues';
+import { colorContentBackground } from '/imports/ui/stylesheets/styled-components/palette';
+import browserInfo from '/imports/utils/browserInfo';
+import { addNewAlert } from '../screenreader-alert/service';
+import { clearCursors } from '/imports/ui/components/whiteboard/cursors/service';
+import { debounce } from '/imports/utils/debounce';
+
+const intlMessages = defineMessages({
+ presentationLabel: {
+ id: 'app.presentationUploder.title',
+ description: 'presentation area element label',
+ },
+ changeNotification: {
+ id: 'app.presentation.notificationLabel',
+ description: 'label displayed in toast when presentation switches',
+ },
+ downloadLabel: {
+ id: 'app.presentation.downloadLabel',
+ description: 'label for downloadable presentations',
+ },
+ slideContentStart: {
+ id: 'app.presentation.startSlideContent',
+ description: 'Indicate the slide content start',
+ },
+ slideContentEnd: {
+ id: 'app.presentation.endSlideContent',
+ description: 'Indicate the slide content end',
+ },
+ slideContentChanged: {
+ id: 'app.presentation.changedSlideContent',
+ description: 'Indicate the slide content has changed',
+ },
+ noSlideContent: {
+ id: 'app.presentation.emptySlideContent',
+ description: 'No content available for slide',
+ },
+});
+
+const { isSafari } = browserInfo;
+const FULLSCREEN_CHANGE_EVENT = isSafari
+ ? 'webkitfullscreenchange'
+ : 'fullscreenchange';
+
+const getToolbarHeight = () => {
+ let height = 0;
+ const toolbarEl = document.getElementById('presentationToolbarWrapper');
+ if (toolbarEl) {
+ const { clientHeight } = toolbarEl;
+ height = clientHeight;
+ }
+ return height;
+};
+
+class Presentation extends PureComponent {
+ constructor() {
+ super();
+
+ this.state = {
+ presentationWidth: 0,
+ presentationHeight: 0,
+ zoom: 100,
+ isFullscreen: false,
+ tldrawAPI: null,
+ isPanning: false,
+ tldrawIsMounting: true,
+ isToolbarVisible: true,
+ hadPresentation: false,
+ };
+
+ this.currentPresentationToastId = null;
+
+ this.getSvgRef = this.getSvgRef.bind(this);
+ this.zoomChanger = debounce(this.zoomChanger.bind(this), 200);
+ this.updateLocalPosition = this.updateLocalPosition.bind(this);
+ this.panAndZoomChanger = this.panAndZoomChanger.bind(this);
+ this.fitToWidthHandler = this.fitToWidthHandler.bind(this);
+ this.onFullscreenChange = this.onFullscreenChange.bind(this);
+ this.getPresentationSizesAvailable = this.getPresentationSizesAvailable.bind(this);
+ this.handleResize = debounce(this.handleResize.bind(this), 200);
+ this.setTldrawAPI = this.setTldrawAPI.bind(this);
+ this.setIsPanning = this.setIsPanning.bind(this);
+ this.setIsToolbarVisible = this.setIsToolbarVisible.bind(this);
+ this.handlePanShortcut = this.handlePanShortcut.bind(this);
+ this.renderPresentationMenu = this.renderPresentationMenu.bind(this);
+
+ this.onResize = () => setTimeout(this.handleResize.bind(this), 0);
+ this.renderCurrentPresentationToast =
+ this.renderCurrentPresentationToast.bind(this);
+ this.setPresentationRef = this.setPresentationRef.bind(this);
+ this.setTldrawIsMounting = this.setTldrawIsMounting.bind(this);
+ Session.set('componentPresentationWillUnmount', false);
+ }
+
+ static getDerivedStateFromProps(props, state) {
+ const { prevProps } = state;
+ const stateChange = { prevProps: props };
+
+ if (
+ props.userIsPresenter &&
+ (!prevProps || !prevProps.userIsPresenter) &&
+ props.currentSlide &&
+ props.slidePosition
+ ) {
+ let potentialZoom =
+ 100 / (props.slidePosition.viewBoxWidth / props.slidePosition.width);
+ potentialZoom = Math.max(
+ HUNDRED_PERCENT,
+ Math.min(MAX_PERCENT, potentialZoom)
+ );
+ stateChange.zoom = potentialZoom;
+ }
+
+ if (!prevProps) return stateChange;
+
+ // When presenter is changed or slide changed we reset localPosition
+ if (
+ prevProps.currentSlide?.id !== props.currentSlide?.id ||
+ prevProps.userIsPresenter !== props.userIsPresenter
+ ) {
+ stateChange.localPosition = undefined;
+ }
+
+ return stateChange;
+ }
+
+ componentDidMount() {
+ this.getInitialPresentationSizes();
+ this.refPresentationContainer.addEventListener(
+ 'keydown',
+ this.handlePanShortcut
+ );
+ this.refPresentationContainer.addEventListener(
+ 'keyup',
+ this.handlePanShortcut
+ );
+ this.refPresentationContainer.addEventListener(
+ FULLSCREEN_CHANGE_EVENT,
+ this.onFullscreenChange
+ );
+ window.addEventListener('resize', this.onResize, false);
+
+ const {
+ currentSlide,
+ slidePosition,
+ numPages,
+ layoutContextDispatch,
+ currentPresentationId,
+ } = this.props;
+
+ if (currentPresentationId) {
+ this.setState({
+ hadPresentation: true,
+ });
+ }
+
+ if (currentSlide) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_NUM_CURRENT_SLIDE,
+ value: currentSlide.num,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_CURRENT_SLIDE_SIZE,
+ value: {
+ width: slidePosition.width,
+ height: slidePosition.height,
+ },
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_SLIDES_LENGTH,
+ value: numPages,
+ });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ currentPresentation,
+ slidePosition,
+ presentationIsOpen,
+ currentSlide,
+ publishedPoll,
+ setPresentationIsOpen,
+ restoreOnUpdate,
+ layoutContextDispatch,
+ userIsPresenter,
+ presentationBounds,
+ numCameras,
+ intl,
+ multiUser,
+ numPages,
+ currentPresentationId,
+ fitToWidth,
+ downloadPresentationUri,
+ } = this.props;
+ const {
+ presentationWidth,
+ presentationHeight,
+ zoom,
+ isPanning,
+ presentationId,
+ hadPresentation,
+ } = this.state;
+ const {
+ numCameras: prevNumCameras,
+ presentationBounds: prevPresentationBounds,
+ multiUser: prevMultiUser,
+ } = prevProps;
+
+ if (prevMultiUser && !multiUser) {
+ clearCursors();
+ }
+
+ if (numCameras !== prevNumCameras) {
+ this.onResize();
+ }
+
+ if (numPages !== prevProps.numPages) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_SLIDES_LENGTH,
+ value: numPages,
+ });
+ }
+
+ if (
+ currentSlide?.num != null &&
+ prevProps?.currentSlide?.num != null &&
+ currentSlide?.num !== prevProps.currentSlide?.num
+ ) {
+ addNewAlert(
+ intl.formatMessage(intlMessages.slideContentChanged, {
+ 0: currentSlide.num,
+ })
+ );
+ }
+
+ if (currentPresentation) {
+ const downloadableOn =
+ !prevProps?.currentPresentation?.downloadable &&
+ currentPresentation.downloadable;
+
+ const shouldCloseToast = !(
+ currentPresentation.downloadable && !userIsPresenter
+ );
+
+ if (
+ prevProps?.currentPresentation?.id !== currentPresentation.id
+ || prevProps?.downloadPresentationUri !== downloadPresentationUri
+ || (downloadableOn && !userIsPresenter)
+ ) {
+ if (this.currentPresentationToastId) {
+ toast.update(this.currentPresentationToastId, {
+ autoClose: shouldCloseToast,
+ render: this.renderCurrentPresentationToast(),
+ });
+ } else {
+ this.currentPresentationToastId = toast(
+ this.renderCurrentPresentationToast(),
+ {
+ onClose: () => {
+ this.currentPresentationToastId = null;
+ },
+ autoClose: shouldCloseToast,
+ className: 'actionToast currentPresentationToast',
+ }
+ );
+ }
+ }
+
+ const downloadableOff =
+ prevProps?.currentPresentation?.downloadable &&
+ !currentPresentation.downloadable;
+
+ if (this.currentPresentationToastId && downloadableOff) {
+ toast.update(this.currentPresentationToastId, {
+ autoClose: true,
+ render: this.renderCurrentPresentationToast(),
+ });
+ }
+ }
+
+ if (prevProps?.slidePosition && slidePosition) {
+ const { width: prevWidth, height: prevHeight } = prevProps.slidePosition;
+ const { width: currWidth, height: currHeight } = slidePosition;
+
+ if (prevWidth !== currWidth || prevHeight !== currHeight) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_CURRENT_SLIDE_SIZE,
+ value: {
+ width: currWidth,
+ height: currHeight,
+ },
+ });
+ }
+ const presentationChanged = presentationId !== currentPresentationId;
+
+ const isInitialPresentation = currentPresentation.isInitialPresentation;
+
+ if (
+ !presentationIsOpen &&
+ restoreOnUpdate &&
+ (currentSlide || presentationChanged)
+ ) {
+ const slideChanged = currentSlide.id !== prevProps.currentSlide.id;
+ const positionChanged =
+ slidePosition.viewBoxHeight !==
+ prevProps.slidePosition.viewBoxHeight ||
+ slidePosition.viewBoxWidth !== prevProps.slidePosition.viewBoxWidth;
+ const pollPublished = publishedPoll && !prevProps.publishedPoll;
+ if (
+ slideChanged ||
+ positionChanged ||
+ pollPublished ||
+ (presentationChanged && (hadPresentation || !isInitialPresentation))
+ ) {
+ setPresentationIsOpen(layoutContextDispatch, !presentationIsOpen);
+ }
+ }
+
+ if (presentationChanged) {
+ this.setState({
+ presentationId: currentPresentationId,
+ hadPresentation: true,
+ });
+ }
+
+ if (
+ presentationBounds !== prevPresentationBounds ||
+ (!presentationWidth && !presentationHeight)
+ )
+ this.onResize();
+ } else if (slidePosition) {
+ const { width: currWidth, height: currHeight } = slidePosition;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_CURRENT_SLIDE_SIZE,
+ value: {
+ width: currWidth,
+ height: currHeight,
+ },
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_NUM_CURRENT_SLIDE,
+ value: currentSlide.num,
+ });
+ }
+
+ if (
+ (zoom <= HUNDRED_PERCENT && isPanning && !fitToWidth) ||
+ (!userIsPresenter && prevProps.userIsPresenter)
+ ) {
+ this.setIsPanning();
+ }
+ }
+
+ componentWillUnmount() {
+ Session.set('componentPresentationWillUnmount', true);
+ const { fullscreenContext, layoutContextDispatch } = this.props;
+
+ window.removeEventListener('resize', this.onResize, false);
+ this.refPresentationContainer.removeEventListener(
+ FULLSCREEN_CHANGE_EVENT,
+ this.onFullscreenChange
+ );
+ this.refPresentationContainer.removeEventListener(
+ 'keydown',
+ this.handlePanShortcut
+ );
+ this.refPresentationContainer.removeEventListener(
+ 'keyup',
+ this.handlePanShortcut
+ );
+
+ if (fullscreenContext) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_FULLSCREEN_ELEMENT,
+ value: {
+ element: '',
+ group: '',
+ },
+ });
+ }
+ }
+
+ handlePanShortcut(e) {
+ const { userIsPresenter } = this.props;
+ const { isPanning } = this.state;
+ if (e.keyCode === SPACE && userIsPresenter) {
+ switch (e.type) {
+ case 'keyup':
+ return isPanning && this.setIsPanning();
+ case 'keydown':
+ return !isPanning && this.setIsPanning();
+ default:
+ }
+ }
+ return null;
+ }
+
+ handleResize() {
+ const presentationSizes = this.getPresentationSizesAvailable();
+ if (Object.keys(presentationSizes).length > 0) {
+ // updating the size of the space available for the slide
+ if (!Session.get('componentPresentationWillUnmount')) {
+ this.setState({
+ presentationHeight: presentationSizes.presentationHeight,
+ presentationWidth: presentationSizes.presentationWidth,
+ });
+ }
+ }
+ }
+
+ onFullscreenChange() {
+ const { isFullscreen } = this.state;
+ const newIsFullscreen = FullscreenService.isFullScreen(
+ this.refPresentationContainer,
+ );
+ if (isFullscreen !== newIsFullscreen) {
+ this.setState({ isFullscreen: newIsFullscreen });
+ Session.set('presentationIsFullscreen', newIsFullscreen);
+ }
+ }
+
+ setTldrawAPI(api) {
+ this.setState({
+ tldrawAPI: api,
+ });
+ }
+
+ setTldrawIsMounting(value) {
+ this.setState({ tldrawIsMounting: value });
+ }
+
+ setIsPanning() {
+ this.setState((prevState) => ({
+ isPanning: !prevState.isPanning,
+ }));
+ }
+
+ setIsToolbarVisible(isVisible) {
+ this.setState({
+ isToolbarVisible: isVisible,
+ });
+ }
+
+ setPresentationRef(ref) {
+ this.refPresentationContainer = ref;
+ }
+
+ // returns a ref to the svg element, which is required by a WhiteboardOverlay
+ // to transform screen coordinates to svg coordinate system
+ getSvgRef() {
+ return this.svggroup;
+ }
+
+ getPresentationSizesAvailable() {
+ const {
+ presentationBounds,
+ presentationAreaSize: newPresentationAreaSize,
+ } = this.props;
+ const presentationSizes = {
+ presentationWidth: 0,
+ presentationHeight: 0,
+ };
+
+ if (newPresentationAreaSize) {
+ presentationSizes.presentationWidth =
+ newPresentationAreaSize.presentationAreaWidth;
+ presentationSizes.presentationHeight =
+ newPresentationAreaSize.presentationAreaHeight -
+ (getToolbarHeight() || 0);
+ return presentationSizes;
+ }
+
+ presentationSizes.presentationWidth = presentationBounds.width;
+ presentationSizes.presentationHeight = presentationBounds.height;
+ return presentationSizes;
+ }
+
+ getInitialPresentationSizes() {
+ // determining the presentationWidth and presentationHeight (available
+ // space for the svg) on the initial load
+
+ const presentationSizes = this.getPresentationSizesAvailable();
+ if (Object.keys(presentationSizes).length > 0) {
+ // setting the state of the available space for the svg
+ this.setState({
+ presentationHeight: presentationSizes.presentationHeight,
+ presentationWidth: presentationSizes.presentationWidth,
+ });
+ }
+ }
+
+ zoomChanger(zoom) {
+ let boundZoom = parseInt(zoom);
+ if (boundZoom < HUNDRED_PERCENT) {
+ boundZoom = HUNDRED_PERCENT
+ } else if (boundZoom > MAX_PERCENT) {
+ boundZoom = MAX_PERCENT
+ }
+ this.setState({ zoom: boundZoom });
+ }
+
+ fitToWidthHandler() {
+ const { setPresentationFitToWidth, fitToWidth } = this.props;
+ setPresentationFitToWidth(!fitToWidth)
+ this.setState({
+ zoom: HUNDRED_PERCENT,
+ });
+ }
+
+ updateLocalPosition(x, y, width, height, zoom) {
+ this.setState({
+ localPosition: {
+ x,
+ y,
+ width,
+ height,
+ },
+ zoom,
+ });
+ }
+
+ calculateSize(viewBoxDimensions) {
+ const { presentationHeight, presentationWidth } = this.state;
+
+ const { userIsPresenter, currentSlide, slidePosition, fitToWidth } = this.props;
+
+ if (!currentSlide || !slidePosition) {
+ return { width: 0, height: 0 };
+ }
+
+ const originalWidth = slidePosition.width;
+ const originalHeight = slidePosition.height;
+ const viewBoxWidth = viewBoxDimensions.width;
+ const viewBoxHeight = viewBoxDimensions.height;
+
+ let svgWidth;
+ let svgHeight;
+
+ if (!userIsPresenter) {
+ svgWidth = (presentationHeight * viewBoxWidth) / viewBoxHeight;
+ if (presentationWidth < svgWidth) {
+ svgHeight = (presentationHeight * presentationWidth) / svgWidth;
+ svgWidth = presentationWidth;
+ } else {
+ svgHeight = presentationHeight;
+ }
+ } else if (!fitToWidth) {
+ svgWidth = (presentationHeight * originalWidth) / originalHeight;
+ if (presentationWidth < svgWidth) {
+ svgHeight = (presentationHeight * presentationWidth) / svgWidth;
+ svgWidth = presentationWidth;
+ } else {
+ svgHeight = presentationHeight;
+ }
+ } else {
+ svgWidth = presentationWidth;
+ svgHeight = (svgWidth * originalHeight) / originalWidth;
+ if (svgHeight > presentationHeight) svgHeight = presentationHeight;
+ }
+
+ if (typeof svgHeight !== 'number' || typeof svgWidth !== 'number') {
+ return { width: 0, height: 0 };
+ }
+
+ return {
+ width: svgWidth,
+ height: svgHeight,
+ };
+ }
+
+ panAndZoomChanger(w, h, x, y) {
+ const { currentSlide, podId, zoomSlide } = this.props;
+
+ zoomSlide(currentSlide.num, podId, w, h, x, y);
+ }
+
+ renderPresentationToolbar(svgWidth = 0) {
+ const {
+ currentSlide,
+ podId,
+ isMobile,
+ layoutType,
+ numCameras,
+ fullscreenElementId,
+ fullscreenContext,
+ layoutContextDispatch,
+ presentationIsOpen,
+ slidePosition,
+ addWhiteboardGlobalAccess,
+ removeWhiteboardGlobalAccess,
+ multiUserSize,
+ multiUser,
+ fitToWidth,
+ } = this.props;
+ const { zoom, isPanning } = this.state;
+
+ if (!currentSlide) return null;
+
+ const { presentationToolbarMinWidth } = DEFAULT_VALUES;
+
+ const toolbarWidth =
+ (this.refWhiteboardArea && svgWidth > presentationToolbarMinWidth) ||
+ isMobile ||
+ (layoutType === LAYOUT_TYPE.VIDEO_FOCUS && numCameras > 0)
+ ? svgWidth
+ : presentationToolbarMinWidth;
+ return (
+
+ );
+ }
+
+ renderCurrentPresentationToast() {
+ const {
+ intl,
+ currentPresentation,
+ userIsPresenter,
+ downloadPresentationUri,
+ } = this.props;
+ const { downloadable } = currentPresentation;
+
+ return (
+
+
+
+
+
+
+
+
+ {`${intl.formatMessage(intlMessages.changeNotification)}`}
+ {`${currentPresentation.name}`}
+
+
+ {downloadable && !userIsPresenter ? (
+
+
+
+ {intl.formatMessage(intlMessages.downloadLabel)}
+
+
+ ) : null}
+
+ );
+ }
+
+ renderPresentationDownload() {
+ const { presentationIsDownloadable, downloadPresentationUri } = this.props;
+
+ if (!presentationIsDownloadable || !downloadPresentationUri) return null;
+
+ const handleDownloadPresentation = () => {
+ window.open(downloadPresentationUri);
+ };
+
+ return (
+
+ );
+ }
+
+ renderPresentationMenu() {
+ const {
+ intl,
+ fullscreenElementId,
+ layoutContextDispatch,
+ } = this.props;
+ const { tldrawAPI, isToolbarVisible } = this.state;
+
+ return (
+
+ );
+ }
+
+ render() {
+ const {
+ userIsPresenter,
+ currentSlide,
+ slidePosition,
+ presentationBounds,
+ fullscreenContext,
+ isMobile,
+ layoutType,
+ numCameras,
+ currentPresentation,
+ podId,
+ intl,
+ isViewersCursorLocked,
+ fullscreenElementId,
+ layoutContextDispatch,
+ presentationIsOpen,
+ darkTheme,
+ isViewersAnnotationsLocked,
+ fitToWidth,
+ } = this.props;
+
+ const {
+ isFullscreen,
+ localPosition,
+ zoom,
+ tldrawIsMounting,
+ isPanning,
+ tldrawAPI,
+ isToolbarVisible,
+ } = this.state;
+
+ let viewBoxDimensions;
+
+ if (userIsPresenter && localPosition) {
+ viewBoxDimensions = {
+ width: localPosition.width,
+ height: localPosition.height,
+ };
+ } else if (slidePosition) {
+ viewBoxDimensions = {
+ width: slidePosition.viewBoxWidth,
+ height: slidePosition.viewBoxHeight,
+ };
+ } else {
+ viewBoxDimensions = {
+ width: 0,
+ height: 0,
+ };
+ }
+
+ const svgDimensions = this.calculateSize(viewBoxDimensions);
+ const svgHeight = svgDimensions.height;
+ const svgWidth = svgDimensions.width;
+
+ const toolbarHeight = getToolbarHeight();
+
+ const { presentationToolbarMinWidth } = DEFAULT_VALUES;
+
+ const isLargePresentation =
+ (svgWidth > presentationToolbarMinWidth) &&
+ !(
+ layoutType === LAYOUT_TYPE.VIDEO_FOCUS
+ && numCameras > 0
+ && !fullscreenContext
+ );
+
+ const containerWidth = isLargePresentation
+ ? svgWidth
+ : presentationToolbarMinWidth;
+
+ const mobileAwareContainerWidth = isMobile
+ ? presentationBounds.width
+ : containerWidth;
+
+ const slideContent = currentSlide?.content
+ ? `${intl.formatMessage(intlMessages.slideContentStart)}
+ ${currentSlide.content}
+ ${intl.formatMessage(intlMessages.slideContentEnd)}`
+ : intl.formatMessage(intlMessages.noSlideContent);
+
+ const isVideoFocus = layoutType === LAYOUT_TYPE.VIDEO_FOCUS;
+ const presentationZIndex = fullscreenContext ? presentationBounds.zIndex : undefined;
+
+ return (
+ <>
+ {
+ this.refPresentationContainer = ref;
+ }}
+ style={{
+ top: presentationBounds.top,
+ left: presentationBounds.left,
+ right: presentationBounds.right,
+ width: presentationBounds.width,
+ height: presentationBounds.height,
+ display: !presentationIsOpen ? 'none' : 'flex',
+ overflow: 'hidden',
+ zIndex: !isVideoFocus ? presentationZIndex : 0,
+ background:
+ layoutType === isVideoFocus && !fullscreenContext
+ ? colorContentBackground
+ : null,
+ }}
+ >
+ {
+ this.refPresentation = ref;
+ }}
+ >
+
+
+ {this.renderPresentationDownload()}
+
+ {slideContent}
+
+ {!tldrawIsMounting &&
+ currentSlide &&
+ this.renderPresentationMenu()}
+
+
+
+ {!tldrawIsMounting && (
+ {
+ this.refPresentationToolbar = ref;
+ }}
+ style={{
+ width: mobileAwareContainerWidth,
+ }}
+ >
+ {this.renderPresentationToolbar(svgWidth)}
+
+ )}
+
+
+
+ >
+ );
+ }
+}
+
+export default injectIntl(Presentation);
+
+Presentation.propTypes = {
+ podId: PropTypes.string.isRequired,
+ // Defines a boolean value to detect whether a current user is a presenter
+ userIsPresenter: PropTypes.bool.isRequired,
+ currentSlide: PropTypes.shape({
+ presentationId: PropTypes.string.isRequired,
+ current: PropTypes.bool.isRequired,
+ num: PropTypes.number.isRequired,
+ id: PropTypes.string.isRequired,
+ imageUri: PropTypes.string.isRequired,
+ curPageId: PropTypes.string,
+ svgUri: PropTypes.string.isRequired,
+ content: PropTypes.string.isRequired,
+ }),
+ slidePosition: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ viewBoxWidth: PropTypes.number.isRequired,
+ viewBoxHeight: PropTypes.number.isRequired,
+ }),
+ // current multi-user status
+ multiUser: PropTypes.bool.isRequired,
+ setPresentationIsOpen: PropTypes.func.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ currentPresentation: PropTypes.shape({
+ downloadable: PropTypes.bool.isRequired,
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ }),
+ presentationIsOpen: PropTypes.bool.isRequired,
+ numPages: PropTypes.number.isRequired,
+ publishedPoll: PropTypes.bool.isRequired,
+ presentationBounds: PropTypes.shape({
+ top: PropTypes.number,
+ left: PropTypes.number,
+ right: PropTypes.number,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ zIndex: PropTypes.number,
+ }),
+ restoreOnUpdate: PropTypes.bool.isRequired,
+ numCameras: PropTypes.number.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ fullscreenContext: PropTypes.bool.isRequired,
+ presentationAreaSize: PropTypes.shape({
+ presentationAreaWidth: PropTypes.number.isRequired,
+ presentationAreaHeight: PropTypes.number.isRequired,
+ }),
+ zoomSlide: PropTypes.func.isRequired,
+ addWhiteboardGlobalAccess: PropTypes.func.isRequired,
+ removeWhiteboardGlobalAccess: PropTypes.func.isRequired,
+ multiUserSize: PropTypes.number.isRequired,
+ layoutType: PropTypes.string.isRequired,
+ fullscreenElementId: PropTypes.string.isRequired,
+ downloadPresentationUri: PropTypes.string,
+ isViewersCursorLocked: PropTypes.bool.isRequired,
+ darkTheme: PropTypes.bool.isRequired,
+};
+
+Presentation.defaultProps = {
+ currentSlide: undefined,
+ slidePosition: undefined,
+ currentPresentation: undefined,
+ presentationAreaSize: undefined,
+ presentationBounds: undefined,
+ downloadPresentationUri: undefined,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/container.jsx b/src/2.7.12/imports/ui/components/presentation/container.jsx
new file mode 100644
index 00000000..ef1509a5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/container.jsx
@@ -0,0 +1,166 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import { notify } from '/imports/ui/services/notification';
+import PresentationService from './service';
+import { Slides } from '/imports/api/slides';
+import Presentation from '/imports/ui/components/presentation/component';
+import PresentationToolbarService from './presentation-toolbar/service';
+import { UsersContext } from '../components-data/users-context/context';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import {
+ layoutSelect,
+ layoutSelectInput,
+ layoutSelectOutput,
+ layoutDispatch,
+} from '../layout/context';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import WhiteboardService from '/imports/ui/components/whiteboard/service';
+import { DEVICE_TYPE } from '../layout/enums';
+import MediaService from '../media/service';
+
+const PresentationContainer = ({
+ presentationIsOpen, presentationPodIds, mountPresentation, layoutType, ...props
+}) => {
+ const cameraDock = layoutSelectInput((i) => i.cameraDock);
+ const presentation = layoutSelectOutput((i) => i.presentation);
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const deviceType = layoutSelect((i) => i.deviceType);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { numCameras } = cameraDock;
+ const { element } = fullscreen;
+ const fullscreenElementId = 'Presentation';
+ const fullscreenContext = (element === fullscreenElementId);
+
+ const isIphone = !!(navigator.userAgent.match(/iPhone/i));
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const userIsPresenter = currentUser.presenter;
+
+ return (
+
+ );
+};
+
+const APP_CONFIG = Meteor.settings.public.app;
+const PRELOAD_NEXT_SLIDE = APP_CONFIG.preloadNextSlides;
+const fetchedpresentation = {};
+
+export default lockContextContainer(
+ withTracker(({ podId, presentationIsOpen, userLocks }) => {
+ const currentSlide = PresentationService.getCurrentSlide(podId);
+ const numPages = PresentationService.getSlidesLength(podId);
+ const presentationIsDownloadable = PresentationService.isPresentationDownloadable(podId);
+ const isViewersCursorLocked = userLocks?.hideViewersCursor;
+ const isViewersAnnotationsLocked = userLocks?.hideViewersAnnotation;
+
+ let slidePosition;
+ if (currentSlide) {
+ const {
+ presentationId,
+ id: slideId,
+ } = currentSlide;
+ slidePosition = PresentationService.getSlidePosition(podId, presentationId, slideId);
+ if (PRELOAD_NEXT_SLIDE && !fetchedpresentation[presentationId]) {
+ fetchedpresentation[presentationId] = {
+ canFetch: true,
+ fetchedSlide: {},
+ };
+ }
+ const currentSlideNum = currentSlide.num;
+ const presentation = fetchedpresentation[presentationId];
+
+ if (PRELOAD_NEXT_SLIDE
+ && !presentation.fetchedSlide[currentSlide.num + PRELOAD_NEXT_SLIDE]
+ && presentation.canFetch) {
+ const slidesToFetch = Slides.find({
+ podId,
+ presentationId,
+ num: {
+ $in: Array(PRELOAD_NEXT_SLIDE).fill(1).map((v, idx) => currentSlideNum + (idx + 1)),
+ },
+ }).fetch();
+
+ const promiseImageGet = slidesToFetch
+ .filter((s) => !fetchedpresentation[presentationId].fetchedSlide[s.num])
+ .map(async (slide) => {
+ if (presentation.canFetch) presentation.canFetch = false;
+ const image = await fetch(slide.imageUri);
+ if (image.ok) {
+ presentation.fetchedSlide[slide.num] = true;
+ }
+ });
+ Promise.all(promiseImageGet).then(() => {
+ presentation.canFetch = true;
+ });
+ }
+ }
+ const currentPresentation = PresentationService.getCurrentPresentation(podId);
+
+ const downloadPresentationUri = currentPresentation
+ ? `${APP_CONFIG.bbbWebBase}/${currentPresentation?.originalFileURI}`
+ : null;
+
+ return {
+ currentSlide,
+ slidePosition,
+ downloadPresentationUri,
+ multiUser:
+ (WhiteboardService.hasMultiUserAccess(currentSlide && currentSlide.id, Auth.userID)
+ || WhiteboardService.isMultiUserActive(currentSlide?.id)
+ ) && presentationIsOpen,
+ presentationIsDownloadable,
+ mountPresentation: !!currentSlide,
+ currentPresentation,
+ currentPresentationId: currentPresentation?.id,
+ numPages,
+ notify,
+ zoomSlide: PresentationToolbarService.zoomSlide,
+ podId,
+ publishedPoll: Meetings.findOne({ meetingId: Auth.meetingID }, {
+ fields: {
+ publishedPoll: 1,
+ },
+ }).publishedPoll,
+ restoreOnUpdate: getFromUserSettings(
+ 'bbb_force_restore_presentation_on_new_events',
+ Meteor.settings.public.presentation.restoreOnUpdate,
+ ),
+ addWhiteboardGlobalAccess: WhiteboardService.addGlobalAccess,
+ removeWhiteboardGlobalAccess: WhiteboardService.removeGlobalAccess,
+ multiUserSize: WhiteboardService.getMultiUserSize(currentSlide?.id),
+ isViewersCursorLocked,
+ setPresentationIsOpen: MediaService.setPresentationIsOpen,
+ isViewersAnnotationsLocked,
+ };
+ })(PresentationContainer),
+);
+
+PresentationContainer.propTypes = {
+ presentationPodIds: PropTypes.arrayOf(PropTypes.shape({
+ podId: PropTypes.string.isRequired,
+ })).isRequired,
+ presentationIsOpen: PropTypes.bool.isRequired,
+ mountPresentation: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/download-presentation-button/component.jsx b/src/2.7.12/imports/ui/components/presentation/download-presentation-button/component.jsx
new file mode 100644
index 00000000..cc34c7a9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/download-presentation-button/component.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ downloadPresentationButton: {
+ id: 'app.downloadPresentationButton.label',
+ description: 'Download presentation label',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handleDownloadPresentation: PropTypes.func.isRequired,
+ dark: PropTypes.bool,
+};
+
+const defaultProps = {
+ dark: false,
+};
+
+const DownloadPresentationButton = ({
+ intl,
+ handleDownloadPresentation,
+ dark,
+}) => (
+
+
+
+);
+
+DownloadPresentationButton.propTypes = propTypes;
+DownloadPresentationButton.defaultProps = defaultProps;
+
+export default injectIntl(DownloadPresentationButton);
diff --git a/src/2.7.12/imports/ui/components/presentation/download-presentation-button/styles.js b/src/2.7.12/imports/ui/components/presentation/download-presentation-button/styles.js
new file mode 100644
index 00000000..b960419c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/download-presentation-button/styles.js
@@ -0,0 +1,74 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ colorWhite,
+ colorBlack,
+ colorTransparent,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const DownloadButton = styled(Button)`
+ &,
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: ${colorTransparent} !important;
+ border: none !important;
+
+ i {
+ border: none !important;
+ background-color: ${colorTransparent} !important;
+ }
+ }
+
+ padding: 5px;
+
+ &:hover {
+ border: 0;
+ }
+
+ i {
+ font-size: 1rem;
+ }
+`;
+
+const ButtonWrapper = styled.div`
+ position: absolute;
+ right: auto;
+ left: 0;
+ background-color: ${colorTransparent};
+ cursor: pointer;
+ border: 0;
+ z-index: 2;
+ margin: 2px;
+ bottom: 0;
+
+ [dir="rtl"] & {
+ right: 0;
+ left : auto;
+ }
+
+ [class*="presentationZoomControls"] & {
+ position: relative !important;
+ }
+
+ ${({ theme }) => theme === 'dark' && `
+ background-color: rgba(0,0,0,.3) !important;
+
+ & > button i {
+ color: ${colorWhite} !important;
+ }
+ `}
+
+ ${({ theme }) => theme === 'light' && `
+ background-color: ${colorTransparent} !important;
+
+ & > button i {
+ color: ${colorBlack} !important;
+ }
+ `}
+`;
+
+export default {
+ DownloadButton,
+ ButtonWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-area/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-area/component.jsx
new file mode 100644
index 00000000..946ea89c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-area/component.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import PresentationPodsContainer from '../../presentation-pod/container';
+
+const PresentationArea = ({
+ width,
+ height,
+ presentationIsOpen,
+ darkTheme,
+ layoutType,
+ setPresentationFitToWidth,
+ fitToWidth,
+}) => {
+ const presentationAreaSize = {
+ presentationAreaWidth: width,
+ presentationAreaHeight: height,
+ };
+ return (
+
+ );
+};
+
+export default PresentationArea;
+
+PresentationArea.propTypes = {
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ presentationIsOpen: PropTypes.bool.isRequired,
+ darkTheme: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-area/container.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-area/container.jsx
new file mode 100644
index 00000000..c51c4ee3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-area/container.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { layoutSelectOutput } from '../../layout/context';
+import PresentationArea from './component';
+
+const PresentationAreaContainer = ({ presentationIsOpen, darkTheme, layoutType, setPresentationFitToWidth, fitToWidth }) => {
+ const presentation = layoutSelectOutput((i) => i.presentation);
+
+ return ;
+};
+
+export default PresentationAreaContainer;
+
+PresentationAreaContainer.propTypes = {
+ presentationIsOpen: PropTypes.bool.isRequired,
+ darkTheme: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-menu/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-menu/component.jsx
new file mode 100644
index 00000000..958b44a6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-menu/component.jsx
@@ -0,0 +1,380 @@
+import React, { useState, useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { toPng } from 'html-to-image';
+import { toast } from 'react-toastify';
+import logger from '/imports/startup/client/logger';
+import Styled from './styles';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+import browserInfo from '/imports/utils/browserInfo';
+import AppService from '/imports/ui/components/app/service';
+
+const intlMessages = defineMessages({
+ downloading: {
+ id: 'app.presentation.options.downloading',
+ description: 'Downloading label',
+ defaultMessage: 'Downloading...',
+ },
+ downloaded: {
+ id: 'app.presentation.options.downloaded',
+ description: 'Downloaded label',
+ defaultMessage: 'Current presentation was downloaded',
+ },
+ downloadFailed: {
+ id: 'app.presentation.options.downloadFailed',
+ description: 'Downloaded failed label',
+ defaultMessage: 'Could not download current presentation',
+ },
+ fullscreenLabel: {
+ id: 'app.presentation.options.fullscreen',
+ description: 'Fullscreen label',
+ defaultMessage: 'Fullscreen',
+ },
+ exitFullscreenLabel: {
+ id: 'app.presentation.options.exitFullscreen',
+ description: 'Exit fullscreen label',
+ defaultMessage: 'Exit fullscreen',
+ },
+ minimizePresentationLabel: {
+ id: 'app.presentation.options.minimize',
+ description: 'Minimize presentation label',
+ defaultMessage: 'Minimize',
+ },
+ optionsLabel: {
+ id: 'app.navBar.settingsDropdown.optionsLabel',
+ description: 'Options button label',
+ defaultMessage: 'Options',
+ },
+ snapshotLabel: {
+ id: 'app.presentation.options.snapshot',
+ description: 'Snapshot of current slide label',
+ defaultMessage: 'Snapshot of current slide',
+ },
+ whiteboardLabel: {
+ id: 'app.shortcut-help.whiteboard',
+ description: 'used for aria whiteboard options button label',
+ defaultMessage: 'Whiteboard',
+ },
+ hideToolsDesc: {
+ id: 'app.presentation.presentationToolbar.hideToolsDesc',
+ description: 'Hide toolbar label',
+ },
+ showToolsDesc: {
+ id: 'app.presentation.presentationToolbar.showToolsDesc',
+ description: 'Show toolbar label',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handleToggleFullscreen: PropTypes.func.isRequired,
+ isFullscreen: PropTypes.bool,
+ elementName: PropTypes.string,
+ fullscreenRef: PropTypes.instanceOf(Element),
+ meetingName: PropTypes.string,
+ isIphone: PropTypes.bool,
+ elementId: PropTypes.string,
+ elementGroup: PropTypes.string,
+ currentElement: PropTypes.string,
+ currentGroup: PropTypes.string,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ isRTL: PropTypes.bool,
+ tldrawAPI: PropTypes.shape({
+ copySvg: PropTypes.func.isRequired,
+ getShapes: PropTypes.func.isRequired,
+ getShape: PropTypes.func.isRequired,
+ currentPageId: PropTypes.string.isRequired,
+ }),
+};
+
+const defaultProps = {
+ allowSnapshotOfCurrentSlide: PropTypes.bool.isRequired,
+ isIphone: false,
+ isFullscreen: false,
+ isRTL: false,
+ elementName: '',
+ meetingName: '',
+ fullscreenRef: null,
+ elementId: '',
+ elementGroup: '',
+ currentElement: '',
+ currentGroup: '',
+ tldrawAPI: null,
+};
+
+const PresentationMenu = (props) => {
+ const {
+ intl,
+ isFullscreen,
+ elementId,
+ elementName,
+ elementGroup,
+ currentElement,
+ currentGroup,
+ fullscreenRef,
+ tldrawAPI,
+ handleToggleFullscreen,
+ layoutContextDispatch,
+ meetingName,
+ isIphone,
+ isRTL,
+ isToolbarVisible,
+ setIsToolbarVisible,
+ allowSnapshotOfCurrentSlide,
+ } = props;
+
+ const [state, setState] = useState({
+ hasError: false,
+ loading: false,
+ });
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const toastId = useRef(null);
+ const dropdownRef = useRef(null);
+
+ const formattedLabel = (fullscreen) => (fullscreen
+ ? intl.formatMessage(intlMessages.exitFullscreenLabel)
+ : intl.formatMessage(intlMessages.fullscreenLabel)
+ );
+
+ const formattedVisibilityLabel = (visible) => (visible
+ ? intl.formatMessage(intlMessages.hideToolsDesc)
+ : intl.formatMessage(intlMessages.showToolsDesc)
+ );
+
+ function renderToastContent() {
+ const { loading, hasError } = state;
+
+ let icon = loading ? 'blank' : 'check';
+ if (hasError) icon = 'circle_close';
+
+ return (
+
+
+
+ {loading && !hasError && intl.formatMessage(intlMessages.downloading)}
+ {!loading && !hasError && intl.formatMessage(intlMessages.downloaded)}
+ {!loading && hasError && intl.formatMessage(intlMessages.downloadFailed)}
+
+
+
+
+
+
+ );
+ }
+
+ function getAvailableOptions() {
+ const menuItems = [];
+
+ if (!isIphone) {
+ menuItems.push(
+ {
+ key: 'list-item-fullscreen',
+ dataTest: 'presentationFullscreen',
+ label: formattedLabel(isFullscreen),
+ icon: isFullscreen ? 'exit_fullscreen' : 'fullscreen',
+ onClick: () => {
+ handleToggleFullscreen(fullscreenRef);
+ const newElement = (elementId === currentElement) ? '' : elementId;
+ const newGroup = (elementGroup === currentGroup) ? '' : elementGroup;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_FULLSCREEN_ELEMENT,
+ value: {
+ element: newElement,
+ group: newGroup,
+ },
+ });
+ },
+ },
+ );
+ }
+
+ const { isSafari } = browserInfo;
+
+ if (!isSafari && allowSnapshotOfCurrentSlide) {
+ menuItems.push(
+ {
+ key: 'list-item-screenshot',
+ label: intl.formatMessage(intlMessages.snapshotLabel),
+ dataTest: 'presentationSnapshot',
+ icon: 'video',
+ onClick: async () => {
+ setState({
+ loading: true,
+ hasError: false,
+ });
+
+ toastId.current = toast.info(renderToastContent(), {
+ hideProgressBar: true,
+ autoClose: false,
+ newestOnTop: true,
+ closeOnClick: true,
+ onClose: () => {
+ toastId.current = null;
+ },
+ });
+
+ // This is a workaround to a conflict of the
+ // dark mode's styles and the html-to-image lib.
+ // Issue:
+ // https://github.com/bubkoo/html-to-image/issues/370
+ const darkThemeState = AppService.isDarkThemeEnabled();
+ AppService.setDarkTheme(false);
+
+ try {
+ const {
+ copySvg, getShape, getShapes, currentPageId,
+ } = tldrawAPI;
+
+ // filter shapes that are inside the slide
+ const backgroundShape = getShape('slide-background-shape');
+ const shapes = getShapes(currentPageId);
+ const svgString = await copySvg(shapes.map((shape) => shape.id));
+ const container = document.createElement('div');
+ container.innerHTML = svgString;
+ const svgElem = container.firstChild;
+ const imageElem = svgElem.getElementsByTagName('image')[0];
+ const transforms = Object.values(imageElem.transform.baseVal);
+ const translation = transforms.find(
+ (t) => t.type === SVGTransform.SVG_TRANSFORM_TRANSLATE,
+ );
+ svgElem.setAttribute('width', backgroundShape.size[0]);
+ svgElem.setAttribute('height', backgroundShape.size[1]);
+ svgElem.setAttribute('viewBox', `${translation.matrix.e} ${translation.matrix.f} ${backgroundShape.size[0]} ${backgroundShape.size[1]}`);
+ const width = svgElem?.width?.baseVal?.value ?? window.screen.width;
+ const height = svgElem?.height?.baseVal?.value ?? window.screen.height;
+
+ const data = await toPng(svgElem, { width, height, backgroundColor: '#FFF' });
+
+ const anchor = document.createElement('a');
+ anchor.href = data;
+ anchor.setAttribute(
+ 'download',
+ `${elementName}_${meetingName}_${new Date().toISOString()}.png`,
+ );
+ anchor.click();
+
+ setState({
+ loading: false,
+ hasError: false,
+ });
+ } catch (e) {
+ setState({
+ loading: false,
+ hasError: true,
+ });
+
+ logger.warn({
+ logCode: 'presentation_snapshot_error',
+ extraInfo: e,
+ });
+ } finally {
+ // Workaround
+ AppService.setDarkTheme(darkThemeState);
+ }
+ },
+ },
+ );
+ }
+
+ const tools = document.querySelector('#TD-Tools');
+ if (tools && (props.hasWBAccess || props.amIPresenter)){
+ menuItems.push(
+ {
+ key: 'list-item-toolvisibility',
+ dataTest: 'toolVisibility',
+ label: formattedVisibilityLabel(isToolbarVisible),
+ icon: isToolbarVisible ? 'close' : 'pen_tool',
+ onClick: () => {
+ setIsToolbarVisible(!isToolbarVisible);
+ },
+ },
+ );
+ }
+
+ return menuItems;
+ }
+
+ useEffect(() => {
+ if (toastId.current) {
+ toast.update(toastId.current, {
+ render: renderToastContent(),
+ hideProgressBar: state.loading,
+ autoClose: state.loading ? false : 3000,
+ newestOnTop: true,
+ closeOnClick: true,
+ onClose: () => {
+ toastId.current = null;
+ },
+ });
+ }
+
+ if (dropdownRef.current) {
+ document.activeElement.blur();
+ dropdownRef.current.focus();
+ }
+ });
+
+ const options = getAvailableOptions();
+
+ if (options.length === 0) {
+ const undoCtrls = document.getElementById('TD-Styles')?.nextSibling;
+ if (undoCtrls?.style) {
+ undoCtrls.style = 'padding:0px';
+ }
+ const styleTool = document.getElementById('TD-Styles')?.parentNode;
+ if (styleTool?.style) {
+ styleTool.style = 'right:0px';
+ }
+ return null;
+ }
+
+ return (
+
+
+ {
+ setIsDropdownOpen((isOpen) => !isOpen);
+ }}
+ >
+
+
+
+ )}
+ opts={{
+ id: 'presentation-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ container: fullscreenRef,
+ }}
+ actions={options}
+ />
+
+ );
+};
+
+PresentationMenu.propTypes = propTypes;
+PresentationMenu.defaultProps = defaultProps;
+
+export default injectIntl(PresentationMenu);
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-menu/container.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-menu/container.jsx
new file mode 100644
index 00000000..65de425f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-menu/container.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import PresentationMenu from './component';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+import { layoutSelect, layoutDispatch } from '/imports/ui/components/layout/context';
+import WhiteboardService from '/imports/ui/components/whiteboard/service';
+import UserService from '/imports/ui/components/user-list/service';
+import { isSnapshotOfCurrentSlideEnabled } from '/imports/ui/services/features';
+
+const PresentationMenuContainer = (props) => {
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const { element: currentElement, group: currentGroup } = fullscreen;
+ const layoutContextDispatch = layoutDispatch();
+ const { elementId } = props;
+ const isFullscreen = currentElement === elementId;
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ return (
+
+ );
+};
+
+export default withTracker((props) => {
+ const handleToggleFullscreen = (ref) => FullscreenService.toggleFullScreen(ref);
+ const isIphone = !!(navigator.userAgent.match(/iPhone/i));
+ const meetingId = Auth.meetingID;
+ const meetingObject = Meetings.findOne({ meetingId }, { fields: { 'meetingProp.name': 1 } });
+ const hasWBAccess = WhiteboardService.hasMultiUserAccess(WhiteboardService.getCurrentWhiteboardId(), Auth.userID);
+ const amIPresenter = UserService.isUserPresenter(Auth.userID);
+
+ return {
+ ...props,
+ allowSnapshotOfCurrentSlide: isSnapshotOfCurrentSlideEnabled(),
+ handleToggleFullscreen,
+ isIphone,
+ isDropdownOpen: Session.get('dropdownOpen'),
+ meetingName: meetingObject.meetingProp.name,
+ hasWBAccess,
+ amIPresenter,
+ };
+})(PresentationMenuContainer);
+
+PresentationMenuContainer.propTypes = {
+ elementId: PropTypes.string.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-menu/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-menu/styles.js
new file mode 100644
index 00000000..709a903d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-menu/styles.js
@@ -0,0 +1,149 @@
+import styled, { css, keyframes } from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import { headingsFontWeight } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorDanger,
+ colorGray,
+ colorGrayDark,
+ colorSuccess,
+ colorGrayLightest,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ borderSizeLarge,
+ lgPaddingX,
+ statusIconSize,
+ borderSize,
+ statusInfoHeight,
+ presentationMenuHeight,
+} from '/imports/ui/stylesheets/styled-components/general';
+
+const DropdownButton = styled.button`
+ background-color: #FFF;
+ border: none;
+ border-radius: 7px;
+ color: ${colorGrayDark};
+ cursor: pointer;
+ padding: .3rem .5rem;
+ padding-bottom: 6px;
+ tab-index: 0;
+
+ &:hover {
+ background-color: #ececec;
+ }
+`;
+
+const Left = styled.div`
+ cursor: pointer;
+ position: absolute;
+ left: 0px;
+ z-index: 999;
+ box-shadow: 0 4px 2px -2px rgba(0, 0, 0, 0.05);
+ border-bottom: 1px solid ${colorWhite};
+ height: 44px;
+
+ > div {
+ padding: 2px 4px 2px 4px;
+ background-color: ${colorWhite};
+ width: 50px;
+ height: 100%;
+ }
+
+ button {
+ height: 100%;
+ width: 100%;
+ }
+
+ [dir="rtl"] & {
+ right: 0px;
+ left: auto;
+ }
+`;
+
+const ToastText = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: left;
+ white-space: nowrap;
+ position: relative;
+ top: ${borderSizeLarge};
+ width: auto;
+ font-weight: ${headingsFontWeight};
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+`;
+
+const StatusIcon = styled.span`
+ margin-left: auto;
+
+ [dir="rtl"] & {
+ margin-right: auto;
+ margin-left: 0;
+ }
+
+ & > i {
+ position: relative;
+ top: 1px;
+ height: ${statusIconSize};
+ width: ${statusIconSize};
+ }
+`;
+
+const rotate = keyframes`
+ 0% { transform: rotate(0); }
+ 100% { transform: rotate(360deg); }
+`;
+
+const ToastIcon = styled(Icon)`
+ position: relative;
+ width: ${statusIconSize};
+ height: ${statusIconSize};
+ font-size: 117%;
+ bottom: ${borderSize};
+ left: ${statusInfoHeight};
+
+ [dir="rtl"] & {
+ left: auto;
+ right: ${statusInfoHeight};
+ }
+
+ ${({ done }) => done && `
+ color: ${colorSuccess};
+ `}
+
+ ${({ error }) => error && `
+ color: ${colorDanger};
+ `}
+
+ ${({ loading }) => loading && css`
+ color: ${colorGrayLightest};
+ border: 1px solid;
+ border-radius: 50%;
+ border-right-color: ${colorGray};
+ animation: ${rotate} 1s linear infinite;
+ `}
+`;
+
+const Line = styled.div`
+ display: flex;
+ width: 100%;
+ flex-wrap: nowrap;
+ padding: ${lgPaddingX} 0;
+`;
+
+const ButtonIcon = styled(Icon)`
+ width: 1em;
+ text-align: center;
+`;
+
+export default {
+ DropdownButton,
+ Left,
+ ToastText,
+ StatusIcon,
+ ToastIcon,
+ Line,
+ ButtonIcon,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component.jsx
new file mode 100644
index 00000000..c87a2549
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component.jsx
@@ -0,0 +1,453 @@
+import Presentations, { UploadingPresentations } from '/imports/api/presentations';
+import React from 'react';
+import { useTracker } from 'meteor/react-meteor-data';
+import Icon from '/imports/ui/components/common/icon/component';
+import { makeCall } from '/imports/ui/services/api';
+import Styled from '/imports/ui/components/presentation/presentation-uploader/styles';
+import { toast } from 'react-toastify';
+import { defineMessages } from 'react-intl';
+
+const TIMEOUT_CLOSE_TOAST = 1; // second
+
+const intlMessages = defineMessages({
+ item: {
+ id: 'app.presentationUploder.item',
+ description: 'single item label',
+ },
+ itemPlural: {
+ id: 'app.presentationUploder.itemPlural',
+ description: 'plural item label',
+ },
+ uploading: {
+ id: 'app.presentationUploder.uploading',
+ description: 'uploading label for toast notification',
+ },
+ uploadStatus: {
+ id: 'app.presentationUploder.uploadStatus',
+ description: 'upload status for toast notification',
+ },
+ completed: {
+ id: 'app.presentationUploder.completed',
+ description: 'uploads complete label for toast notification',
+ },
+ GENERATING_THUMBNAIL: {
+ id: 'app.presentationUploder.conversion.generatingThumbnail',
+ description: 'indicatess that it is generating thumbnails',
+ },
+ GENERATING_SVGIMAGES: {
+ id: 'app.presentationUploder.conversion.generatingSvg',
+ description: 'warns that it is generating svg images',
+ },
+ GENERATED_SLIDE: {
+ id: 'app.presentationUploder.conversion.generatedSlides',
+ description: 'warns that were slides generated',
+ },
+ 413: {
+ id: 'app.presentationUploder.upload.413',
+ description: 'error that file exceed the size limit',
+ },
+ CONVERSION_TIMEOUT: {
+ id: 'app.presentationUploder.conversion.conversionTimeout',
+ description: 'warns the user that the presentation timed out in the back-end in specific page of the document',
+ },
+ FILE_TOO_LARGE: {
+ id: 'app.presentationUploder.upload.413',
+ description: 'error that file exceed the size limit',
+ },
+ INVALID_MIME_TYPE: {
+ id: 'app.presentationUploder.conversion.invalidMimeType',
+ description: 'warns user that the file\'s mime type is not supported or it doesn\'t match the extension',
+ },
+ PAGE_COUNT_EXCEEDED: {
+ id: 'app.presentationUploder.conversion.pageCountExceeded',
+ description: 'warns the user that the conversion failed because of the page count',
+ },
+ PDF_HAS_BIG_PAGE: {
+ id: 'app.presentationUploder.conversion.pdfHasBigPage',
+ description: 'warns the user that the conversion failed because of the pdf page siz that exceeds the allowed limit',
+ },
+ OFFICE_DOC_CONVERSION_INVALID: {
+ id: 'app.presentationUploder.conversion.officeDocConversionInvalid',
+ description: '',
+ },
+ OFFICE_DOC_CONVERSION_FAILED: {
+ id: 'app.presentationUploder.conversion.officeDocConversionFailed',
+ description: 'warns the user that the conversion failed because of wrong office file',
+ },
+ UNSUPPORTED_DOCUMENT: {
+ id: 'app.presentationUploder.conversion.unsupportedDocument',
+ description: 'warns the user that the file extension is not supported',
+ },
+ 204: {
+ id: 'app.presentationUploder.conversion.204',
+ description: 'error indicating that the file has no content to capture',
+ },
+ fileToUpload: {
+ id: 'app.presentationUploder.fileToUpload',
+ description: 'message used in the file selected for upload',
+ },
+ uploadProcess: {
+ id: 'app.presentationUploder.upload.progress',
+ description: 'message that indicates the percentage of the upload',
+ },
+ badConnectionError: {
+ id: 'app.presentationUploder.connectionClosedError',
+ description: 'message indicating that the connection was closed',
+ },
+ conversionProcessingSlides: {
+ id: 'app.presentationUploder.conversion.conversionProcessingSlides',
+ description: 'indicates how many slides were converted',
+ },
+ genericError: {
+ id: 'app.presentationUploder.genericError',
+ description: 'generic error while uploading/converting',
+ },
+ genericConversionStatus: {
+ id: 'app.presentationUploder.conversion.genericConversionStatus',
+ description: 'indicates that file is being converted',
+ },
+});
+
+function renderPresentationItemStatus(item, intl) {
+ if ((('progress' in item) && item.progress === 0) || (('upload' in item) && item.upload.progress === 0 && !item.upload.error)) {
+ return intl.formatMessage(intlMessages.fileToUpload);
+ }
+
+ if (('progress' in item) && item.progress < 100 && !('conversion' in item)) {
+ return intl.formatMessage(intlMessages.uploadProcess, {
+ 0: Math.floor(item.progress).toString(),
+ });
+ }
+
+ const constraint = {};
+
+ if (('upload' in item) && (item.upload.done && item.upload.error)) {
+ if (item.conversion.status === 'FILE_TOO_LARGE' || item.upload.status !== 413) {
+ constraint['0'] = ((item.conversion.maxFileSize) / 1000 / 1000).toFixed(2);
+ } else if (item.progress < 100) {
+ const errorMessage = intlMessages.badConnectionError;
+ return intl.formatMessage(errorMessage);
+ }
+
+ const errorMessage = intlMessages[item.upload.status] || intlMessages.genericError;
+ return intl.formatMessage(errorMessage, constraint);
+ }
+
+ if (('conversion' in item) && (!item.conversion.done && item.conversion.error)) {
+ const errorMessage = intlMessages[item.conversion.status]
+ || intlMessages.genericConversionStatus;
+
+ switch (item.conversion.status) {
+ case 'CONVERSION_TIMEOUT':
+ constraint['0'] = item.conversion.numberPageError;
+ constraint['1'] = item.conversion.maxNumberOfAttempts;
+ break;
+ case 'FILE_TOO_LARGE':
+ constraint['0'] = ((item.conversion.maxFileSize) / 1000 / 1000).toFixed(2);
+ break;
+ case 'PAGE_COUNT_EXCEEDED':
+ constraint['0'] = item.conversion.maxNumberPages;
+ break;
+ case 'PDF_HAS_BIG_PAGE':
+ constraint['0'] = (item.conversion.bigPageSize / 1000 / 1000).toFixed(2);
+ break;
+ case 'INVALID_MIME_TYPE':
+ constraint['0'] = item.conversion.fileExtension;
+ constraint['1'] = item.conversion.fileMime;
+ break;
+ default:
+ break;
+ }
+
+ return intl.formatMessage(errorMessage, constraint);
+ }
+
+ if ((('conversion' in item) && (!item.conversion.done && !item.conversion.error)) || (('progress' in item) && item.progress === 100)) {
+ let conversionStatusMessage;
+ if ('conversion' in item) {
+ if (item.conversion.pagesCompleted < item.conversion.numPages) {
+ return intl.formatMessage(intlMessages.conversionProcessingSlides, {
+ 0: item.conversion.pagesCompleted,
+ 1: item.conversion.numPages,
+ });
+ }
+
+ conversionStatusMessage = intlMessages[item.conversion.status]
+ || intlMessages.genericConversionStatus;
+ } else {
+ conversionStatusMessage = intlMessages.genericConversionStatus;
+ }
+ return intl.formatMessage(conversionStatusMessage);
+ }
+
+ return null;
+}
+
+function renderToastItem(item, intl) {
+ const isUploading = ('progress' in item) && item.progress <= 100;
+ const isConverting = ('conversion' in item) && !item.conversion.done;
+ const hasError = ((('conversion' in item) && item.conversion.error) || (('upload' in item) && item.upload.error));
+ const isProcessing = (isUploading || isConverting) && !hasError;
+
+ let icon = isProcessing ? 'blank' : 'check';
+ if (hasError) icon = 'circle_close';
+
+ return (
+ {
+ if (hasError || isProcessing) Session.set('showUploadPresentationView', true);
+ }}
+ >
+
+
+
+
+
+ {item.filename || item.name}
+
+
+
+
+
+
+
+ {renderPresentationItemStatus(item, intl)}
+
+
+
+ );
+}
+
+const renderToastList = (presentations, intl) => {
+ let converted = 0;
+
+ const presentationsSorted = presentations
+ .sort((a, b) => a.uploadTimestamp - b.uploadTimestamp)
+ .sort((a, b) => {
+ const presADone = a.conversion ? a.conversion.done : false;
+ const presBDone = b.conversion ? b.conversion.done : false;
+
+ return presADone - presBDone;
+ });
+
+ presentationsSorted
+ .forEach((p) => {
+ const presDone = p.conversion ? p.conversion.done : false;
+ if (presDone) converted += 1;
+ return p;
+ });
+
+ let toastHeading = '';
+ const itemLabel = presentationsSorted.length > 1
+ ? intl.formatMessage(intlMessages.itemPlural)
+ : intl.formatMessage(intlMessages.item);
+
+ if (converted === 0) {
+ toastHeading = intl.formatMessage(intlMessages.uploading, {
+ 0: presentationsSorted.length,
+ 1: itemLabel,
+ });
+ }
+
+ if (converted > 0 && converted !== presentationsSorted.length) {
+ toastHeading = intl.formatMessage(intlMessages.uploadStatus, {
+ 0: converted,
+ 1: presentationsSorted.length,
+ });
+ }
+
+ if (converted === presentationsSorted.length) {
+ toastHeading = intl.formatMessage(intlMessages.completed, {
+ 0: converted,
+ });
+ }
+
+ return (
+
+
+
+ {toastHeading}
+
+
+
+
+ {presentationsSorted.map((item) => renderToastItem(item, intl))}
+
+
+
+
+ );
+};
+
+function handleDismissToast(toastId) {
+ return toast.dismiss(toastId);
+}
+
+const alreadyRenderedPresList = [];
+
+const enteredConversion = {};
+
+export const PresentationUploaderToast = ({ intl }) => {
+ useTracker(() => {
+ const presentationsRenderedFalseAndConversionFalse = Presentations.find({ $or: [{ renderedInToast: false }, { 'conversion.done': false }] }).fetch();
+
+ const convertingPresentations = presentationsRenderedFalseAndConversionFalse
+ .filter((p) => !p.renderedInToast);
+
+ // removing ones with errors.
+ // If presentation has an error status - we don't want to have it pending as uploading
+ convertingPresentations.forEach((p) => {
+ if ('conversion' in p && p.conversion.error) {
+ UploadingPresentations.remove(
+ { $or: [{ temporaryPresentationId: p.temporaryPresentationId }, { id: p.id }] },
+ );
+ }
+ });
+
+ const toRemoveFromUploadingPresentations = [];
+ // main goal of this mapping is to sort out what doesn't need to be displayed
+ UploadingPresentations.find().fetch().forEach((p) => {
+ if (
+ (('upload' in p && p.upload.done) // if presentation is marked as done - it's potentially to be removed
+ && !p.subscriptionId) // at upload stage or already converted
+ || (p.lastModifiedUploader === false) // if presentation uploaded internally (e.g., breakout capture)
+ ) {
+ if (convertingPresentations[0]) { // there are presentations being converted
+ convertingPresentations.forEach((cp) => {
+ // if this presentation is being converted
+ // we don't want it to be marked as still uploading
+ if (cp.temporaryPresentationId === p.temporaryPresentationId) {
+ toRemoveFromUploadingPresentations
+ .push({ temporaryPresentationId: p.temporaryPresentationId, id: p.id });
+ }
+ });
+ // upload stage is done and presentation is entering conversion stage
+ } else if (!enteredConversion[p.temporaryPresentationId]) {
+ // we mark that it has entered conversion stage
+ enteredConversion[p.temporaryPresentationId] = true;
+ } else {
+ // presentation doesn't normally enter conversion twice so we remove
+ // the inconsistencies between UploadingPresentation and Presentation (corner case)
+ const presentationsAlreadyRenderedIds = Presentations
+ .find({ renderedInToast: true }).fetch().map((pr) => (
+ {
+ id: pr.id,
+ temporaryPresentationId: pr.temporaryPresentationId,
+ }
+ ));
+ presentationsAlreadyRenderedIds.forEach((pr) => {
+ UploadingPresentations.remove({
+ $or: [{ temporaryPresentationId: pr.temporaryPresentationId }, { id: pr.id }],
+ });
+ });
+ }
+ }
+ });
+
+ toRemoveFromUploadingPresentations.forEach((p) => {
+ UploadingPresentations
+ .remove({ $or: [{ temporaryPresentationId: p.temporaryPresentationId }, { id: p.id }] });
+ });
+
+ const uploadingPresentations = UploadingPresentations.find().fetch();
+
+ let presentationsToConvert = convertingPresentations.concat(uploadingPresentations);
+ // Updating or populating the "state" presentation list
+ presentationsToConvert.map((p) => (
+ {
+ filename: p.name || p.filename,
+ temporaryPresentationId: p.temporaryPresentationId,
+ presentationId: p.id,
+ hasError: p.conversion?.error || p.upload?.error,
+ lastModifiedUploader: p.lastModifiedUploader,
+ }
+ )).forEach((p) => {
+ const docIndexAlreadyInList = alreadyRenderedPresList.findIndex((pres) => (
+ (pres.temporaryPresentationId === p.temporaryPresentationId
+ || pres.presentationId === p.presentationId
+ || (
+ pres.lastModifiedUploader !== undefined
+ && !pres.lastModifiedUploader && pres.filename === p.filename
+ )
+ )
+ ));
+ if (docIndexAlreadyInList === -1) {
+ alreadyRenderedPresList.push({
+ filename: p.filename,
+ temporaryPresentationId: p.temporaryPresentationId,
+ presentationId: p.presentationId,
+ rendered: false,
+ lastModifiedUploader: p.lastModifiedUploader,
+ hasError: p.hasError,
+ });
+ } else {
+ const presAlreadyRendered = alreadyRenderedPresList[docIndexAlreadyInList];
+ presAlreadyRendered.temporaryPresentationId = p.temporaryPresentationId;
+ presAlreadyRendered.presentationId = p.presentationId;
+ presAlreadyRendered.lastModifiedUploader = p.lastModifiedUploader;
+ presAlreadyRendered.hasError = p.hasError;
+ }
+ });
+ let activeToast = Session.get('presentationUploaderToastId');
+ const showToast = presentationsToConvert.length > 0;
+
+ if (showToast && !activeToast) {
+ activeToast = toast.info(() => renderToastList(presentationsToConvert, intl), {
+ hideProgressBar: true,
+ autoClose: false,
+ newestOnTop: true,
+ closeOnClick: true,
+ className: 'presentationUploaderToast toastClass',
+ onClose: () => {
+ presentationsToConvert = [];
+ if (alreadyRenderedPresList.every((pres) => pres.rendered)) {
+ makeCall('setPresentationRenderedInToast').then(() => {
+ Session.set('presentationUploaderToastId', null);
+ });
+ alreadyRenderedPresList.length = 0;
+ }
+ },
+ });
+ Session.set('presentationUploaderToastId', activeToast);
+ } else if (!showToast && activeToast) {
+ handleDismissToast(activeToast);
+ Session.set('presentationUploaderToastId', null);
+ } else {
+ toast.update(activeToast, {
+ render: renderToastList(presentationsToConvert, intl),
+ });
+ }
+
+ const temporaryPresentationIdListToSetAsRendered = presentationsToConvert.filter((p) => (
+ 'conversion' in p && (p.conversion.done || p.conversion.error)
+ ));
+
+ temporaryPresentationIdListToSetAsRendered.forEach((p) => {
+ const index = alreadyRenderedPresList.findIndex((pres) => (
+ pres.temporaryPresentationId === p.temporaryPresentationId || pres.presentationId === p.id
+ ));
+ if (index !== -1) {
+ alreadyRenderedPresList[index].rendered = true;
+ }
+ });
+
+ if (alreadyRenderedPresList.every((pres) => pres.rendered && !pres.hasError)) {
+ setTimeout(() => {
+ makeCall('setPresentationRenderedInToast');
+ alreadyRenderedPresList.length = 0;
+ }, TIMEOUT_CLOSE_TOAST * 1000);
+ }
+ }, []);
+ return null;
+};
+
+export default {
+ handleDismissToast,
+ renderPresentationItemStatus,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/component.jsx
new file mode 100644
index 00000000..db256859
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/component.jsx
@@ -0,0 +1,509 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import deviceInfo from '/imports/utils/deviceInfo';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import {
+ HUNDRED_PERCENT,
+ MAX_PERCENT,
+ STEP,
+} from '/imports/utils/slideCalcUtils';
+import Styled from './styles';
+import ZoomTool from './zoom-tool/component';
+import SmartMediaShareContainer from './smart-video-share/container';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+import KEY_CODES from '/imports/utils/keyCodes';
+
+const intlMessages = defineMessages({
+ previousSlideLabel: {
+ id: 'app.presentation.presentationToolbar.prevSlideLabel',
+ description: 'Previous slide button label',
+ },
+ previousSlideDesc: {
+ id: 'app.presentation.presentationToolbar.prevSlideDesc',
+ description: 'Aria description for when switching to previous slide',
+ },
+ nextSlideLabel: {
+ id: 'app.presentation.presentationToolbar.nextSlideLabel',
+ description: 'Next slide button label',
+ },
+ nextSlideDesc: {
+ id: 'app.presentation.presentationToolbar.nextSlideDesc',
+ description: 'Aria description for when switching to next slide',
+ },
+ noNextSlideDesc: {
+ id: 'app.presentation.presentationToolbar.noNextSlideDesc',
+ description: '',
+ },
+ noPrevSlideDesc: {
+ id: 'app.presentation.presentationToolbar.noPrevSlideDesc',
+ description: '',
+ },
+ skipSlideLabel: {
+ id: 'app.presentation.presentationToolbar.skipSlideLabel',
+ description: 'Aria label for when switching to a specific slide',
+ },
+ skipSlideDesc: {
+ id: 'app.presentation.presentationToolbar.skipSlideDesc',
+ description: 'Aria description for when switching to a specific slide',
+ },
+ goToSlide: {
+ id: 'app.presentation.presentationToolbar.goToSlide',
+ description: 'button for slide select',
+ },
+ selectLabel: {
+ id: 'app.presentation.presentationToolbar.selectLabel',
+ description: 'slide select label',
+ },
+ fitToWidth: {
+ id: 'app.presentation.presentationToolbar.fitToWidth',
+ description: 'button for fit to width',
+ },
+ fitToWidthDesc: {
+ id: 'app.presentation.presentationToolbar.fitWidthDesc',
+ description: 'Aria description to display the whole width of the slide',
+ },
+ fitToPage: {
+ id: 'app.presentation.presentationToolbar.fitToPage',
+ description: 'button label for fit to width',
+ },
+ fitToPageDesc: {
+ id: 'app.presentation.presentationToolbar.fitScreenDesc',
+ description: 'Aria description to display the whole slide',
+ },
+ presentationLabel: {
+ id: 'app.presentationUploder.title',
+ description: 'presentation area element label',
+ },
+ toolbarMultiUserOn: {
+ id: 'app.whiteboard.toolbar.multiUserOn',
+ description: 'Whiteboard toolbar turn multi-user on menu',
+ },
+ toolbarMultiUserOff: {
+ id: 'app.whiteboard.toolbar.multiUserOff',
+ description: 'Whiteboard toolbar turn multi-user off menu',
+ },
+ pan: {
+ id: 'app.whiteboard.toolbar.tools.hand',
+ description: 'presentation toolbar pan label',
+ },
+});
+
+class PresentationToolbar extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ wasFTWActive: false,
+ };
+
+ this.setWasActive = this.setWasActive.bind(this);
+ this.handleFTWSlideChange = this.handleFTWSlideChange.bind(this);
+ this.handleSkipToSlideChange = this.handleSkipToSlideChange.bind(this);
+ this.change = this.change.bind(this);
+ this.renderAriaDescs = this.renderAriaDescs.bind(this);
+ this.nextSlideHandler = this.nextSlideHandler.bind(this);
+ this.previousSlideHandler = this.previousSlideHandler.bind(this);
+ this.fullscreenToggleHandler = this.fullscreenToggleHandler.bind(this);
+ this.switchSlide = this.switchSlide.bind(this);
+ this.handleSwitchWhiteboardMode = this.handleSwitchWhiteboardMode.bind(this);
+ }
+
+ componentDidMount() {
+ document.addEventListener('keydown', this.switchSlide);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { zoom, setIsPanning, fitToWidth, fitToWidthHandler, currentSlideNum } = this.props;
+ const { wasFTWActive } = this.state;
+
+ if (zoom <= HUNDRED_PERCENT && zoom !== prevProps.zoom && !fitToWidth) setIsPanning();
+
+ if ((prevProps?.currentSlideNum !== currentSlideNum) && (!fitToWidth && wasFTWActive)) {
+ setTimeout(() => {
+ fitToWidthHandler();
+ this.setWasActive(false);
+ }, 150)
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.switchSlide);
+ }
+
+ setWasActive(wasFTWActive) {
+ this.setState({ wasFTWActive });
+ }
+
+ handleFTWSlideChange() {
+ const { fitToWidth, fitToWidthHandler } = this.props;
+ if (fitToWidth) {
+ fitToWidthHandler();
+ this.setWasActive(fitToWidth);
+ }
+ }
+
+ handleSkipToSlideChange(event) {
+ const { skipToSlide, podId } = this.props;
+ const requestedSlideNum = Number.parseInt(event.target.value, 10);
+
+ this.handleFTWSlideChange();
+ if (event) event.currentTarget.blur();
+ skipToSlide(requestedSlideNum, podId);
+ }
+
+ handleSwitchWhiteboardMode() {
+ const {
+ multiUser,
+ whiteboardId,
+ removeWhiteboardGlobalAccess,
+ addWhiteboardGlobalAccess,
+ } = this.props;
+ if (multiUser) {
+ return removeWhiteboardGlobalAccess(whiteboardId);
+ }
+ return addWhiteboardGlobalAccess(whiteboardId);
+ }
+
+ fullscreenToggleHandler() {
+ const {
+ fullscreenElementId,
+ isFullscreen,
+ layoutContextDispatch,
+ fullscreenAction,
+ fullscreenRef,
+ handleToggleFullScreen,
+ } = this.props;
+
+ handleToggleFullScreen(fullscreenRef);
+ const newElement = isFullscreen ? '' : fullscreenElementId;
+
+ layoutContextDispatch({
+ type: fullscreenAction,
+ value: {
+ element: newElement,
+ group: '',
+ },
+ });
+ }
+
+ nextSlideHandler(event) {
+ const {
+ nextSlide, currentSlideNum, numberOfSlides, podId, endCurrentPoll
+ } = this.props;
+
+ this.handleFTWSlideChange();
+ if (event) event.currentTarget.blur();
+ endCurrentPoll();
+ nextSlide(currentSlideNum, numberOfSlides, podId);
+ }
+
+ previousSlideHandler(event) {
+ const {
+ previousSlide, currentSlideNum, podId, endCurrentPoll
+ } = this.props;
+
+ this.handleFTWSlideChange();
+ if (event) event.currentTarget.blur();
+ endCurrentPoll();
+ previousSlide(currentSlideNum, podId);
+ }
+
+ switchSlide(event) {
+ const { target, which } = event;
+ const isBody = target.nodeName === 'BODY';
+
+ if (isBody) {
+ switch (which) {
+ case KEY_CODES.ARROW_LEFT:
+ case KEY_CODES.PAGE_UP:
+ this.previousSlideHandler();
+ break;
+ case KEY_CODES.ARROW_RIGHT:
+ case KEY_CODES.PAGE_DOWN:
+ this.nextSlideHandler();
+ break;
+ case KEY_CODES.ENTER:
+ this.fullscreenToggleHandler();
+ break;
+ default:
+ }
+ }
+ }
+
+ change(value) {
+ const { zoomChanger } = this.props;
+ zoomChanger(value);
+ }
+
+ renderAriaDescs() {
+ const { intl } = this.props;
+ return (
+
+ {/* Aria description's for toolbar buttons */}
+
+ {intl.formatMessage(intlMessages.previousSlideDesc)}
+
+
+ {intl.formatMessage(intlMessages.noPrevSlideDesc)}
+
+
+ {intl.formatMessage(intlMessages.nextSlideDesc)}
+
+
+ {intl.formatMessage(intlMessages.noNextSlideDesc)}
+
+
+ {intl.formatMessage(intlMessages.skipSlideDesc)}
+
+
+ {intl.formatMessage(intlMessages.fitToWidthDesc)}
+
+
+ {intl.formatMessage(intlMessages.fitToPageDesc)}
+
+
+ );
+ }
+
+ renderSkipSlideOpts(numberOfSlides) {
+ // Fill drop down menu with all the slides in presentation
+ const { intl } = this.props;
+ const optionList = [];
+ for (let i = 1; i <= numberOfSlides; i += 1) {
+ optionList.push(
+
+ {intl.formatMessage(intlMessages.goToSlide, { 0: i })}
+ ,
+ );
+ }
+
+ return optionList;
+ }
+
+ render() {
+ const {
+ currentSlideNum,
+ numberOfSlides,
+ fitToWidthHandler,
+ fitToWidth,
+ intl,
+ zoom,
+ isMeteorConnected,
+ isPollingEnabled,
+ amIPresenter,
+ currentSlidHasContent,
+ parseCurrentSlideContent,
+ startPoll,
+ currentSlide,
+ slidePosition,
+ multiUserSize,
+ multiUser,
+ } = this.props;
+
+ const { isMobile } = deviceInfo;
+
+ const startOfSlides = !(currentSlideNum > 1);
+ const endOfSlides = !(currentSlideNum < numberOfSlides);
+
+ const prevSlideAriaLabel = startOfSlides
+ ? intl.formatMessage(intlMessages.previousSlideLabel)
+ : `${intl.formatMessage(intlMessages.previousSlideLabel)} (${currentSlideNum <= 1 ? '' : currentSlideNum - 1
+ })`;
+
+ const nextSlideAriaLabel = endOfSlides
+ ? intl.formatMessage(intlMessages.nextSlideLabel)
+ : `${intl.formatMessage(intlMessages.nextSlideLabel)} (${currentSlideNum >= 1 ? currentSlideNum + 1 : ''
+ })`;
+
+ return (
+
+ {this.renderAriaDescs()}
+
+ {isPollingEnabled ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {this.renderSkipSlideOpts(numberOfSlides)}
+
+
+
+
+
+ this.handleSwitchWhiteboardMode(!multiUser)}
+ label={
+ multiUser
+ ? intl.formatMessage(intlMessages.toolbarMultiUserOff)
+ : intl.formatMessage(intlMessages.toolbarMultiUserOn)
+ }
+ hideLabel
+ />
+ {multiUser ? (
+ {multiUserSize}
+ ) : (
+
+ )}
+ {!isMobile ? (
+
+
+
+ ) : null}
+
+
+
+ );
+ }
+}
+
+PresentationToolbar.propTypes = {
+ // The Id for the current pod. Should always be default pod
+ podId: PropTypes.string.isRequired,
+ // Number of current slide being displayed
+ currentSlideNum: PropTypes.number.isRequired,
+ // Total number of slides in this presentation
+ numberOfSlides: PropTypes.number.isRequired,
+ // Actions required for the presenter toolbar
+ nextSlide: PropTypes.func.isRequired,
+ previousSlide: PropTypes.func.isRequired,
+ skipToSlide: PropTypes.func.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ zoomChanger: PropTypes.func.isRequired,
+ fitToWidthHandler: PropTypes.func.isRequired,
+ fitToWidth: PropTypes.bool.isRequired,
+ zoom: PropTypes.number.isRequired,
+ isMeteorConnected: PropTypes.bool.isRequired,
+ fullscreenElementId: PropTypes.string.isRequired,
+ fullscreenAction: PropTypes.string.isRequired,
+ isFullscreen: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ setIsPanning: PropTypes.func.isRequired,
+ multiUser: PropTypes.bool.isRequired,
+ whiteboardId: PropTypes.string.isRequired,
+ removeWhiteboardGlobalAccess: PropTypes.func.isRequired,
+ addWhiteboardGlobalAccess: PropTypes.func.isRequired,
+ fullscreenRef: PropTypes.instanceOf(Element),
+ handleToggleFullScreen: PropTypes.func.isRequired,
+ isPollingEnabled: PropTypes.bool.isRequired,
+ amIPresenter: PropTypes.bool.isRequired,
+ currentSlidHasContent: PropTypes.bool.isRequired,
+ parseCurrentSlideContent: PropTypes.func.isRequired,
+ startPoll: PropTypes.func.isRequired,
+ currentSlide: PropTypes.shape().isRequired,
+ slidePosition: PropTypes.shape().isRequired,
+ multiUserSize: PropTypes.number.isRequired,
+};
+
+PresentationToolbar.defaultProps = {
+ fullscreenRef: null,
+};
+
+export default injectWbResizeEvent(injectIntl(PresentationToolbar));
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/container.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/container.jsx
new file mode 100644
index 00000000..f4280fd1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/container.jsx
@@ -0,0 +1,91 @@
+import React, { useContext } from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import PresentationService from '/imports/ui/components/presentation/service';
+import PollService from '/imports/ui/components/poll/service';
+import { makeCall } from '/imports/ui/services/api';
+import PresentationToolbar from './component';
+import PresentationToolbarService from './service';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import Auth from '/imports/ui/services/auth';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import { isPollingEnabled } from '/imports/ui/services/features';
+import { CurrentPoll } from '/imports/api/polls';
+
+const PresentationToolbarContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const userIsPresenter = currentUser.presenter;
+
+ const { layoutSwapped } = props;
+
+ const handleToggleFullScreen = (ref) => FullscreenService.toggleFullScreen(ref);
+
+ const endCurrentPoll = () => {
+ if (CurrentPoll.findOne({ meetingId: Auth.meetingID })) makeCall('stopPoll');
+ };
+
+ if (userIsPresenter && !layoutSwapped) {
+ // Only show controls if user is presenter and layout isn't swapped
+
+ return (
+
+ );
+ }
+ return null;
+};
+
+export default withTracker((params) => {
+ const {
+ podId,
+ presentationId,
+ } = params;
+
+ const startPoll = (type, id, answers = [], question = '', multiResp = false) => {
+ Session.set('openPanel', 'poll');
+ Session.set('forcePollOpen', true);
+ window.dispatchEvent(new Event('panelChanged'));
+
+ makeCall('startPoll', PollService.pollTypes, type, id, false, question, multiResp, answers);
+ };
+
+ return {
+ numberOfSlides: PresentationToolbarService.getNumberOfSlides(podId, presentationId),
+ nextSlide: PresentationToolbarService.nextSlide,
+ previousSlide: PresentationToolbarService.previousSlide,
+ skipToSlide: PresentationToolbarService.skipToSlide,
+ isMeteorConnected: Meteor.status().connected,
+ isPollingEnabled: isPollingEnabled(),
+ currentSlidHasContent: PresentationService.currentSlidHasContent(),
+ parseCurrentSlideContent: PresentationService.parseCurrentSlideContent,
+ startPoll,
+ };
+})(PresentationToolbarContainer);
+
+PresentationToolbarContainer.propTypes = {
+ // Number of current slide being displayed
+ currentSlideNum: PropTypes.number.isRequired,
+ zoom: PropTypes.number.isRequired,
+ zoomChanger: PropTypes.func.isRequired,
+
+ // Total number of slides in this presentation
+ numberOfSlides: PropTypes.number.isRequired,
+
+ // Actions required for the presenter toolbar
+ nextSlide: PropTypes.func.isRequired,
+ previousSlide: PropTypes.func.isRequired,
+ skipToSlide: PropTypes.func.isRequired,
+ layoutSwapped: PropTypes.bool,
+};
+
+PresentationToolbarContainer.defaultProps = {
+ layoutSwapped: false,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/service.js b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/service.js
new file mode 100644
index 00000000..efb85325
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/service.js
@@ -0,0 +1,46 @@
+import Auth from '/imports/ui/services/auth';
+import Presentations from '/imports/api/presentations';
+import { makeCall } from '/imports/ui/services/api';
+import { throttle } from '/imports/utils/throttle';
+
+const PAN_ZOOM_INTERVAL = Meteor.settings.public.presentation.panZoomInterval || 200;
+
+const getNumberOfSlides = (podId, presentationId) => {
+ const meetingId = Auth.meetingID;
+
+ const presentation = Presentations.findOne({
+ meetingId,
+ podId,
+ id: presentationId,
+ });
+
+ return presentation && presentation.pages ? presentation.pages.length : 0;
+};
+
+const previousSlide = (currentSlideNum, podId) => {
+ if (currentSlideNum > 1) {
+ makeCall('switchSlide', currentSlideNum - 1, podId);
+ }
+};
+
+const nextSlide = (currentSlideNum, numberOfSlides, podId) => {
+ if (currentSlideNum < numberOfSlides) {
+ makeCall('switchSlide', currentSlideNum + 1, podId);
+ }
+};
+
+const zoomSlide = throttle((currentSlideNum, podId, widthRatio, heightRatio, xOffset, yOffset) => {
+ makeCall('zoomSlide', currentSlideNum, podId, widthRatio, heightRatio, xOffset, yOffset);
+}, PAN_ZOOM_INTERVAL);
+
+const skipToSlide = (requestedSlideNum, podId) => {
+ makeCall('switchSlide', requestedSlideNum, podId);
+};
+
+export default {
+ getNumberOfSlides,
+ nextSlide,
+ previousSlide,
+ skipToSlide,
+ zoomSlide,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx
new file mode 100644
index 00000000..8d4f4700
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/component.jsx
@@ -0,0 +1,92 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import { safeMatch } from '/imports/utils/string-utils';
+import { isUrlValid, startWatching } from '/imports/ui/components/external-video-player/service';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ externalVideo: {
+ id: 'app.smartMediaShare.externalVideo',
+ },
+});
+
+const createAction = (url) => {
+ const hasHttps = url?.startsWith("https://");
+ const finalUrl = hasHttps ? url : `https://${url}`;
+ const label = hasHttps ? url?.replace("https://", "") : url;
+
+ if (isUrlValid(finalUrl)) {
+ return {
+ label,
+ onClick: () => startWatching(finalUrl),
+ };
+ }
+};
+
+export const SmartMediaShare = (props) => {
+ const {
+ currentSlide, intl, isMobile, isRTL,
+ } = props;
+ const linkPatt = /(https?:\/\/.*?)(?=\s|$)/g;
+ const externalLinks = safeMatch(linkPatt, currentSlide?.content?.replace(/[\r\n]/g, ' '), false);
+ if (!externalLinks) return null;
+
+ const actions = [];
+
+ externalLinks?.forEach((l) => {
+ const action = createAction(l);
+ if (action) actions.push(action);
+ });
+
+ if (actions?.length === 0) return null;
+
+ const customStyles = { top: '-1rem' };
+
+ return (
+ null}
+ hideLabel
+ />
+ )}
+ actions={actions}
+ opts={{
+ id: 'external-video-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ />
+ );
+};
+
+export default SmartMediaShare;
+
+SmartMediaShare.propTypes = {
+ currentSlide: PropTypes.shape({
+ content: PropTypes.string.isRequired,
+ }),
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isMobile: PropTypes.bool.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+};
+
+SmartMediaShare.defaultProps = {
+ currentSlide: undefined,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx
new file mode 100644
index 00000000..0a28ce00
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/container.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { SmartMediaShare } from './component';
+
+import { layoutSelect } from '/imports/ui/components/layout/context';
+import { isMobile } from '/imports/ui/components/layout/utils';
+
+const SmartMediaShareContainer = (props) => (
+
+);
+
+export default withTracker(() => {
+ const isRTL = layoutSelect((i) => i.isRTL);
+ return {
+ isRTL,
+ isMobile: isMobile(),
+ };
+})(SmartMediaShareContainer);
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/styles.js
new file mode 100644
index 00000000..61cbaa30
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/smart-video-share/styles.js
@@ -0,0 +1,25 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+
+const QuickVideoButton = styled(Button)`
+ margin-left: .5rem;
+
+ i {
+ color: unset;
+ font-size: 1rem;
+ padding-left: 20%;
+ right: 2px;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+`;
+
+export default {
+ QuickVideoButton,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/styles.js
new file mode 100644
index 00000000..27882f37
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/styles.js
@@ -0,0 +1,297 @@
+import styled from 'styled-components';
+import QuickPollDropdownContainer from '/imports/ui/components/actions-bar/quick-poll-dropdown/container';
+import {
+ colorOffWhite,
+ colorBlueLightest,
+ toolbarButtonColor,
+ colorDanger,
+ colorWhite,
+ colorGrayDark,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ whiteboardToolbarMargin,
+ whiteboardToolbarPaddingSm,
+ whiteboardToolbarPadding,
+ borderSize,
+ smPaddingY,
+ borderSizeLarge,
+ smPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import Button from '/imports/ui/components/common/button/component';
+
+const PresentationToolbarWrapper = styled.div`
+ position: absolute;
+ align-self: center;
+ z-index: 1;
+ background-color: ${colorOffWhite};
+ border-top: 1px solid ${colorBlueLightest};
+ width: 100%;
+ bottom: 0px;
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ padding: 2px;
+ ${({ isMobile }) => (isMobile
+ ? 'overflow: auto;'
+ : 'min-width: fit-content;'
+ )};
+
+ select {
+ &:-moz-focusring {
+ outline: none;
+ }
+ border: 0;
+ background-color: ${colorOffWhite};
+ cursor: pointer;
+ margin: 0 ${whiteboardToolbarMargin} 0 0;
+ padding: ${whiteboardToolbarPadding};
+ padding-left: ${whiteboardToolbarPaddingSm};
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${whiteboardToolbarMargin};
+ padding: ${whiteboardToolbarPadding};
+ padding-right: ${whiteboardToolbarPaddingSm};
+ }
+
+ & > option {
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ }
+ }
+
+ i {
+ color: ${toolbarButtonColor};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+`;
+
+const QuickPollButton = styled(QuickPollDropdownContainer)`
+ position: relative;
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ border-radius: 0;
+ box-shadow: none !important;
+ border: 0;
+
+ &:focus {
+ background-color: ${colorOffWhite};
+ }
+`;
+
+const QuickPollButtonWrapper = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const PresentationSlideControls = styled.div`
+ justify-content: center;
+ padding-left: ${whiteboardToolbarPadding};
+ padding-right: ${whiteboardToolbarPadding};
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ & > button {
+ padding: ${whiteboardToolbarPadding};
+ }
+`;
+
+const PrevSlideButton = styled(Button)`
+ i {
+ font-size: 1rem;
+ padding-left: 20%;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+`;
+
+const NextSlideButton = styled(Button)`
+ i {
+ font-size: 1rem;
+ padding-left: 60%;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+`;
+
+const SkipSlideSelect = styled.select`
+ padding: 0 ${smPaddingY};
+ margin: ${borderSize};
+ margin-left: ${whiteboardToolbarMargin};
+
+ [dir="rtl"] & {
+ margin: ${borderSize};
+ margin-right: ${whiteboardToolbarMargin};
+ }
+
+ &:-moz-focusring {
+ outline: none;
+ }
+
+ &:focus,
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ background-color: #DCE4EC;
+ border-radius: 4px;
+ }
+
+ &:focus {
+ outline-style: solid;
+ box-shadow: 0 0 0 1px #cdd6e0 !important;
+ }
+`;
+
+const PresentationZoomControls = styled.div`
+ justify-content: flex-end;
+ padding: 0 ${whiteboardToolbarPadding} 0 0;
+
+ [dir="rtl"] & {
+ padding: 0 0 0 ${whiteboardToolbarPadding};
+ }
+
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ button {
+ padding: ${whiteboardToolbarPadding};
+ }
+
+ i {
+ font-size: 1.2rem;
+ }
+`;
+
+const FitToWidthButton = styled(Button)`
+ border: none !important;
+
+ & > i {
+ font-size: 1.2rem;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+
+ margin-left: ${whiteboardToolbarMargin};
+ margin-right: ${whiteboardToolbarMargin};
+
+ position: relative;
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ border-radius: 0;
+ box-shadow: none !important;
+ border: 0;
+
+ ${({ $fitToWidth }) => $fitToWidth && `
+ & > span {
+ border: solid ${borderSizeLarge} ${colorGrayDark};
+ }
+ `}
+
+ &:focus {
+ background-color: ${colorOffWhite};
+ border: 0;
+ }
+`;
+
+const MultiUserTool = styled.span`
+ background-color: ${colorDanger};
+ border-radius: 50%;
+ width: 1rem;
+ height: 1rem;
+ position: relative;
+ z-index: 2;
+ bottom: 0.5rem;
+ color: ${colorWhite};
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-shadow: 1px 1px ${borderSizeLarge} ${colorGrayDark};
+ font-size: ${smPaddingX};
+
+ [dir="ltr"] & {
+ right: 1rem;
+ }
+
+ [dir="rtl"] & {
+ left: 1rem;
+ }
+`;
+
+const MUTPlaceholder = styled.div`
+ width: 1rem;
+ height: 1rem;
+ position: relative;
+ bottom: 0.5rem;
+
+ [dir="ltr"] & {
+ right: 1rem;
+ }
+
+ [dir="rtl"] & {
+ left: 1rem;
+ }
+`;
+
+const WBAccessButton = styled(Button)`
+ border: none !important;
+
+ i {
+ font-size: 1.2rem;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+
+ position: relative;
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ border-radius: 0;
+ box-shadow: none !important;
+ border: 0;
+
+ &:focus {
+ background-color: ${colorOffWhite};
+ border: 0;
+ }
+`;
+
+export default {
+ PresentationToolbarWrapper,
+ QuickPollButton,
+ QuickPollButtonWrapper,
+ PresentationSlideControls,
+ PrevSlideButton,
+ NextSlideButton,
+ SkipSlideSelect,
+ PresentationZoomControls,
+ FitToWidthButton,
+ MultiUserTool,
+ WBAccessButton,
+ MUTPlaceholder,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/component.jsx
new file mode 100644
index 00000000..f385ffba
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/component.jsx
@@ -0,0 +1,254 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import HoldButton from './holdButton/component';
+
+const DELAY_MILLISECONDS = 200;
+const STEP_TIME = 100;
+
+const intlMessages = defineMessages({
+ resetZoomLabel: {
+ id: 'app.presentation.presentationToolbar.zoomReset',
+ description: 'Reset zoom button label',
+ },
+ zoomInLabel: {
+ id: 'app.presentation.presentationToolbar.zoomInLabel',
+ description: 'Aria label for increment zoom level',
+ },
+ zoomInDesc: {
+ id: 'app.presentation.presentationToolbar.zoomInDesc',
+ description: 'Aria description for increment zoom level',
+ },
+ zoomOutLabel: {
+ id: 'app.presentation.presentationToolbar.zoomOutLabel',
+ description: 'Aria label for decrement zoom level',
+ },
+ zoomOutDesc: {
+ id: 'app.presentation.presentationToolbar.zoomOutDesc',
+ description: 'Aria description for decrement zoom level',
+ },
+ currentValue: {
+ id: 'app.submenu.application.currentSize',
+ description: 'current presentation zoom percentage aria description',
+ },
+});
+
+class ZoomTool extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.increment = this.increment.bind(this);
+ this.decrement = this.decrement.bind(this);
+ this.mouseDownHandler = this.mouseDownHandler.bind(this);
+ this.mouseUpHandler = this.mouseUpHandler.bind(this);
+ this.execInterval = this.execInterval.bind(this);
+ this.onChanger = this.onChanger.bind(this);
+ this.setInt = 0;
+ this.state = {
+ stateZoomValue: props.zoomValue,
+ initialstateZoomValue: props.zoomValue,
+ mouseHolding: false,
+ };
+ }
+
+ componentDidUpdate() {
+ const { zoomValue } = this.props;
+ const { stateZoomValue } = this.state;
+ const isDifferent = zoomValue !== stateZoomValue;
+ if (isDifferent) {
+ this.onChanger(zoomValue);
+ }
+ }
+
+ onChanger(value) {
+ const {
+ maxBound,
+ minBound,
+ change,
+ zoomValue,
+ } = this.props;
+ const { stateZoomValue } = this.state;
+ let newValue = value;
+ const isDifferent = newValue !== stateZoomValue;
+
+ if (newValue <= minBound) {
+ newValue = minBound;
+ } else if (newValue >= maxBound) {
+ newValue = maxBound;
+ }
+
+ const propsIsDifferente = zoomValue !== newValue;
+ if (isDifferent && propsIsDifferente) {
+ this.setState({ stateZoomValue: newValue }, () => {
+ change(newValue);
+ });
+ }
+ if (isDifferent && !propsIsDifferente) this.setState({ stateZoomValue: newValue });
+ }
+
+ increment() {
+ const {
+ step,
+ } = this.props;
+ const { stateZoomValue } = this.state;
+ const increaseZoom = stateZoomValue + step;
+ this.onChanger(increaseZoom);
+ }
+
+ decrement() {
+ const {
+ step,
+ } = this.props;
+ const { stateZoomValue } = this.state;
+ const decreaseZoom = stateZoomValue - step;
+ this.onChanger(decreaseZoom);
+ }
+
+ execInterval(inc) {
+ const { mouseHolding } = this.state;
+ const exec = inc ? this.increment : this.decrement;
+
+ const interval = () => {
+ clearInterval(this.setInt);
+ this.setInt = setInterval(exec, STEP_TIME);
+ };
+
+ setTimeout(() => {
+ if (mouseHolding) {
+ interval();
+ }
+ }, DELAY_MILLISECONDS);
+ }
+
+ mouseDownHandler(bool) {
+ this.setState({
+ mouseHolding: true,
+ }, () => {
+ this.execInterval(bool);
+ });
+ }
+
+ mouseUpHandler() {
+ this.setState({
+ mouseHolding: false,
+ }, () => clearInterval(this.setInt));
+ }
+
+ resetZoom() {
+ const { stateZoomValue, initialstateZoomValue } = this.state;
+ if (stateZoomValue !== initialstateZoomValue) this.onChanger(initialstateZoomValue);
+ }
+
+ render() {
+ const {
+ zoomValue,
+ minBound,
+ maxBound,
+ intl,
+ isMeteorConnected,
+ step,
+ } = this.props;
+ const { stateZoomValue } = this.state;
+
+ let zoomOutAriaLabel = intl.formatMessage(intlMessages.zoomOutLabel);
+ if (zoomValue > minBound) {
+ zoomOutAriaLabel += ` ${intl.formatNumber(((zoomValue - step) / 100), { style: 'percent' })}`;
+ }
+
+ let zoomInAriaLabel = intl.formatMessage(intlMessages.zoomInLabel);
+ if (zoomValue < maxBound) {
+ zoomInAriaLabel += ` ${intl.formatNumber(((zoomValue + step) / 100), { style: 'percent' })}`;
+ }
+
+ const stateZoomPct = intl.formatNumber((stateZoomValue / 100), { style: 'percent' });
+
+ return (
+ [
+ (
+
+ { }}
+ disabled={(zoomValue <= minBound) || !isMeteorConnected}
+ hideLabel
+ />
+ {intl.formatMessage(intlMessages.zoomOutDesc)}
+
+ ),
+ (
+
+ this.resetZoom()}
+ label={intl.formatMessage(intlMessages.resetZoomLabel)}
+ data-test="resetZoomButton"
+ hideLabel
+ />
+
+ {intl.formatMessage(intlMessages.currentValue, ({ 0: stateZoomPct }))}
+
+
+ ),
+ (
+
+ { }}
+ disabled={(zoomValue >= maxBound) || !isMeteorConnected}
+ hideLabel
+ />
+ {intl.formatMessage(intlMessages.zoomInDesc)}
+
+ ),
+ ]
+ );
+ }
+}
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ formatNumber: PropTypes.func.isRequired,
+ }).isRequired,
+ zoomValue: PropTypes.number.isRequired,
+ change: PropTypes.func.isRequired,
+ minBound: PropTypes.number.isRequired,
+ maxBound: PropTypes.number.isRequired,
+ step: PropTypes.number.isRequired,
+ isMeteorConnected: PropTypes.bool.isRequired,
+};
+
+ZoomTool.propTypes = propTypes;
+
+export default injectIntl(ZoomTool);
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/holdButton/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/holdButton/component.jsx
new file mode 100644
index 00000000..ae1026f3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/holdButton/component.jsx
@@ -0,0 +1,113 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const DELAY_MILLISECONDS = 300;
+const STEP_TIME = 100;
+
+class HoldDownButton extends PureComponent {
+ constructor(props) {
+ super(props);
+ this.mouseDownHandler = this.mouseDownHandler.bind(this);
+ this.mouseUpHandler = this.mouseUpHandler.bind(this);
+ this.touchStart = this.touchStart.bind(this);
+ this.touchEnd = this.touchEnd.bind(this);
+ this.execInterval = this.execInterval.bind(this);
+ this.onClick = this.onClick.bind(this);
+ this.setInt = 0;
+ this.state = {
+ mouseHolding: false,
+ };
+ }
+
+ onClick() {
+ const {
+ exec,
+ minBound,
+ maxBound,
+ value,
+ } = this.props;
+ const bounds = (value === maxBound) || (value === minBound);
+ if (bounds) return;
+ exec();
+ }
+
+ execInterval() {
+ const interval = () => {
+ clearInterval(this.setInt);
+ this.setInt = setInterval(this.onClick, STEP_TIME);
+ };
+
+ setTimeout(() => {
+ const { mouseHolding } = this.state;
+ if (mouseHolding) interval();
+ }, DELAY_MILLISECONDS);
+ }
+
+ mouseDownHandler() {
+ this.setState({ mouseHolding: true }, this.execInterval());
+ }
+
+ mouseUpHandler() {
+ this.setState({ mouseHolding: false }, clearInterval(this.setInt));
+ }
+
+ touchStart() {
+ this.setState({ mouseHolding: true }, this.execInterval());
+ }
+
+ touchEnd() {
+ this.setState({ mouseHolding: false }, clearInterval(this.setInt));
+ }
+
+ render() {
+ const {
+ uniqueKey,
+ className,
+ children,
+ } = this.props;
+
+ return (
+ {}}
+ className={className}
+ tabIndex={-1}
+ >
+ {children}
+
+ );
+ }
+}
+
+const defaultProps = {
+ className: null,
+ exec: () => {},
+ minBound: null,
+ maxBound: Infinity,
+ uniqueKey: uniqueId('holdButton-'),
+ value: 0,
+};
+
+const propTypes = {
+ uniqueKey: PropTypes.string,
+ exec: PropTypes.func,
+ minBound: PropTypes.number,
+ maxBound: PropTypes.number,
+ value: PropTypes.number,
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired,
+};
+
+HoldDownButton.propTypes = propTypes;
+HoldDownButton.defaultProps = defaultProps;
+
+export default HoldDownButton;
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/styles.js
new file mode 100644
index 00000000..07ffcd80
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-toolbar/zoom-tool/styles.js
@@ -0,0 +1,56 @@
+import styled from 'styled-components';
+import {
+ colorOffWhite,
+ toolbarButtonColor,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ whiteboardToolbarMargin,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import Button from '/imports/ui/components/common/button/component';
+
+const DecreaseZoomButton = styled(Button)``;
+
+const IncreaseZoomButton = styled(Button)``;
+
+const ResetZoomButton = styled(Button)`
+ text-align: center;
+ color: black;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 0 !important;
+ font-weight: 200;
+ margin-left: ${whiteboardToolbarMargin};
+ margin-right: ${whiteboardToolbarMargin};
+ position: relative;
+ color: ${toolbarButtonColor};
+ background-color: ${colorOffWhite};
+ border-radius: 0;
+ box-shadow: none !important;
+ border: 0;
+
+ &:focus,
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ background-color: #DCE4EC;
+ border-radius: 4px;
+ }
+
+ &:hover {
+ opacity: .8;
+ }
+
+ &:focus {
+ outline-style: solid;
+ box-shadow: 0 0 0 1px #cdd6e0 !important;
+ }
+`;
+
+export default {
+ DecreaseZoomButton,
+ IncreaseZoomButton,
+ ResetZoomButton,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/component.jsx
new file mode 100644
index 00000000..ce967328
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/component.jsx
@@ -0,0 +1,1301 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { PresentationUploaderToast } from '/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component';
+import { TAB } from '/imports/utils/keyCodes';
+import deviceInfo from '/imports/utils/deviceInfo';
+import Button from '/imports/ui/components/common/button/component';
+import Icon from '/imports/ui/components/common/icon/component';
+import update from 'immutability-helper';
+import logger from '/imports/startup/client/logger';
+import { notify } from '/imports/ui/services/notification';
+import { toast } from 'react-toastify';
+import { registerTitleView, unregisterTitleView } from '/imports/utils/dom-utils';
+import Styled from './styles';
+import PresentationDownloadDropdown from './presentation-download-dropdown/component';
+import Settings from '/imports/ui/services/settings';
+import Radio from '/imports/ui/components/common/radio/component';
+import { unique } from 'radash';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+
+const { isMobile } = deviceInfo;
+const propTypes = {
+ allowDownloadOriginal: PropTypes.bool.isRequired,
+ allowDownloadConverted: PropTypes.bool.isRequired,
+ allowDownloadWithAnnotations: PropTypes.bool.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ fileUploadConstraintsHint: PropTypes.bool.isRequired,
+ fileSizeMax: PropTypes.number.isRequired,
+ filePagesMax: PropTypes.number.isRequired,
+ handleSave: PropTypes.func.isRequired,
+ dispatchChangePresentationDownloadable: PropTypes.func.isRequired,
+ fileValidMimeTypes: PropTypes.arrayOf(PropTypes.shape).isRequired,
+ presentations: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ filename: PropTypes.string.isRequired,
+ isCurrent: PropTypes.bool.isRequired,
+ conversion: PropTypes.shape,
+ upload: PropTypes.shape,
+ })).isRequired,
+ currentPresentation: PropTypes.string.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ handleFiledrop: PropTypes.func.isRequired,
+ selectedToBeNextCurrent: PropTypes.string,
+ renderPresentationItemStatus: PropTypes.func.isRequired,
+ externalUploadData: PropTypes.shape({
+ presentationUploadExternalDescription: PropTypes.string.isRequired,
+ presentationUploadExternalUrl: PropTypes.string.isRequired,
+ }).isRequired,
+ isPresenter: PropTypes.bool.isRequired,
+ exportPresentation: PropTypes.func.isRequired,
+ hasAnnotations: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ selectedToBeNextCurrent: '',
+};
+
+const intlMessages = defineMessages({
+ currentBadge: {
+ id: 'app.presentationUploder.currentBadge',
+ },
+ title: {
+ id: 'app.presentationUploder.title',
+ description: 'title of the modal',
+ },
+ message: {
+ id: 'app.presentationUploder.message',
+ description: 'message warning the types of files accepted',
+ },
+ uploadLabel: {
+ id: 'app.presentationUploder.uploadLabel',
+ description: 'confirm label when presentations are to be uploaded',
+ },
+ confirmLabel: {
+ id: 'app.presentationUploder.confirmLabel',
+ description: 'confirm label when no presentations are to be uploaded',
+ },
+ confirmDesc: {
+ id: 'app.presentationUploder.confirmDesc',
+ description: 'description of the confirm',
+ },
+ dismissLabel: {
+ id: 'app.presentationUploder.dismissLabel',
+ description: 'used in the button that close modal',
+ },
+ dismissDesc: {
+ id: 'app.presentationUploder.dismissDesc',
+ description: 'description of the dismiss',
+ },
+ dropzoneLabel: {
+ id: 'app.presentationUploder.dropzoneLabel',
+ description: 'message warning where drop files for upload',
+ },
+ externalUploadTitle: {
+ id: 'app.presentationUploder.externalUploadTitle',
+ description: 'title for external upload area',
+ },
+ externalUploadLabel: {
+ id: 'app.presentationUploder.externalUploadLabel',
+ description: 'message of external upload button',
+ },
+ dropzoneImagesLabel: {
+ id: 'app.presentationUploder.dropzoneImagesLabel',
+ description: 'message warning where drop images for upload',
+ },
+ browseFilesLabel: {
+ id: 'app.presentationUploder.browseFilesLabel',
+ description: 'message use on the file browser',
+ },
+ browseImagesLabel: {
+ id: 'app.presentationUploder.browseImagesLabel',
+ description: 'message use on the image browser',
+ },
+ fileToUpload: {
+ id: 'app.presentationUploder.fileToUpload',
+ description: 'message used in the file selected for upload',
+ },
+ extraHint: {
+ id: 'app.presentationUploder.extraHint',
+ description: 'message used to indicate upload file max sizes',
+ },
+ rejectedError: {
+ id: 'app.presentationUploder.rejectedError',
+ description: 'some files rejected, please check the file mime types',
+ },
+ badConnectionError: {
+ id: 'app.presentationUploder.connectionClosedError',
+ description: 'message indicating that the connection was closed',
+ },
+ uploadProcess: {
+ id: 'app.presentationUploder.upload.progress',
+ description: 'message that indicates the percentage of the upload',
+ },
+ 413: {
+ id: 'app.presentationUploder.upload.413',
+ description: 'error that file exceed the size limit',
+ },
+ 408: {
+ id: 'app.presentationUploder.upload.408',
+ description: 'error for token request timeout',
+ },
+ 404: {
+ id: 'app.presentationUploder.upload.404',
+ description: 'error not found',
+ },
+ 401: {
+ id: 'app.presentationUploder.upload.401',
+ description: 'error for failed upload token request.',
+ },
+ conversionProcessingSlides: {
+ id: 'app.presentationUploder.conversion.conversionProcessingSlides',
+ description: 'indicates how many slides were converted',
+ },
+ genericError: {
+ id: 'app.presentationUploder.genericError',
+ description: 'generic error while uploading/converting',
+ },
+ genericConversionStatus: {
+ id: 'app.presentationUploder.conversion.genericConversionStatus',
+ description: 'indicates that file is being converted',
+ },
+ TIMEOUT: {
+ id: 'app.presentationUploder.conversion.timeout',
+ },
+ CONVERSION_TIMEOUT: {
+ id: 'app.presentationUploder.conversion.conversionTimeout',
+ description: 'warns the user that the presentation timed out in the back-end in specific page of the document',
+ },
+ GENERATING_THUMBNAIL: {
+ id: 'app.presentationUploder.conversion.generatingThumbnail',
+ description: 'indicatess that it is generating thumbnails',
+ },
+ GENERATING_SVGIMAGES: {
+ id: 'app.presentationUploder.conversion.generatingSvg',
+ description: 'warns that it is generating svg images',
+ },
+ GENERATED_SLIDE: {
+ id: 'app.presentationUploder.conversion.generatedSlides',
+ description: 'warns that were slides generated',
+ },
+ PAGE_COUNT_EXCEEDED: {
+ id: 'app.presentationUploder.conversion.pageCountExceeded',
+ description: 'warns the user that the conversion failed because of the page count',
+ },
+ PDF_HAS_BIG_PAGE: {
+ id: 'app.presentationUploder.conversion.pdfHasBigPage',
+ description: 'warns the user that the conversion failed because of the pdf page siz that exceeds the allowed limit',
+ },
+ OFFICE_DOC_CONVERSION_INVALID: {
+ id: 'app.presentationUploder.conversion.officeDocConversionInvalid',
+ description: '',
+ },
+ OFFICE_DOC_CONVERSION_FAILED: {
+ id: 'app.presentationUploder.conversion.officeDocConversionFailed',
+ description: 'warns the user that the conversion failed because of wrong office file',
+ },
+ UNSUPPORTED_DOCUMENT: {
+ id: 'app.presentationUploder.conversion.unsupportedDocument',
+ description: 'warns the user that the file extension is not supported',
+ },
+ isDownloadable: {
+ id: 'app.presentationUploder.isDownloadableLabel',
+ description: 'presentation is available for downloading by all viewers',
+ },
+ isNotDownloadable: {
+ id: 'app.presentationUploder.isNotDownloadableLabel',
+ description: 'presentation is not available for downloading the viewers',
+ },
+ removePresentation: {
+ id: 'app.presentationUploder.removePresentationLabel',
+ description: 'select to delete this presentation',
+ },
+ setAsCurrentPresentation: {
+ id: 'app.presentationUploder.setAsCurrentPresentation',
+ description: 'set this presentation to be the current one',
+ },
+ status: {
+ id: 'app.presentationUploder.tableHeading.status',
+ description: 'aria label status table heading',
+ },
+ options: {
+ id: 'app.presentationUploder.tableHeading.options',
+ description: 'aria label for options table heading',
+ },
+ filename: {
+ id: 'app.presentationUploder.tableHeading.filename',
+ description: 'aria label for file name table heading',
+ },
+ uploading: {
+ id: 'app.presentationUploder.uploading',
+ description: 'uploading label for toast notification',
+ },
+ uploadStatus: {
+ id: 'app.presentationUploder.uploadStatus',
+ description: 'upload status for toast notification',
+ },
+ completed: {
+ id: 'app.presentationUploder.completed',
+ description: 'uploads complete label for toast notification',
+ },
+ item: {
+ id: 'app.presentationUploder.item',
+ description: 'single item label',
+ },
+ itemPlural: {
+ id: 'app.presentationUploder.itemPlural',
+ description: 'plural item label',
+ },
+ clearErrors: {
+ id: 'app.presentationUploder.clearErrors',
+ description: 'button label for clearing upload errors',
+ },
+ clearErrorsDesc: {
+ id: 'app.presentationUploder.clearErrorsDesc',
+ description: 'aria description for button clearing upload error',
+ },
+ uploadViewTitle: {
+ id: 'app.presentationUploder.uploadViewTitle',
+ description: 'view name apended to document title',
+ },
+ exportHint: {
+ id: 'app.presentationUploader.exportHint',
+ description: 'message to indicate the export presentation option',
+ },
+ exportToastHeader: {
+ id: 'app.presentationUploader.exportToastHeader',
+ description: 'exporting toast header',
+ },
+ exportToastHeaderPlural: {
+ id: 'app.presentationUploader.exportToastHeaderPlural',
+ description: 'exporting toast header in plural',
+ },
+ export: {
+ id: 'app.presentationUploader.export',
+ description: 'send presentation to chat',
+ },
+ exporting: {
+ id: 'app.presentationUploader.exporting',
+ description: 'presentation is being sent to chat',
+ },
+ currentLabel: {
+ id: 'app.presentationUploader.currentPresentationLabel',
+ description: 'current presentation label',
+ },
+ actionsLabel: {
+ id: 'app.presentation.actionsLabel',
+ description: 'actions label',
+ },
+ sending: {
+ id: 'app.presentationUploader.sending',
+ description: 'sending label',
+ },
+ collecting: {
+ id: 'app.presentationUploader.collecting',
+ description: 'collecting label',
+ },
+ processing: {
+ id: 'app.presentationUploader.processing',
+ description: 'processing label',
+ },
+ sent: {
+ id: 'app.presentationUploader.sent',
+ description: 'sent label',
+ },
+ exportingTimeout: {
+ id: 'app.presentationUploader.exportingTimeout',
+ description: 'exporting timeout label',
+ },
+ linkAvailable: {
+ id: 'app.presentationUploader.export.linkAvailable',
+ description: 'download presentation link available on public chat',
+ },
+ downloadButtonAvailable: {
+ id: 'app.presentationUploader.export.downloadButtonAvailable',
+ description: 'download presentation link available on public chat',
+ },
+});
+
+const EXPORT_STATUSES = {
+ RUNNING: 'RUNNING',
+ COLLECTING: 'COLLECTING',
+ PROCESSING: 'PROCESSING',
+ TIMEOUT: 'TIMEOUT',
+ EXPORTED: 'EXPORTED',
+};
+
+const handleDismissToast = (id) => toast.dismiss(id);
+
+class PresentationUploader extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ presentations: [],
+ disableActions: false,
+ presExporting: new Set(),
+ };
+
+ this.toastId = null;
+ this.hasError = null;
+ this.exportToastId = null;
+
+ const { handleFiledrop } = this.props;
+ // handlers
+ this.handleFiledrop = handleFiledrop;
+ this.handleConfirm = this.handleConfirm.bind(this);
+ this.handleDismiss = this.handleDismiss.bind(this);
+ this.handleRemove = this.handleRemove.bind(this);
+ this.handleCurrentChange = this.handleCurrentChange.bind(this);
+ this.handleDownloadingOfPresentation = this.handleDownloadingOfPresentation.bind(this);
+ // renders
+ this.renderDropzone = this.renderDropzone.bind(this);
+ this.renderExternalUpload = this.renderExternalUpload.bind(this);
+ this.renderPicDropzone = this.renderPicDropzone.bind(this);
+ this.renderPresentationList = this.renderPresentationList.bind(this);
+ this.renderPresentationItem = this.renderPresentationItem.bind(this);
+ this.renderExportToast = this.renderExportToast.bind(this);
+ this.renderToastExportItem = this.renderToastExportItem.bind(this);
+ this.renderExportationStatus = this.renderExportationStatus.bind(this);
+ // utilities
+ this.deepMergeUpdateFileKey = this.deepMergeUpdateFileKey.bind(this);
+ this.updateFileKey = this.updateFileKey.bind(this);
+ this.getPresentationsToShow = this.getPresentationsToShow.bind(this);
+ this.handleDownloadableChange = this.handleDownloadableChange.bind(this);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { isOpen, presentations: propPresentations, currentPresentation, intl } = this.props;
+ const { presentations } = this.state;
+ const { presentations: prevPropPresentations } = prevProps;
+
+ let shouldUpdateState = isOpen && !prevProps.isOpen;
+ const presState = Object.values({
+ ...JSON.parse(JSON.stringify(propPresentations)),
+ ...JSON.parse(JSON.stringify(presentations)),
+ });
+ if (propPresentations.length > prevPropPresentations.length) {
+ shouldUpdateState = true;
+ const propsDiffs = propPresentations.filter(
+ (p) => !prevPropPresentations.some(
+ (presentation) => p.id === presentation.id
+ || p.temporaryPresentationId === presentation.temporaryPresentationId,
+ ),
+ );
+
+ propsDiffs.forEach((p) => {
+ const index = presState.findIndex(
+ (pres) => pres.temporaryPresentationId === p.temporaryPresentationId || pres.id === p.id,
+ );
+ if (index === -1) {
+ presState.push(p);
+ }
+ });
+ }
+ const presStateFiltered = presState.filter((presentation) => {
+ const currentPropPres = propPresentations.find((pres) => pres.id === presentation.id);
+ const prevPropPres = prevPropPresentations.find((pres) => pres.id === presentation.id);
+ const hasConversionError = presentation?.conversion?.error;
+ const finishedConversion = presentation?.conversion?.done
+ || currentPropPres?.conversion?.done;
+ const hasTemporaryId = presentation.id.startsWith(presentation.filename);
+
+ if (hasConversionError || (!finishedConversion && hasTemporaryId)) return true;
+ if (!currentPropPres) return false;
+
+ if (presentation?.conversion?.done !== finishedConversion) {
+ shouldUpdateState = true;
+ }
+
+ const modPresentation = presentation;
+ if (currentPropPres.isCurrent !== prevPropPres?.isCurrent) {
+ modPresentation.isCurrent = currentPropPres.isCurrent;
+ }
+
+ if (currentPropPres?.isDownloadable !== prevPropPres?.isDownloadable) {
+ presentation.isDownloadable = currentPropPres.isDownloadable;
+ shouldUpdateState = true;
+ }
+
+ if (currentPropPres?.downloadableExtension !== prevPropPres?.downloadableExtension) {
+ presentation.downloadableExtension = currentPropPres.downloadableExtension;
+ shouldUpdateState = true;
+ }
+
+ if (currentPropPres?.filenameConverted !== prevPropPres?.filenameConverted) {
+ presentation.filenameConverted = currentPropPres.filenameConverted;
+ shouldUpdateState = true;
+ }
+
+ modPresentation.conversion = currentPropPres.conversion;
+ modPresentation.isRemovable = currentPropPres.isRemovable;
+
+ return true;
+ }).filter((presentation) => {
+ const duplicated = presentations.find(
+ (pres) => pres.filename === presentation.filename
+ && pres.id !== presentation.id,
+ );
+ if (duplicated
+ && duplicated.id.startsWith(presentation.filename)
+ && !presentation.id.startsWith(presentation.filename)
+ && presentation?.conversion?.done === duplicated?.conversion?.done) {
+ return false; // Prioritizing propPresentations (the one with id from back-end)
+ }
+ return true;
+ });
+
+ if (shouldUpdateState) {
+ this.setState({
+ presentations: unique(presStateFiltered, p => p.id)
+ });
+ }
+
+ if (!isOpen && prevProps.isOpen) {
+ unregisterTitleView();
+ }
+
+ // Updates presentation list when modal opens to avoid missing presentations
+ if (isOpen && !prevProps.isOpen) {
+ registerTitleView(intl.formatMessage(intlMessages.uploadViewTitle));
+ const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
+ const modal = document.getElementById('upload-modal');
+ const firstFocusableElement = modal?.querySelectorAll(focusableElements)[0];
+ const focusableContent = modal?.querySelectorAll(focusableElements);
+ const lastFocusableElement = focusableContent[focusableContent.length - 1];
+
+ firstFocusableElement.focus();
+
+ modal.addEventListener('keydown', (e) => {
+ const tab = e.key === 'Tab' || e.keyCode === TAB;
+ if (!tab) return;
+ if (e.shiftKey) {
+ if (document.activeElement === firstFocusableElement) {
+ lastFocusableElement.focus();
+ e.preventDefault();
+ }
+ } else if (document.activeElement === lastFocusableElement) {
+ firstFocusableElement.focus();
+ e.preventDefault();
+ }
+ });
+ }
+
+ if (currentPresentation && currentPresentation !== prevProps.currentPresentation) {
+ this.handleCurrentChange(currentPresentation);
+ }
+
+ if (presentations.length > 0) {
+ const selected = propPresentations.filter((p) => p.isCurrent);
+ if (selected.length > 0) Session.set('selectedToBeNextCurrent', selected[0].id);
+ }
+
+ if (this.exportToastId) {
+ if (!prevProps.isOpen && isOpen) {
+ handleDismissToast(this.exportToastId);
+ }
+
+ toast.update(this.exportToastId, {
+ render: this.renderExportToast(),
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ const id = Session.get('presentationUploaderToastId');
+ if (id) {
+ toast.dismiss(id);
+ Session.set('presentationUploaderToastId', null);
+ }
+ Session.set('showUploadPresentationView', false);
+ }
+
+ handleRemove(item, withErr = false) {
+ if (withErr) {
+ const { presentations } = this.state;
+ const { presentations: propPresentations } = this.props;
+
+ const filteredPropPresentations = propPresentations.filter(d => d.upload.done && d.conversion?.done);
+ const ids = new Set(filteredPropPresentations.map((d) => d.id));
+ const filteredPresentations = presentations.filter((d) => {
+ d.isCurrent = false;
+ return !ids.has(d.id) && !(d.upload.error || d.conversion.error) && !(d.upload.done && d.conversion.done)});
+ const merged = [
+ ...filteredPresentations,
+ ...filteredPropPresentations,
+ ];
+ let hasUploading
+ merged.forEach(d => {
+ if (!d.upload?.done || !d.conversion?.done) {
+ hasUploading = true;
+ }})
+ this.hasError = false;
+ if (hasUploading) {
+ return this.setState({
+ presentations: merged,
+ });
+ } else {
+ return this.setState({
+ presentations: merged,
+ disableActions: false,
+ });
+ }
+ }
+
+ const { presentations } = this.state;
+ const toRemoveIndex = presentations.indexOf(item);
+ return this.setState({
+ presentations: update(presentations, {
+ $splice: [[toRemoveIndex, 1]],
+ }),
+ }, () => {
+ const { presentations: updatedPresentations, oldCurrentId } = this.state;
+ const commands = {};
+
+ const currentIndex = updatedPresentations.findIndex((p) => p.isCurrent);
+ const actualCurrentIndex = updatedPresentations.findIndex((p) => p.id === oldCurrentId);
+
+ if (currentIndex === -1 && updatedPresentations.length > 0) {
+ const newCurrentIndex = actualCurrentIndex === -1 ? 0 : actualCurrentIndex;
+ commands[newCurrentIndex] = {
+ $apply: (presentation) => {
+ const p = presentation;
+ p.isCurrent = true;
+ return p;
+ },
+ };
+ }
+
+ const updatedCurrent = update(updatedPresentations, commands);
+ this.setState({ presentations: updatedCurrent });
+ });
+ }
+
+ handleCurrentChange(id) {
+ const { presentations, disableActions } = this.state;
+
+ if (disableActions || presentations?.length === 0) return;
+
+ const currentIndex = presentations.findIndex((p) => p.isCurrent);
+ const newCurrentIndex = presentations.findIndex((p) => p.id === id);
+ const commands = {};
+
+ // we can end up without a current presentation
+ if (currentIndex !== -1) {
+ commands[currentIndex] = {
+ $apply: (presentation) => {
+ const p = presentation;
+ p.isCurrent = false;
+ return p;
+ },
+ };
+ }
+
+ commands[newCurrentIndex] = {
+ $apply: (presentation) => {
+ if (!presentation) return;
+ const p = presentation;
+ p.isCurrent = true;
+ return p;
+ },
+ };
+
+ const presentationsUpdated = update(presentations, commands);
+ this.setState({ presentations: presentationsUpdated });
+ }
+
+ handleConfirm() {
+ const {
+ handleSave,
+ selectedToBeNextCurrent,
+ presentations: propPresentations,
+ dispatchChangePresentationDownloadable,
+ } = this.props;
+ const { disableActions, presentations } = this.state;
+ const presentationsToSave = presentations;
+
+ if (!isPresentationEnabled()) {
+ this.setState(
+ { presentations: [] },
+ Session.set('showUploadPresentationView', false),
+ );
+ return null;
+ }
+
+ this.setState({ disableActions: true });
+
+ presentations.forEach((item) => {
+ if (item.upload.done) {
+ const didDownloadableStateChange = propPresentations.some(
+ (p) => p.id === item.id && p.isDownloadable !== item.isDownloadable,
+ );
+ if (didDownloadableStateChange) {
+ dispatchChangePresentationDownloadable(item, item.isDownloadable);
+ }
+ }
+ });
+
+ if (!disableActions) {
+ Session.set('showUploadPresentationView', false);
+ return handleSave(presentationsToSave)
+ .then(() => {
+ const hasError = presentations.some((p) => p.upload.error || p.conversion.error);
+ if (!hasError) {
+ this.setState({
+ disableActions: false,
+ });
+ return;
+ }
+ // if there's error we don't want to close the modal
+ this.setState({
+ disableActions: true,
+ // preventClosing: true,
+ }, () => {
+ // if the selected current has error we revert back to the old one
+ const newCurrent = presentations.find((p) => p.isCurrent);
+ if (newCurrent.upload.error || newCurrent.conversion.error) {
+ this.handleCurrentChange(selectedToBeNextCurrent);
+ }
+ });
+ })
+ .catch((error) => {
+ logger.error({
+ logCode: 'presentationuploader_component_save_error',
+ extraInfo: { error },
+ }, 'Presentation uploader catch error on confirm');
+ });
+ }
+
+ Session.set('showUploadPresentationView', false);
+ return null;
+ }
+
+ handleDownloadableChange(item, fileStateType, downloadable) {
+ const { dispatchChangePresentationDownloadable } = this.props;
+
+ dispatchChangePresentationDownloadable(item, downloadable, fileStateType);
+ }
+
+ handleDismiss() {
+ const { presentations } = this.state;
+ const { presentations: propPresentations } = this.props;
+
+ const ids = new Set(propPresentations.map((d) => d.id));
+
+ const filteredPresentations = presentations.filter((d) => !ids.has(d.id)
+ && (d.upload.done || d.upload.progress !== 0));
+ const isThereStateCurrentPres = filteredPresentations.some((p) => p.isCurrent);
+ const merged = [
+ ...filteredPresentations,
+ ...propPresentations.filter((p) => {
+ if (isThereStateCurrentPres) {
+ p.isCurrent = false;
+ }
+ return true;
+ }),
+ ];
+ this.setState(
+ { presentations: merged },
+ Session.set('showUploadPresentationView', false),
+ );
+ }
+
+ handleDownloadingOfPresentation(item, fileStateType) {
+ const {
+ exportPresentation,
+ intl,
+ } = this.props;
+
+ const observer = (exportation, stopped) => {
+ this.deepMergeUpdateFileKey(item.id, 'exportation', exportation);
+
+ if (exportation.status === EXPORT_STATUSES.EXPORTED && stopped) {
+ if (fileStateType === 'Original' || fileStateType === 'Converted') {
+ if (!item.isDownloadable) {
+ notify(intl.formatMessage(intlMessages.downloadButtonAvailable, { 0: item.filename }), 'success');
+ }
+ } else {
+ notify(intl.formatMessage(intlMessages.linkAvailable, { 0: item.filename }), 'success');
+ }
+ }
+
+ if ([
+ EXPORT_STATUSES.RUNNING,
+ EXPORT_STATUSES.COLLECTING,
+ EXPORT_STATUSES.PROCESSING,
+ ].includes(exportation.status) && fileStateType === 'Annotated') {
+ this.setState((prevState) => {
+ prevState.presExporting.add(item.id);
+ return {
+ presExporting: prevState.presExporting,
+ };
+ }, () => {
+ if (this.exportToastId) {
+ toast.update(this.exportToastId, {
+ render: this.renderExportToast(),
+ });
+ } else {
+ this.exportToastId = toast.info(this.renderExportToast(), {
+ hideProgressBar: true,
+ autoClose: false,
+ newestOnTop: true,
+ closeOnClick: true,
+ onClose: () => {
+ this.exportToastId = null;
+ const presToShow = this.getPresentationsToShow();
+ const isAnyRunning = presToShow.some(
+ (p) => p.exportation.status === EXPORT_STATUSES.RUNNING
+ || p.exportation.status === EXPORT_STATUSES.COLLECTING
+ || p.exportation.status === EXPORT_STATUSES.PROCESSING,
+ );
+ if (!isAnyRunning) {
+ this.setState({ presExporting: new Set() });
+ }
+ },
+ });
+ }
+ });
+ }
+ };
+
+ exportPresentation(item.id, observer, fileStateType);
+ }
+
+ getPresentationsToShow() {
+ const { presentations, presExporting } = this.state;
+
+ return Array.from(presExporting)
+ .map((id) => presentations.find((p) => p.id === id))
+ .filter((p) => p);
+ }
+
+ deepMergeUpdateFileKey(id, key, value) {
+ const applyValue = (toUpdate) => update(toUpdate, { $merge: value });
+ this.updateFileKey(id, key, applyValue, '$apply');
+ }
+
+ updateFileKey(id, key, value, operation = '$set') {
+ this.setState(({ presentations }) => {
+ const fileIndex = presentations.findIndex((f) => f.id === id);
+
+ return fileIndex === -1 ? false : {
+ presentations: update(presentations, {
+ [fileIndex]: {
+ $apply: (file) => update(file, {
+ [key]: {
+ [operation]: value,
+ },
+ }),
+ },
+ }),
+ };
+ });
+ }
+
+ renderExtraHint() {
+ const {
+ intl,
+ fileSizeMax,
+ filePagesMax,
+ } = this.props;
+
+ const options = {
+ 0: fileSizeMax / 1000000,
+ 1: filePagesMax,
+ };
+
+ return (
+
+ {intl.formatMessage(intlMessages.extraHint, options)}
+
+ );
+ }
+
+ renderPresentationList() {
+ const { presentations } = this.state;
+ const { intl } = this.props;
+
+ let presentationsSorted = presentations;
+
+ try {
+ presentationsSorted = presentations
+ .sort((a, b) => a.uploadTimestamp - b.uploadTimestamp)
+ .sort((a, b) => a.filename.localeCompare(b.filename))
+ .sort((a, b) => b.upload.progress - a.upload.progress)
+ .sort((a, b) => b.conversion.done - a.conversion.done)
+ .sort((a, b) => {
+ const aUploadNotTriggeredYet = !a.upload.done && a.upload.progress === 0;
+ const bUploadNotTriggeredYet = !b.upload.done && b.upload.progress === 0;
+ return bUploadNotTriggeredYet - aUploadNotTriggeredYet;
+ });
+ } catch (error) {
+ logger.error({
+ logCode: 'presentationuploader_component_render_error',
+ extraInfo: { error },
+ }, 'Presentation uploader catch error on render presentation list');
+ }
+
+ return (
+
+
+
+
+
+ {intl.formatMessage(intlMessages.setAsCurrentPresentation)}
+
+
+ {intl.formatMessage(intlMessages.filename)}
+
+
+ {intl.formatMessage(intlMessages.status)}
+
+
+ {intl.formatMessage(intlMessages.options)}
+
+
+
+ {intl.formatMessage(intlMessages.currentLabel)}
+ {intl.formatMessage(intlMessages.actionsLabel)}
+
+
+
+ {unique(presentationsSorted, p => p.id) .map((item) => this.renderPresentationItem(item))}
+
+
+
+ );
+ }
+
+ renderExportToast() {
+ const { intl } = this.props;
+ const { presExporting } = this.state;
+
+ const presToShow = this.getPresentationsToShow();
+
+ const isAllExported = presToShow.every(
+ (p) => p.exportation.status === EXPORT_STATUSES.EXPORTED,
+ );
+ const shouldDismiss = isAllExported && this.exportToastId;
+
+ if (shouldDismiss) {
+ handleDismissToast(this.exportToastId);
+
+ if (presExporting.size) {
+ this.setState({ presExporting: new Set() });
+ }
+ return null;
+ }
+
+ const presToShowSorted = [
+ ...presToShow.filter((p) => p.exportation.status === EXPORT_STATUSES.RUNNING),
+ ...presToShow.filter((p) => p.exportation.status === EXPORT_STATUSES.COLLECTING),
+ ...presToShow.filter((p) => p.exportation.status === EXPORT_STATUSES.PROCESSING),
+ ...presToShow.filter((p) => p.exportation.status === EXPORT_STATUSES.TIMEOUT),
+ ...presToShow.filter((p) => p.exportation.status === EXPORT_STATUSES.EXPORTED),
+ ];
+
+ const headerLabelId = presToShowSorted.length === 1
+ ? 'exportToastHeader'
+ : 'exportToastHeaderPlural';
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages[headerLabelId], { 0: presToShowSorted.length })}
+
+
+
+
+
+ {presToShowSorted.map((item) => this.renderToastExportItem(item))}
+
+
+
+
+ );
+ }
+
+ renderToastExportItem(item) {
+ const { status } = item.exportation;
+ const loading = [EXPORT_STATUSES.RUNNING, EXPORT_STATUSES.COLLECTING,
+ EXPORT_STATUSES.PROCESSING].includes(status);
+ const done = status === EXPORT_STATUSES.EXPORTED;
+ const statusIconMap = {
+ [EXPORT_STATUSES.RUNNING]: 'blank',
+ [EXPORT_STATUSES.COLLECTING]: 'blank',
+ [EXPORT_STATUSES.PROCESSING]: 'blank',
+ [EXPORT_STATUSES.EXPORTED]: 'check',
+ [EXPORT_STATUSES.TIMEOUT]: 'warning',
+ };
+
+ const icon = statusIconMap[status] || '';
+
+ return (
+
+
+
+
+
+
+ {item.filename}
+
+
+
+
+
+
+
+ {this.renderExportationStatus(item)}
+
+
+
+ );
+ }
+
+ renderExportationStatus(item) {
+ const { intl } = this.props;
+
+ switch (item.exportation.status) {
+ case EXPORT_STATUSES.RUNNING:
+ return intl.formatMessage(intlMessages.sending);
+ case EXPORT_STATUSES.COLLECTING:
+ return intl.formatMessage(intlMessages.collecting,
+ { 0: item.exportation.pageNumber, 1: item.exportation.totalPages });
+ case EXPORT_STATUSES.PROCESSING:
+ return intl.formatMessage(intlMessages.processing,
+ { 0: item.exportation.pageNumber, 1: item.exportation.totalPages });
+ case EXPORT_STATUSES.TIMEOUT:
+ return intl.formatMessage(intlMessages.exportingTimeout);
+ case EXPORT_STATUSES.EXPORTED:
+ return intl.formatMessage(intlMessages.sent);
+ default:
+ return '';
+ }
+ }
+
+ renderDownloadableWithAnnotationsHint() {
+ const {
+ intl,
+ allowDownloadWithAnnotations,
+ } = this.props;
+
+ return allowDownloadWithAnnotations ? (
+
+ {intl.formatMessage(intlMessages.exportHint)}
+
+ )
+ : null;
+ }
+
+ renderPresentationItem(item) {
+ const { disableActions } = this.state;
+ const {
+ intl,
+ selectedToBeNextCurrent,
+ allowDownloadOriginal,
+ allowDownloadConverted,
+ allowDownloadWithAnnotations,
+ renderPresentationItemStatus,
+ hasAnnotations,
+ } = this.props;
+
+ const isActualCurrent = selectedToBeNextCurrent
+ ? item.id === selectedToBeNextCurrent
+ : item.isCurrent;
+ const isUploading = !item.upload.done && item.upload.progress > 0;
+ const isConverting = !item.conversion.done && item.upload.done;
+ const hasError = item.conversion.error || item.upload.error;
+ const isProcessing = (isUploading || isConverting) && !hasError;
+
+ if (hasError) {
+ this.hasError = true;
+ }
+
+ const { animations } = Settings.application;
+
+ const { isRemovable, exportation: { status }, isDownloadable } = item;
+
+ const isExporting = status === 'RUNNING';
+
+ const shouldDisableExportButton = (isExporting
+ || !item.conversion.done
+ || hasError
+ || disableActions) && !item.conversion?.done;
+
+ const formattedDownloadLabel = isExporting
+ ? intl.formatMessage(intlMessages.exporting)
+ : intl.formatMessage(intlMessages.export);
+
+ const formattedDownloadAriaLabel = `${formattedDownloadLabel} ${item.filename}`;
+
+ const isNew = item.id.indexOf(item.filename) !== -1;
+ const hasAnyAnnotation = isNew ? false : hasAnnotations(item.id);
+
+ return (
+
+
+ this.handleCurrentChange(item.id)}
+ disabled={disableActions || hasError}
+ />
+
+
+ {item.filename}
+
+ {
+ isActualCurrent
+ ? (
+
+
+ {intl.formatMessage(intlMessages.currentBadge)}
+
+
+ )
+ : null
+ }
+
+ {renderPresentationItemStatus(item, intl)}
+
+ {
+ hasError ? null : (
+
+ {allowDownloadOriginal || allowDownloadWithAnnotations || allowDownloadConverted ? (
+ Session.set('showUploadPresentationView', false)}
+ handleDownloadingOfPresentation={(fileStateType) => this
+ .handleDownloadingOfPresentation(item, fileStateType)}
+ />
+ ) : null}
+ {isRemovable ? (
+ this.handleRemove(item)}
+ animations={animations}
+ />
+ ) : null}
+
+ )}
+
+ );
+ }
+
+ renderDropzone() {
+ const {
+ intl,
+ fileValidMimeTypes,
+ } = this.props;
+
+ const { disableActions } = this.state;
+
+ if (disableActions && !this.hasError) return null;
+
+ return this.hasError ? (
+
+
this.handleRemove(null, true)}
+ label={intl.formatMessage(intlMessages.clearErrors)}
+ aria-describedby="clearErrorDesc"
+ />
+
+ {intl.formatMessage(intlMessages.clearErrorsDesc)}
+
+
+ ) : (
+ // Until the Dropzone package has fixed the mime type hover validation, the rejectClassName
+ // prop is being remove to prevent the error styles from being applied to valid file types.
+ // Error handling is being done in the onDrop prop.
+ fileValid.extension)}
+ disablepreview="true"
+ onDrop={(files, files2) => this.handleFiledrop(files, files2, this, intl, intlMessages)}
+ >
+
+
+ {intl.formatMessage(intlMessages.dropzoneLabel)}
+
+
+ {intl.formatMessage(intlMessages.browseFilesLabel)}
+
+
+
+ );
+ }
+
+ renderExternalUpload() {
+ const { externalUploadData, intl } = this.props;
+
+ const {
+ presentationUploadExternalDescription, presentationUploadExternalUrl,
+ } = externalUploadData;
+
+ if (!presentationUploadExternalDescription || !presentationUploadExternalUrl) return null;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.externalUploadTitle)}
+
+
+
{presentationUploadExternalDescription}
+
+ window.open(`${presentationUploadExternalUrl}`)}
+ label={intl.formatMessage(intlMessages.externalUploadLabel)}
+ aria-describedby={intl.formatMessage(intlMessages.externalUploadLabel)}
+ />
+
+ );
+ }
+
+ renderPicDropzone() {
+ const {
+ intl,
+ } = this.props;
+
+ const { disableActions } = this.state;
+
+ if (disableActions && !this.hasError) return null;
+
+ return this.hasError ? (
+
+
this.handleRemove(null, true)}
+ label={intl.formatMessage(intlMessages.clearErrors)}
+ aria-describedby="clearErrorDesc"
+ />
+
+ {intl.formatMessage(intlMessages.clearErrorsDesc)}
+
+
+ ) : (
+ this.handleFiledrop(files, files2, this, intl, intlMessages)}
+ >
+
+
+ {intl.formatMessage(intlMessages.dropzoneImagesLabel)}
+
+
+ {intl.formatMessage(intlMessages.browseImagesLabel)}
+
+
+
+ );
+ }
+
+ render() {
+ const {
+ isOpen,
+ isPresenter,
+ intl,
+ fileUploadConstraintsHint,
+ } = this.props;
+ if (!isPresenter) return null;
+ const { presentations, disableActions } = this.state;
+
+ let hasNewUpload = false;
+
+ presentations.forEach((item) => {
+ if (item.id.indexOf(item.filename) !== -1 && item.upload.progress === 0) hasNewUpload = true;
+ });
+
+ return (
+ <>
+
+ {isOpen
+ ? (
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+ this.handleConfirm()}
+ disabled={disableActions}
+ label={hasNewUpload
+ ? intl.formatMessage(intlMessages.uploadLabel)
+ : intl.formatMessage(intlMessages.confirmLabel)}
+ />
+
+
+
+
+ {`${intl.formatMessage(intlMessages.message)}`}
+ {fileUploadConstraintsHint ? this.renderExtraHint() : null}
+
+ {this.renderPresentationList()}
+ {this.renderDownloadableWithAnnotationsHint()}
+ {isMobile ? this.renderPicDropzone() : null}
+ {this.renderDropzone()}
+ {this.renderExternalUpload()}
+
+
+ )
+ : null}
+ >
+ );
+ }
+}
+
+PresentationUploader.propTypes = propTypes;
+PresentationUploader.defaultProps = defaultProps;
+
+export default injectIntl(PresentationUploader);
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx
new file mode 100644
index 00000000..1c322f1b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/container.jsx
@@ -0,0 +1,69 @@
+import React, { useContext } from 'react';
+import { Meteor } from 'meteor/meteor';
+import { withTracker } from 'meteor/react-meteor-data';
+import ErrorBoundary from '/imports/ui/components/common/error-boundary/component';
+import FallbackModal from '/imports/ui/components/common/fallback-errors/fallback-modal/component';
+import Service from './service';
+import PresUploaderToast from '/imports/ui/components/presentation/presentation-toast/presentation-uploader-toast/component';
+import PresentationUploader from './component';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import Auth from '/imports/ui/services/auth';
+import {
+ isDownloadPresentationWithAnnotationsEnabled,
+ isDownloadPresentationOriginalFileEnabled,
+ isDownloadPresentationConvertedToPdfEnabled,
+ isPresentationEnabled,
+} from '/imports/ui/services/features';
+import { hasAnnotations } from '/imports/ui/components/whiteboard/service';
+
+const PRESENTATION_CONFIG = Meteor.settings.public.presentation;
+
+const PresentationUploaderContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const userIsPresenter = currentUser.presenter;
+
+ return userIsPresenter && (
+
+
+
+ );
+};
+
+export default withTracker(() => {
+ const presentations = Service.getPresentations();
+ const currentPresentation = presentations.find((p) => p.isCurrent)?.id || '';
+ const {
+ dispatchDisableDownloadable,
+ dispatchEnableDownloadable,
+ dispatchChangePresentationDownloadable,
+ exportPresentation,
+ } = Service;
+ const isOpen = isPresentationEnabled() && (Session.get('showUploadPresentationView') || false);
+
+ return {
+ presentations,
+ currentPresentation,
+ fileUploadConstraintsHint: PRESENTATION_CONFIG.fileUploadConstraintsHint,
+ fileSizeMax: PRESENTATION_CONFIG.mirroredFromBBBCore.uploadSizeMax,
+ filePagesMax: PRESENTATION_CONFIG.mirroredFromBBBCore.uploadPagesMax,
+ fileValidMimeTypes: PRESENTATION_CONFIG.uploadValidMimeTypes,
+ allowDownloadOriginal: isDownloadPresentationOriginalFileEnabled(),
+ allowDownloadConverted: isDownloadPresentationConvertedToPdfEnabled(),
+ allowDownloadWithAnnotations: isDownloadPresentationWithAnnotationsEnabled(),
+ handleSave: Service.handleSavePresentation,
+ handleDismissToast: PresUploaderToast.handleDismissToast,
+ renderToastList: Service.renderToastList,
+ renderPresentationItemStatus: PresUploaderToast.renderPresentationItemStatus,
+ dispatchDisableDownloadable,
+ dispatchEnableDownloadable,
+ dispatchChangePresentationDownloadable,
+ exportPresentation,
+ isOpen,
+ selectedToBeNextCurrent: Session.get('selectedToBeNextCurrent') || null,
+ externalUploadData: Service.getExternalUploadData(),
+ handleFiledrop: Service.handleFiledrop,
+ hasAnnotations,
+ };
+})(PresentationUploaderContainer);
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx
new file mode 100644
index 00000000..7e2e9de9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/component.jsx
@@ -0,0 +1,207 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import { uniqueId } from '/imports/utils/string-utils';
+import Trigger from '/imports/ui/components/common/control-header/right/component';
+import PresentationDownloadDropdownWrapper from './presentation-download-dropdown-wrapper/component';
+
+const intlMessages = defineMessages({
+ enableOriginalPresentationDownload: {
+ id: 'app.presentationUploader.enableOriginalPresentationDownload',
+ description: 'Send original presentation to chat',
+ },
+ disableOriginalPresentationDownload: {
+ id: 'app.presentationUploader.disableOriginalPresentationDownload',
+ description: 'Send original presentation to chat',
+ },
+ sendCurrentStateDocument: {
+ id: 'app.presentationUploader.exportCurrentStatePresentation',
+ description: 'Send presentation to chat in the current state label',
+ },
+ copySuccess: {
+ id: 'app.chat.copySuccess',
+ description: 'aria success alert',
+ },
+ copyErr: {
+ id: 'app.chat.copyErr',
+ description: 'aria error alert',
+ },
+ options: {
+ id: 'app.presentationUploader.dropdownExportOptions',
+ description: 'Chat Options',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handleDownloadingOfPresentation: PropTypes.func.isRequired,
+ handleDownloadableChange: PropTypes.func.isRequired,
+ isDownloadable: PropTypes.bool.isRequired,
+ allowDownloadOriginal: PropTypes.bool.isRequired,
+ allowDownloadConverted: PropTypes.bool.isRequired,
+ allowDownloadWithAnnotations: PropTypes.bool.isRequired,
+ item: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ filename: PropTypes.string.isRequired,
+ filenameConverted: PropTypes.string,
+ isCurrent: PropTypes.bool.isRequired,
+ temporaryPresentationId: PropTypes.string,
+ isDownloadable: PropTypes.bool.isRequired,
+ isRemovable: PropTypes.bool.isRequired,
+ conversion: PropTypes.shape({
+ done: PropTypes.bool,
+ error: PropTypes.bool,
+ status: PropTypes.string,
+ numPages: PropTypes.number,
+ pagesCompleted: PropTypes.number,
+ }),
+ upload: PropTypes.shape({
+ done: PropTypes.bool,
+ error: PropTypes.bool,
+ }).isRequired,
+ exportation: PropTypes.shape({
+ status: PropTypes.string,
+ }),
+ uploadTimestamp: PropTypes.string,
+ downloadableExtension: PropTypes.string,
+ }).isRequired,
+ closeModal: PropTypes.func.isRequired,
+ disabled: PropTypes.bool.isRequired,
+};
+
+class PresentationDownloadDropdown extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.actionsKey = [
+ uniqueId('action-item-'),
+ uniqueId('action-item-'),
+ uniqueId('action-item-'),
+ ];
+ }
+
+ getAvailableActions() {
+ const {
+ intl,
+ handleDownloadingOfPresentation,
+ handleDownloadableChange,
+ isDownloadable,
+ allowDownloadOriginal,
+ allowDownloadConverted,
+ allowDownloadWithAnnotations,
+ item,
+ closeModal,
+ } = this.props;
+
+ this.menuItems = [];
+
+ const { filenameConverted, filename, downloadableExtension } = item;
+ const convertedFileExtension = filenameConverted?.split('.').slice(-1)[0];
+ const originalFileExtension = filename?.split('.').slice(-1)[0];
+ const changeDownloadOriginalOrConvertedPresentation = (enableDownload, fileStateType) => {
+ handleDownloadableChange(item, fileStateType, enableDownload);
+ if (enableDownload) {
+ handleDownloadingOfPresentation(fileStateType);
+ }
+ closeModal();
+ };
+
+ if (allowDownloadOriginal) {
+ if (isDownloadable && !!downloadableExtension
+ && downloadableExtension === originalFileExtension) {
+ this.menuItems.push({
+ key: this.actionsKey[0],
+ dataTest: 'disableOriginalPresentationDownload',
+ label: intl.formatMessage(intlMessages.disableOriginalPresentationDownload,
+ { 0: originalFileExtension }),
+ onClick: () => changeDownloadOriginalOrConvertedPresentation(false, 'Original'),
+ });
+ } else {
+ this.menuItems.push({
+ key: this.actionsKey[0],
+ dataTest: 'enableOriginalPresentationDownload',
+ label: intl.formatMessage(intlMessages.enableOriginalPresentationDownload,
+ { 0: originalFileExtension }),
+ onClick: () => changeDownloadOriginalOrConvertedPresentation(true, 'Original'),
+ });
+ }
+ }
+ if (allowDownloadConverted) {
+ if ((!!filenameConverted && filenameConverted !== '')
+ && convertedFileExtension !== originalFileExtension) {
+ if (isDownloadable && !!downloadableExtension
+ && downloadableExtension === convertedFileExtension) {
+ this.menuItems.push({
+ key: this.actionsKey[0],
+ dataTest: 'disableOriginalPresentationDownload',
+ label: intl.formatMessage(intlMessages.disableOriginalPresentationDownload,
+ { 0: convertedFileExtension }),
+ onClick: () => changeDownloadOriginalOrConvertedPresentation(false, 'Converted'),
+ });
+ } else {
+ this.menuItems.push({
+ key: this.actionsKey[0],
+ dataTest: 'enableOriginalPresentationDownload',
+ label: intl.formatMessage(intlMessages.enableOriginalPresentationDownload,
+ { 0: convertedFileExtension }),
+ onClick: () => changeDownloadOriginalOrConvertedPresentation(true, 'Converted'),
+ });
+ }
+ }
+ }
+ if (allowDownloadWithAnnotations) {
+ this.menuItems.push({
+ key: this.actionsKey[1],
+ id: 'sendCurrentStateDocument',
+ dataTest: 'sendCurrentStateDocument',
+ label: intl.formatMessage(intlMessages.sendCurrentStateDocument),
+ onClick: () => {
+ closeModal();
+ handleDownloadingOfPresentation('Annotated');
+ },
+ });
+ }
+ return this.menuItems;
+ }
+
+ render() {
+ const { intl, disabled } = this.props;
+
+ const customStyles = { zIndex: 9999 };
+
+ return (
+
+ null}
+ />
+ )}
+ opts={{
+ id: 'presentation-download-dropdown',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 2,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: 'left' },
+ transformOrigin: { vertical: 'top', horizontal: 'left' },
+ }}
+ actions={this.getAvailableActions()}
+ />
+
+ );
+ }
+}
+
+PresentationDownloadDropdown.propTypes = propTypes;
+
+export default injectIntl(PresentationDownloadDropdown);
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx
new file mode 100644
index 00000000..d496ece8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx
@@ -0,0 +1,39 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const propTypes = {
+ children: PropTypes.shape({}).isRequired,
+ disabled: PropTypes.bool.isRequired,
+};
+
+class PresentationDownloadDropdownWrapper extends PureComponent {
+ validateDisabled(eventHandler, ...args) {
+ const { disabled } = this.props;
+ if (!disabled && typeof eventHandler === 'function') {
+ return eventHandler(...args);
+ }
+
+ return null;
+ }
+
+ render() {
+ const {
+ disabled,
+ children,
+ } = this.props;
+
+ return (
+
+ {children}
+
+ );
+ }
+}
+
+PresentationDownloadDropdownWrapper.propTypes = propTypes;
+
+export default PresentationDownloadDropdownWrapper;
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js
new file mode 100644
index 00000000..728309dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/styles.js
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+const DropdownMenuWrapper = styled.div`
+ display: inline-block;
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: .5;
+ box-shadow: none;
+ pointer-events: none;
+ }
+`;
+
+export default {
+ DropdownMenuWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js
new file mode 100644
index 00000000..644e2618
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/service.js
@@ -0,0 +1,497 @@
+import Presentations, { UploadingPresentations } from '/imports/api/presentations';
+import PresentationUploadToken from '/imports/api/presentation-upload-token';
+import Auth from '/imports/ui/services/auth';
+import Poll from '/imports/api/polls/';
+import { Meteor } from 'meteor/meteor';
+import { makeCall } from '/imports/ui/services/api';
+import logger from '/imports/startup/client/logger';
+import { partition } from '/imports/utils/array-utils';
+import update from 'immutability-helper';
+import { Random } from 'meteor/random';
+import Meetings from '/imports/api/meetings';
+import { uniqueId } from '/imports/utils/string-utils';
+import { isPresentationEnabled } from '/imports/ui/services/features';
+import { notify } from '/imports/ui/services/notification';
+
+const CONVERSION_TIMEOUT = 300000;
+const TOKEN_TIMEOUT = 5000;
+const PRESENTATION_CONFIG = Meteor.settings.public.presentation;
+
+// fetch doesn't support progress. So we use xhr which support progress.
+const futch = (url, opts = {}, onProgress) => new Promise((res, rej) => {
+ const xhr = new XMLHttpRequest();
+
+ xhr.open(opts.method || 'get', url);
+
+ Object.keys(opts.headers || {})
+ .forEach((k) => xhr.setRequestHeader(k, opts.headers[k]));
+
+ xhr.onload = (e) => {
+ if (e.target.status !== 200) {
+ return rej(new Error({ code: e.target.status, message: e.target.statusText }));
+ }
+
+ return res(e.target.responseText);
+ };
+ xhr.onerror = rej;
+ if (xhr.upload && onProgress) {
+ xhr.upload.addEventListener('progress', onProgress, false);
+ }
+ xhr.send(opts.body);
+});
+
+const getPresentations = () => Presentations
+ .find({
+ 'conversion.error': false,
+ })
+ .fetch()
+ .map((presentation) => {
+ const {
+ conversion,
+ current,
+ downloadable,
+ removable,
+ renderedInToast,
+ temporaryPresentationId,
+ id,
+ name,
+ exportation,
+ filenameConverted,
+ downloadableExtension,
+ } = presentation;
+
+ const uploadTimestamp = id.split('-').pop();
+
+ return {
+ id,
+ filename: name,
+ renderedInToast,
+ temporaryPresentationId,
+ isCurrent: current || false,
+ upload: { done: true, error: false },
+ isDownloadable: downloadable,
+ isRemovable: removable,
+ conversion: conversion || { done: true, error: false },
+ uploadTimestamp,
+ exportation: exportation || { error: false },
+ filenameConverted,
+ downloadableExtension,
+ };
+ });
+
+const dispatchChangePresentationDownloadable = (presentation, newState, fileStateType) => {
+ makeCall('setPresentationDownloadable', presentation.id, newState, fileStateType);
+};
+
+const observePresentationConversion = (
+ meetingId,
+ temporaryPresentationId,
+ onConversion,
+) => new Promise((resolve) => {
+ // The token is placed as an id before the original one is generated
+ // in the back-end;
+ const tokenId = PresentationUploadToken.findOne({ temporaryPresentationId })?.authzToken;
+
+ const conversionTimeout = setTimeout(() => {
+ onConversion({
+ done: true,
+ error: true,
+ status: 'TIMEOUT',
+ });
+ }, CONVERSION_TIMEOUT);
+
+ const didValidate = (doc) => {
+ clearTimeout(conversionTimeout);
+ resolve(doc);
+ };
+
+ Tracker.autorun((c) => {
+ const query = Presentations.find({ meetingId });
+
+ query.observe({
+ added: (doc) => {
+ if (doc.temporaryPresentationId !== temporaryPresentationId && doc.id !== tokenId) return;
+
+ if (doc.conversion.status === 'FILE_TOO_LARGE' || doc.conversion.status === 'UNSUPPORTED_DOCUMENT'
+ || doc.conversion.status === 'CONVERSION_TIMEOUT' || doc.conversion.status === 'INVALID_MIME_TYPE') {
+ Presentations.update(
+ { id: tokenId }, { $set: { temporaryPresentationId, renderedInToast: false } },
+ );
+ onConversion(doc.conversion);
+ c.stop();
+ clearTimeout(conversionTimeout);
+ }
+ },
+ changed: (newDoc) => {
+ if (newDoc.temporaryPresentationId !== temporaryPresentationId) return;
+
+ onConversion(newDoc.conversion);
+
+ if (newDoc.conversion.error) {
+ c.stop();
+ clearTimeout(conversionTimeout);
+ }
+
+ if (newDoc.conversion.done) {
+ c.stop();
+ didValidate(newDoc);
+ }
+ },
+ });
+ });
+});
+
+const requestPresentationUploadToken = (
+ temporaryPresentationId,
+ podId,
+ meetingId,
+ filename,
+) => new Promise((resolve, reject) => {
+ makeCall('requestPresentationUploadToken', podId, filename, temporaryPresentationId);
+
+ let computation = null;
+ const timeout = setTimeout(() => {
+ computation.stop();
+ reject(new Error({ code: 408, message: 'requestPresentationUploadToken timeout' }));
+ }, TOKEN_TIMEOUT);
+
+ Tracker.autorun((c) => {
+ computation = c;
+ const sub = Meteor.subscribe('presentation-upload-token', podId, filename, temporaryPresentationId);
+ if (!sub.ready()) return;
+
+ const PresentationToken = PresentationUploadToken.findOne({
+ podId,
+ meetingId,
+ temporaryPresentationId,
+ used: false,
+ });
+
+ if (!PresentationToken || !('failed' in PresentationToken)) return;
+
+ if (!PresentationToken.failed) {
+ clearTimeout(timeout);
+ resolve(PresentationToken.authzToken);
+ }
+
+ if (PresentationToken.failed) {
+ reject(new Error({ code: 401, message: `requestPresentationUploadToken token ${PresentationToken.authzToken} failed` }));
+ }
+ });
+});
+
+const uploadAndConvertPresentation = (
+ file,
+ downloadable,
+ podId,
+ meetingId,
+ endpoint,
+ onUpload,
+ onProgress,
+ onConversion,
+) => {
+ const temporaryPresentationId = uniqueId(Random.id(20));
+
+ const data = new FormData();
+ data.append('fileUpload', file);
+ data.append('conference', meetingId);
+ data.append('room', meetingId);
+ data.append('temporaryPresentationId', temporaryPresentationId);
+
+ // TODO: Currently the uploader is not related to a POD so the id is fixed to the default
+ data.append('pod_id', podId);
+
+ data.append('is_downloadable', downloadable);
+
+ const opts = {
+ method: 'POST',
+ body: data,
+ };
+
+ // If the presentation is from sharedNotes I don't want to
+ // insert another one, I just need to update it.
+ UploadingPresentations.upsert({
+ filename: file.name,
+ lastModifiedUploader: false,
+ }, {
+ $set: {
+ temporaryPresentationId,
+ progress: 0,
+ filename: file.name,
+ lastModifiedUploader: true,
+ upload: {
+ done: false,
+ error: false,
+ },
+ uploadTimestamp: new Date(),
+ },
+ });
+
+ return requestPresentationUploadToken(temporaryPresentationId, podId, meetingId, file.name)
+ .then((token) => {
+ makeCall('setUsedToken', token);
+ UploadingPresentations.upsert({
+ temporaryPresentationId,
+ }, {
+ $set: {
+ id: token,
+ },
+ });
+ return futch(endpoint.replace('upload', `${token}/upload`), opts, (e) => {
+ onProgress(e);
+ const pr = (e.loaded / e.total) * 100;
+ if (pr !== 100) {
+ UploadingPresentations.upsert({ temporaryPresentationId }, { $set: { progress: pr } });
+ } else {
+ UploadingPresentations.upsert({ temporaryPresentationId }, {
+ $set: {
+ progress: pr,
+ upload: {
+ done: true,
+ error: false,
+ },
+ },
+ });
+ }
+ });
+ })
+ .then(() => observePresentationConversion(meetingId, temporaryPresentationId, onConversion))
+ // Trap the error so we can have parallel upload
+ .catch((error) => {
+ logger.debug({
+ logCode: 'presentation_uploader_service',
+ extraInfo: {
+ error,
+ },
+ }, 'Generic presentation upload exception catcher');
+ observePresentationConversion(meetingId, temporaryPresentationId, onConversion);
+ onUpload({ error: true, done: true, status: error.code });
+ return Promise.resolve();
+ });
+};
+
+const uploadAndConvertPresentations = (
+ presentationsToUpload,
+ meetingId,
+ podId,
+ uploadEndpoint,
+) => Promise.all(presentationsToUpload.map((p) => uploadAndConvertPresentation(
+ p.file, p.isDownloadable, podId, meetingId, uploadEndpoint,
+ p.onUpload, p.onProgress, p.onConversion,
+)));
+
+const setPresentation = (presentationId, podId) => {
+ makeCall('setPresentation', presentationId, podId);
+};
+
+const removePresentation = (presentationId, podId) => {
+ const hasPoll = Poll.find({}, { fields: {} }).count();
+ if (hasPoll) makeCall('stopPoll');
+ makeCall('removePresentation', presentationId, podId);
+};
+
+const removePresentations = (
+ presentationsToRemove,
+ podId,
+) => Promise.all(presentationsToRemove.map((p) => removePresentation(p.id, podId)));
+
+const persistPresentationChanges = (oldState, newState, uploadEndpoint, podId) => {
+ const presentationsToUpload = newState.filter((p) => !p.upload.done);
+ const presentationsToRemove = oldState.filter((p) => !newState.find((u) => { return u.id === p.id }));
+
+ let currentPresentation = newState.find((p) => p.isCurrent);
+
+ return uploadAndConvertPresentations(presentationsToUpload, Auth.meetingID, podId, uploadEndpoint)
+ .then((presentations) => {
+ if (!presentations.length && !currentPresentation) return Promise.resolve();
+
+ // Update the presentation with their new ids
+ presentations.forEach((p, i) => {
+ if (p === undefined) return;
+ presentationsToUpload[i].onDone(p.id);
+ });
+
+ return Promise.resolve(presentations);
+ })
+ .then((presentations) => {
+ if (currentPresentation === undefined) {
+ setPresentation('', podId);
+ return Promise.resolve();
+ }
+
+ // If its a newly uploaded presentation we need to get it from promise result
+ if (!currentPresentation.conversion.done) {
+ const currentIndex = presentationsToUpload.findIndex((p) => p === currentPresentation);
+ currentPresentation = presentations[currentIndex];
+ }
+
+ // skip setting as current if error happened
+ if (currentPresentation.conversion.error) {
+ return Promise.resolve();
+ }
+
+ return setPresentation(currentPresentation.id, podId);
+ })
+ .then(removePresentations.bind(null, presentationsToRemove, podId));
+};
+
+const handleSavePresentation = (
+ presentations = [], isFromPresentationUploaderInterface = true, newPres = {},
+) => {
+ if (!isPresentationEnabled()) {
+ return null;
+ }
+
+ const currentPresentations = getPresentations();
+ if (!isFromPresentationUploaderInterface) {
+ if (presentations.length === 0) {
+ presentations = [...currentPresentations];
+ }
+ presentations = presentations.map((p) => update(p, {
+ isCurrent: {
+ $set: false,
+ },
+ }));
+ newPres.isCurrent = true;
+ presentations.push(newPres);
+ }
+ return persistPresentationChanges(
+ currentPresentations,
+ presentations,
+ PRESENTATION_CONFIG.uploadEndpoint,
+ 'DEFAULT_PRESENTATION_POD',
+ );
+};
+
+const getExternalUploadData = () => {
+ const { meetingProp } = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ {
+ fields: {
+ 'meetingProp.presentationUploadExternalDescription': 1,
+ 'meetingProp.presentationUploadExternalUrl': 1,
+ },
+ },
+ );
+
+ const { presentationUploadExternalDescription, presentationUploadExternalUrl } = meetingProp;
+
+ return {
+ presentationUploadExternalDescription,
+ presentationUploadExternalUrl,
+ };
+};
+
+const exportPresentation = (presentationId, observer, fileStateType) => {
+ let lastStatus = {};
+
+ Tracker.autorun((c) => {
+ const cursor = Presentations.find({ id: presentationId });
+
+ const checkStatus = (exportation) => {
+ const shouldStop = ['RUNNING', 'PROCESSING'].includes(lastStatus.status) && exportation.status === 'EXPORTED';
+
+ if (shouldStop) {
+ observer(exportation, true);
+ c.stop();
+ return;
+ }
+
+ observer(exportation, false);
+ lastStatus = exportation;
+ };
+
+ cursor.observe({
+ added: (doc) => {
+ checkStatus(doc.exportation);
+ },
+ changed: (doc) => {
+ checkStatus(doc.exportation);
+ },
+ });
+ });
+
+ makeCall('exportPresentation', presentationId, fileStateType);
+};
+
+function handleFiledrop(files, files2, that, intl, intlMessages) {
+ if (that) {
+ const { fileValidMimeTypes } = that.props;
+ const { toUploadCount } = that.state;
+ const validMimes = fileValidMimeTypes.map((fileValid) => fileValid.mime);
+ const validExtentions = fileValidMimeTypes.map((fileValid) => fileValid.extension);
+ const [accepted, rejected] = partition(
+ files.concat(files2), (f) => (
+ validMimes.includes(f.type) || validExtentions.includes(`.${f.name.split('.').pop()}`)
+ ),
+ );
+
+ const presentationsToUpload = accepted.map((file) => {
+ const id = uniqueId(file.name);
+
+ return {
+ file,
+ isDownloadable: false, // by default new presentations are set not to be downloadable
+ isRemovable: true,
+ id,
+ filename: file.name,
+ isCurrent: false,
+ conversion: { done: false, error: false },
+ upload: { done: false, error: false, progress: 0 },
+ exportation: { error: false },
+ onProgress: (event) => {
+ if (!event.lengthComputable) {
+ that.deepMergeUpdateFileKey(id, 'upload', {
+ progress: 100,
+ done: true,
+ });
+
+ return;
+ }
+
+ that.deepMergeUpdateFileKey(id, 'upload', {
+ progress: (event.loaded / event.total) * 100,
+ done: event.loaded === event.total,
+ });
+ },
+ onConversion: (conversion) => {
+ that.deepMergeUpdateFileKey(id, 'conversion', conversion);
+ },
+ onUpload: (upload) => {
+ that.deepMergeUpdateFileKey(id, 'upload', upload);
+ },
+ onDone: (newId) => {
+ that.updateFileKey(id, 'id', newId);
+ },
+ };
+ });
+
+ that.setState(({ presentations }) => ({
+ presentations: presentations.concat(presentationsToUpload),
+ toUploadCount: (toUploadCount + presentationsToUpload.length),
+ }), () => {
+ // after the state is set (files have been dropped),
+ // make the first of the new presentations current
+ if (presentationsToUpload && presentationsToUpload.length) {
+ that.handleCurrentChange(presentationsToUpload[0].id);
+ }
+ });
+
+ if (rejected.length > 0) {
+ notify(intl.formatMessage(intlMessages.rejectedError), 'error');
+ }
+ }
+}
+
+export default {
+ handleSavePresentation,
+ getPresentations,
+ persistPresentationChanges,
+ dispatchChangePresentationDownloadable,
+ setPresentation,
+ requestPresentationUploadToken,
+ getExternalUploadData,
+ exportPresentation,
+ uploadAndConvertPresentation,
+ handleFiledrop,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js
new file mode 100644
index 00000000..a5de15a5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/presentation-uploader/styles.js
@@ -0,0 +1,669 @@
+import styled, { css, keyframes } from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import Dropzone from 'react-dropzone';
+import Button from '/imports/ui/components/common/button/component';
+import {
+ fileLineWidth,
+ iconPaddingMd,
+ borderSizeLarge,
+ lgPaddingX,
+ statusIconSize,
+ toastMdMargin,
+ uploadListHeight,
+ smPaddingX,
+ smPaddingY,
+ borderSize,
+ borderRadius,
+ lgPaddingY,
+ mdPaddingY,
+ modalInnerWidth,
+ statusInfoHeight,
+ itemActionsWidth,
+ uploadIconSize,
+ iconLineHeight,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ headingsFontWeight,
+ fontSizeLarge,
+ fontSizeLarger,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ colorGrayLight,
+ colorGrayDark,
+ colorPrimary,
+ colorWhite,
+ colorDanger,
+ colorGray,
+ colorGrayLighter,
+ colorLink,
+ colorSuccess,
+ colorGrayLightest,
+ colorText,
+ colorBlueLight,
+ colorOffWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+
+const barStripes = keyframes`
+ from { background-position: 1rem 0; }
+ to { background-position: 0 0; }
+`;
+const rotate = keyframes`
+ 0% { transform: rotate(0); }
+ 100% { transform: rotate(360deg); }
+`;
+
+const UploadRow = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const FileLine = styled.div`
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding-bottom: ${iconPaddingMd};
+ width: ${fileLineWidth};
+`;
+
+const ToastFileName = styled.span`
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ height: 1.25rem !important;
+ margin-left: ${mdPaddingY};
+ height: 1rem;
+ width: auto;
+ text-align: left;
+ font-weight: ${headingsFontWeight};
+
+ [dir="rtl"] & {
+ margin-right: ${mdPaddingY};
+ margin-left: 0;
+ text-align: right;
+ }
+`;
+
+const StatusIcon = styled.span`
+ & > i {
+ height: ${statusIconSize};
+ width: ${statusIconSize};
+ }
+`;
+
+const StatusInfo = styled.div`
+ padding: 0;
+ bottom: ${toastMdMargin};
+ position: relative;
+ left: ${borderSizeLarge};
+
+ [dir="rtl"] & {
+ right: ${borderSizeLarge};
+ left: 0;
+ }
+`;
+
+const FileList = styled(ScrollboxVertical)`
+ height: 100%;
+ max-height: ${uploadListHeight};
+ padding: 1px;
+ margin-bottom: 2rem;
+ overflow-x: hidden;
+`;
+
+const Table = styled.table`
+ position: relative;
+ width: 100%;
+ border-spacing: 0;
+ border-collapse: collapse;
+
+ & > tbody {
+ text-align: left;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+
+ > tr {
+ border-bottom: 1px solid ${colorGrayLight};
+
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &:hover,
+ &:focus {
+ background-color: transparentize(#8B9AA8, .85);
+ }
+
+ th,
+ td {
+ padding: calc(${smPaddingY} * 2) calc(${smPaddingX} / 2);
+ white-space: nowrap;
+ }
+
+ th {
+ font-weight: bold;
+ color: ${colorGrayDark};
+ }
+ }
+ }
+`;
+
+const VisuallyHidden = styled.th`
+ position: absolute;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ height: 1px; width: 1px;
+ margin: -1px; padding: 0; border: 0;
+`;
+
+const ToastWrapper = styled.div`
+ max-height: 50%;
+ width: ${fileLineWidth};
+`;
+
+const UploadToastHeader = styled.div`
+ position: relative;
+ margin-bottom: ${toastMdMargin};
+ padding-bottom: ${smPaddingX};
+`;
+
+const UploadIcon = styled(Icon)`
+ background-color: ${colorPrimary};
+ color: ${colorWhite};
+ height: ${uploadIconSize};
+ width: ${uploadIconSize};
+ border-radius: 50%;
+ font-size: 135%;
+ line-height: ${iconLineHeight};
+ margin-right: ${smPaddingX};
+
+ [dir="rtl"] & {
+ margin-left: ${smPaddingX};
+ margin-right: 0;
+ }
+`;
+
+const UploadToastTitle = styled.span`
+ position: fixed;
+ font-weight: 600;
+ margin-top: ${toastMdMargin};
+`;
+
+const InnerToast = styled(ScrollboxVertical)`
+ position: relative;
+ width: 100%;
+ height: 100%;
+ max-height: ${uploadListHeight};
+ overflow-y: auto;
+ padding-right: 1.5rem;
+ box-sizing: content-box;
+ background: none;
+
+ [dir="rtl"] & {
+ padding-right: 0;
+ padding-left: 1.5rem;
+ }
+`;
+
+const TableItemIcon = styled.td`
+ width: 1%;
+
+ & > i {
+ font-size: 1.35rem;
+ }
+`;
+
+const TableItemCurrent = styled.th`
+ width: 1%;
+
+ padding-left: 0;
+ padding-right: inherit;
+
+ [dir="rtl"] & {
+ padding-left: inherit;
+ padding-right: 0;
+ }
+`;
+
+const CurrentLabel = styled.span`
+ display: inline;
+ padding: .25em .5em;
+ font-size: 75%;
+ font-weight: 700;
+ line-height: 1;
+ color: ${colorWhite};
+ background: ${colorPrimary};
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ border-radius: .25em;
+ text-transform: uppercase;
+`;
+
+const TableItemName = styled.th`
+ height: 1rem;
+ width: auto;
+ position: relative;
+
+ &:before {
+ content: "\\00a0";
+ visibility: hidden;
+ }
+
+ & > span {
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ position: absolute;
+ left: 0;
+ right: 0;
+
+ [dir="rtl"] & {
+ right: 1rem;
+ }
+ }
+`;
+
+const TableItemStatus = styled.td`
+ width: 1%;
+
+ text-align: right;
+
+ [dir="rtl"] & {
+ text-align: left;
+ }
+`;
+
+const ItemAction = styled.div`
+ margin-left: ${smPaddingX};
+ &, & i {
+ margin-top: .25rem;
+ display: inline-block;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.35rem;
+ color: ${colorGrayLight};
+ padding: 0;
+ ${({ animations }) => animations && `
+ transition: all .25s;
+ `}
+ :hover, :focus {
+ padding: unset !important;
+ }
+ }
+`;
+
+const RemoveButton = styled(Button)`
+ [dir="ltr"] & {
+ margin-left: ${smPaddingX};
+ }
+
+ [dir="rtl"] & {
+ margin-right: ${smPaddingX};
+ }
+
+ div > i {
+ margin-top: .25rem;
+ }
+
+ &,
+ & > i {
+ display: inline-block;
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.35rem;
+ color: ${colorGrayLight};
+ padding: 0;
+
+ ${({ animations }) => animations && `
+ transition: all .25s;
+ `}
+
+ :hover, :focus {
+ padding: unset !important;
+ }
+ }
+
+ background-color: transparent;
+ border: 0 !important;
+
+ & > i:focus,
+ & > i:hover {
+ color: ${colorDanger} !important;
+ }
+
+ &[aria-disabled="true"] {
+ cursor: not-allowed;
+ opacity: .5;
+ box-shadow: none;
+ pointer-events: none;
+ }
+`;
+
+const UploaderDropzone = styled(Dropzone)`
+ flex: auto;
+ border: ${borderSize} dashed ${colorGray};
+ color: ${colorGray};
+ border-radius: ${borderRadius};
+ padding: calc(${lgPaddingY} * 2.5) ${lgPaddingX};
+ text-align: center;
+ font-size: ${fontSizeLarge};
+ cursor: pointer;
+
+ & .dropzoneActive {
+ background-color: ${colorGrayLighter};
+ }
+`;
+
+const DropzoneIcon = styled(Icon)`
+ font-size: calc(${fontSizeLarge} * 3);
+`;
+
+const DropzoneMessage = styled.p`
+ margin: ${mdPaddingY} 0;
+`;
+
+const DropzoneLink = styled.span`
+ color: ${colorLink};
+ text-decoration: underline;
+ font-size: 80%;
+ display: block;
+`;
+
+const UploaderModal = styled.div`
+ background-color: white;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1300;
+`;
+
+const ModalInner = styled.div`
+ margin-left: auto;
+ margin-right: auto;
+ width: ${modalInnerWidth};
+ max-height: 100%;
+ max-width: 100%;
+ padding-bottom: .75rem;
+ overflow-y: auto;
+
+ @media ${smallOnly} {
+ padding-left: ${statusInfoHeight};
+ padding-right: ${statusInfoHeight};
+ }
+`;
+
+const ModalHeader = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ border-bottom:${borderSize} solid ${colorGrayLighter};
+ margin-bottom: 2rem;
+ padding: ${mdPaddingX} 0;
+
+ div {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+`;
+
+const ActionWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ margin: 0 0.25rem;
+`;
+
+const DismissButton = styled(Button)`
+ min-width: 6rem;
+ height: 1.875rem;
+ margin-right: ${toastMdMargin};
+`;
+
+const ConfirmButton = styled(Button)`
+ min-width: 6rem;
+ height: 1.875rem;
+`;
+
+const ModalHint = styled.div`
+ margin-bottom: 1rem;
+ color: ${colorText};
+ font-weight: normal;
+`;
+
+const ToastItemIcon = styled(Icon)`
+ position: relative;
+ width: ${statusIconSize};
+ height: ${statusIconSize};
+ font-size: 117%;
+ left: ${statusInfoHeight};
+
+ [dir="rtl"] & {
+ left: unset;
+ right: ${statusInfoHeight};
+ }
+
+ ${({ done }) => done && `
+ color: ${colorSuccess};
+ `}
+
+ ${({ error }) => error && `
+ color: ${colorDanger};
+ `}
+
+ ${({ loading }) => loading && css`
+ color: ${colorGrayLightest};
+ border: 1px solid;
+ border-radius: 50%;
+ border-right-color: ${({ color }) => color || colorGray};
+ animation: ${rotate} 1s linear infinite;
+ `}
+`;
+
+const StatusInfoSpan = styled.span`
+ font-size: 70%;
+
+ ${({ styles }) => styles === 'error' && `
+ display: inline-block;
+ color: ${colorDanger};
+ `}
+`;
+
+const PresentationItem = styled.tr`
+ ${({ isNew }) => isNew && `
+ background-color: rgba(0, 128, 129, 0.05);
+ `}
+
+ ${({ uploading }) => uploading && `
+ background-color: rgba(0, 128, 129, 0.25);
+ `}
+
+ ${({ converting }) => converting && `
+ background-color: rgba(0, 128, 129, 0.25);
+ `}
+
+ ${({ error }) => error && `
+ background-color: rgba(223, 39, 33, 0.25);
+ `}
+
+ ${({ animated }) => animated && `
+ background-image: linear-gradient(45deg,
+ rgba(255, 255, 255, .15) 25%,
+ transparent 25%,
+ transparent 50%,
+ rgba(255, 255, 255, .15) 50%,
+ rgba(255, 255, 255, .15) 75%,
+ transparent 75%,
+ transparent
+ );
+ background-size: 1rem 1rem;
+
+ ${({ animations }) => animations && css`
+ animation: ${barStripes} 1s linear infinite;
+ `}
+ }
+ `}
+`;
+
+const TableItemActions = styled.td`
+ width: 1%;
+ min-width: ${itemActionsWidth};
+ text-align: left;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+
+ ${({ notDownloadable }) => notDownloadable && `
+ min-width: 48px;
+ `}
+`;
+
+const ExtraHint = styled.div`
+ margin-top: 1rem;
+ font-weight: bold;
+`;
+
+const ExternalUpload = styled.div`
+ background-color: ${colorOffWhite};
+ border-radius: ${borderRadius};
+ margin-top: 2rem;
+ padding: ${lgPaddingX};
+ color: ${colorText};
+ font-weight: normal;
+ display: flex;
+ justify-content: space-between;
+ flex-direction: row;
+
+ & p {
+ margin: 0;
+ }
+`;
+
+const ExternalUploadTitle = styled.h4`
+ font-size: 0.9rem;
+ margin: 0;
+`;
+
+const ExternalUploadButton = styled(Button)`
+ height: 2rem;
+ align-self: center;
+ margin-left: 2rem;
+`;
+
+const ExportHint = styled(ModalHint)`
+ margin: 2rem 0;
+`;
+
+const SetCurrentAction = styled.td`
+ width: 0;
+
+ &, & i {
+ border: 0;
+ background: transparent;
+ cursor: pointer;
+ font-size: 1.35rem;
+
+ [dir="ltr"] & {
+ padding-left: 0 !important;
+ }
+
+ [dir="rtl"] & {
+ padding-right: 0 !important;
+ }
+
+ ${({ animations }) => animations && `
+ transition: all .25s;
+ `}
+ }
+`;
+
+const Head = styled.tr`
+ color: ${colorText};
+
+ th {
+ padding: calc(${smPaddingY} * 2) calc(${smPaddingX} / 2);
+ white-space: nowrap;
+ text-align: left;
+
+ [dir="rtl"] & {
+ text-align: right;
+ }
+
+ &:first-child {
+ [dir="ltr"] & {
+ padding-left: 0;
+ }
+
+ [dir="rtl"] & {
+ padding-right: 0;
+ }
+ }
+ }
+`;
+
+const Title = styled.h1`
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: ${fontSizeLarger};
+ font-weight: ${headingsFontWeight};
+`;
+
+export default {
+ UploadRow,
+ FileLine,
+ ToastFileName,
+ StatusIcon,
+ StatusInfo,
+ FileList,
+ Table,
+ VisuallyHidden,
+ ToastWrapper,
+ UploadToastHeader,
+ UploadIcon,
+ UploadToastTitle,
+ InnerToast,
+ TableItemIcon,
+ TableItemCurrent,
+ CurrentLabel,
+ TableItemName,
+ TableItemStatus,
+ ItemAction,
+ RemoveButton,
+ UploaderDropzone,
+ DropzoneIcon,
+ DropzoneMessage,
+ DropzoneLink,
+ UploaderModal,
+ ModalInner,
+ ModalHeader,
+ ActionWrapper,
+ DismissButton,
+ ConfirmButton,
+ ModalHint,
+ ToastItemIcon,
+ StatusInfoSpan,
+ PresentationItem,
+ TableItemActions,
+ ExtraHint,
+ ExternalUpload,
+ ExternalUploadTitle,
+ ExternalUploadButton,
+ ExportHint,
+ SetCurrentAction,
+ Head,
+ Title,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx b/src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx
new file mode 100644
index 00000000..f2b47fee
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/resize-wrapper/component.jsx
@@ -0,0 +1,19 @@
+import React, { Component } from 'react';
+
+const injectWbResizeEvent = (WrappedComponent) => class Resize extends Component {
+ componentDidMount() {
+ window.dispatchEvent(new Event('resize'));
+ }
+
+ componentWillUnmount() {
+ window.dispatchEvent(new Event('resize'));
+ }
+
+ render() {
+ return (
+
+ );
+ }
+};
+
+export default injectWbResizeEvent;
diff --git a/src/2.7.12/imports/ui/components/presentation/service.js b/src/2.7.12/imports/ui/components/presentation/service.js
new file mode 100644
index 00000000..7c858472
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/service.js
@@ -0,0 +1,244 @@
+import Presentations from '/imports/api/presentations';
+import { Slides, SlidePositions } from '/imports/api/slides';
+import PollService from '/imports/ui/components/poll/service';
+import { safeMatch } from '/imports/utils/string-utils';
+
+const POLL_SETTINGS = Meteor.settings.public.poll;
+const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom;
+const MAX_CHAR_LIMIT = POLL_SETTINGS.maxTypedAnswerLength;
+
+const getCurrentPresentation = (podId) => Presentations.findOne({
+ podId,
+ current: true,
+});
+
+const isPresentationDownloadable = (podId) => {
+ const currentPresentation = getCurrentPresentation(podId);
+ if (!currentPresentation) {
+ return null;
+ }
+
+ return currentPresentation.downloadable;
+};
+
+const getCurrentSlide = (podId) => {
+ const currentPresentation = getCurrentPresentation(podId);
+
+ if (!currentPresentation) {
+ return null;
+ }
+
+ return Slides.findOne({
+ podId,
+ presentationId: currentPresentation.id,
+ current: true,
+ }, {
+ fields: {
+ meetingId: 0,
+ thumbUri: 0,
+ txtUri: 0,
+ },
+ });
+};
+
+const getSlidesLength = (podId) => getCurrentPresentation(podId)?.pages?.length || 0;
+
+const getSlidePosition = (podId, presentationId, slideId) => SlidePositions.findOne({
+ podId,
+ presentationId,
+ id: slideId,
+});
+
+const currentSlidHasContent = () => {
+ const currentSlide = getCurrentSlide('DEFAULT_PRESENTATION_POD');
+ if (!currentSlide) return false;
+
+ const {
+ content,
+ } = currentSlide;
+
+ return !!content.length;
+};
+
+// Utility function to escape special characters for regex
+const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+// Function to create a regex pattern
+const createPattern = (values) => new RegExp(`.*(${escapeRegExp(values[0])}\\/${escapeRegExp(values[1])}|${escapeRegExp(values[1])}\\/${escapeRegExp(values[0])}).*`, 'gmi');
+
+const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue, falseValue) => {
+ const { pollTypes } = PollService;
+ const currentSlide = getCurrentSlide('DEFAULT_PRESENTATION_POD');
+ const quickPollOptions = [];
+ if (!currentSlide) return quickPollOptions;
+
+ let {
+ content,
+ } = currentSlide;
+
+ let lines = content.split('\n');
+ let questions = [];
+ let questionLines = [];
+
+ for (let line of lines) {
+ let startsWithCapital = /^[A-Z]/.test(line);
+ let isEndOfQuestion = /\?$/.test(line);
+
+ if (startsWithCapital) {
+ if (questionLines.length > 0) {
+ questions.push(questionLines.join(' '));
+ }
+ questionLines = [];
+ }
+
+ questionLines.push(line.trim());
+
+ if (isEndOfQuestion) {
+ questions.push(questionLines.join(' '));
+ questionLines = [];
+ }
+ }
+
+ if (questionLines.length > 0) {
+ questions.push(questionLines.join(' '));
+ }
+
+ const question = questions.filter(q => /^[A-Z].*\?$/.test(q?.trim()));
+
+ if (question?.length > 0) {
+ question[0] = question[0]?.replace(/\n/g, ' ');
+ const urlRegex = /\bhttps?:\/\/\S+\b/g;
+ const hasUrl = safeMatch(urlRegex, question[0], '');
+ if (hasUrl.length > 0) question.pop();
+ }
+
+ const doubleQuestionRegex = /\?{2}/gm;
+ const doubleQuestion = safeMatch(doubleQuestionRegex, content, false);
+
+ const yesNoPatt = createPattern([yesValue, noValue]);
+ const hasYN = safeMatch(yesNoPatt, content, false);
+
+ const trueFalsePatt = createPattern([trueValue, falseValue]);
+ const hasTF = safeMatch(trueFalsePatt, content, false);
+
+ const pollRegex = /\b[1-9A-Ia-i][.)] .*/g;
+ let optionsPoll = safeMatch(pollRegex, content, []);
+ const optionsWithLabels = [];
+
+ if (hasYN) {
+ optionsPoll = ['yes', 'no'];
+ }
+
+ if (optionsPoll) {
+ optionsPoll = optionsPoll.map((opt) => {
+ const formattedOpt = opt.substring(0, MAX_CHAR_LIMIT);
+ optionsWithLabels.push(formattedOpt);
+ return `\r${opt[0]}.`;
+ });
+ }
+
+ optionsPoll.reduce((acc, currentValue) => {
+ const lastElement = acc[acc.length - 1];
+
+ if (!lastElement) {
+ acc.push({
+ options: [currentValue],
+ });
+ return acc;
+ }
+
+ const {
+ options,
+ } = lastElement;
+
+ const lastOption = options[options.length - 1];
+
+ const isLastOptionInteger = !!parseInt(lastOption.charAt(1), 10);
+ const isCurrentValueInteger = !!parseInt(currentValue.charAt(1), 10);
+
+ if (isLastOptionInteger === isCurrentValueInteger) {
+ if (currentValue.toLowerCase().charCodeAt(1) > lastOption.toLowerCase().charCodeAt(1)) {
+ options.push(currentValue);
+ } else {
+ acc.push({
+ options: [currentValue],
+ });
+ }
+ } else {
+ acc.push({
+ options: [currentValue],
+ });
+ }
+ return acc;
+ }, []).filter(({
+ options,
+ }) => options.length > 1 && options.length < 10).forEach((p) => {
+ const poll = p;
+ if (doubleQuestion) poll.multiResp = true;
+ if (poll.options.length <= 5 || MAX_CUSTOM_FIELDS <= 5) {
+ const maxAnswer = poll.options.length > MAX_CUSTOM_FIELDS
+ ? MAX_CUSTOM_FIELDS
+ : poll.options.length;
+ quickPollOptions.push({
+ type: `${pollTypes.Letter}${maxAnswer}`,
+ poll,
+ });
+ } else {
+ quickPollOptions.push({
+ type: pollTypes.Custom,
+ poll,
+ });
+ }
+ });
+
+ if (question.length > 0 && optionsPoll.length === 0 && !doubleQuestion && !hasYN && !hasTF) {
+ quickPollOptions.push({
+ type: 'R-',
+ poll: {
+ question: question[0],
+ },
+ });
+ }
+
+ if (quickPollOptions.length > 0) {
+ content = content.replace(new RegExp(pollRegex), '');
+ }
+
+ const ynPoll = PollService.matchYesNoPoll(yesValue, noValue, content);
+ const ynaPoll = PollService.matchYesNoAbstentionPoll(yesValue, noValue, abstentionValue, content);
+ const tfPoll = PollService.matchTrueFalsePoll(trueValue, falseValue, content);
+
+ ynPoll.forEach((poll) => quickPollOptions.push({
+ type: pollTypes.YesNo,
+ poll,
+ }));
+
+ ynaPoll.forEach((poll) => quickPollOptions.push({
+ type: pollTypes.YesNoAbstention,
+ poll,
+ }));
+
+ tfPoll.forEach((poll) => quickPollOptions.push({
+ type: pollTypes.TrueFalse,
+ poll,
+ }));
+
+ const pollQuestion = (question?.length > 0 && question[0]?.replace(/ *\([^)]*\) */g, '')) || '';
+
+ return {
+ slideId: currentSlide.id,
+ quickPollOptions,
+ optionsWithLabels,
+ pollQuestion,
+ };
+};
+
+export default {
+ getCurrentSlide,
+ getSlidePosition,
+ isPresentationDownloadable,
+ currentSlidHasContent,
+ parseCurrentSlideContent,
+ getCurrentPresentation,
+ getSlidesLength,
+};
diff --git a/src/2.7.12/imports/ui/components/presentation/slide/component.jsx b/src/2.7.12/imports/ui/components/presentation/slide/component.jsx
new file mode 100644
index 00000000..3c106ec1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/slide/component.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const Slide = ({ imageUri, svgWidth, svgHeight }) => (
+
+ {imageUri
+ // some pdfs lose a white background color during the conversion to svg
+ // their background color is transparent
+ // that's why we have a white rectangle covering the whole slide area by default
+ ? (
+
+
+
+
+ )
+ : null}
+
+);
+
+Slide.propTypes = {
+ // Image Uri
+ imageUri: PropTypes.string.isRequired,
+ // Width of the slide (Svg coordinate system)
+ svgWidth: PropTypes.number.isRequired,
+ // Height of the slide (Svg coordinate system)
+ svgHeight: PropTypes.number.isRequired,
+};
+
+export default Slide;
diff --git a/src/2.7.12/imports/ui/components/presentation/styles.js b/src/2.7.12/imports/ui/components/presentation/styles.js
new file mode 100644
index 00000000..bc8778ca
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/presentation/styles.js
@@ -0,0 +1,184 @@
+import styled from 'styled-components';
+import {
+ innerToastWidth,
+ toastIconSide,
+ smPaddingX,
+ smPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorPrimary,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeLarger,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container';
+import ToastStyled from '/imports/ui/components/common/toast/styles';
+
+const VisuallyHidden = styled.span`
+ position: absolute;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ height: 1px; width: 1px;
+ margin: -1px; padding: 0; border: 0;
+`;
+
+const PresentationSvg = styled.svg`
+ object-fit: contain;
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: 100%;
+
+ //always show an arrow by default
+ cursor: default;
+
+ //double click on the whiteboard shouldn't change the cursor
+ -moz-user-select: -moz-none;
+ -webkit-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+`;
+
+const PresentationFullscreenButton = styled(FullscreenButtonContainer)`
+ z-index: 1;
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: auto;
+ cursor: pointer;
+
+ [dir="rtl"] & {
+ right: auto;
+ left : 0;
+ }
+`;
+
+const InnerToastWrapper = styled.div`
+ width: ${innerToastWidth};
+`;
+
+const ToastIcon = styled.div`
+ margin-right: ${smPaddingX};
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: ${smPaddingX};
+ }
+`;
+
+const IconWrapper = styled.div`
+ background-color: ${colorPrimary};
+ width: ${toastIconSide};
+ height: ${toastIconSide};
+ border-radius: 50%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & > i {
+ position: relative;
+ color: ${colorWhite};
+ font-size: ${fontSizeLarger};
+ }
+`;
+
+const ToastTextContent = styled.div`
+ position: relative;
+ overflow: hidden;
+ margin-top: ${smPaddingY};
+
+ & > div:first-of-type {
+ font-weight: bold;
+ }
+`;
+
+const PresentationName = styled.div`
+ text-overflow: ellipsis;
+ overflow: hidden;
+`;
+
+const ToastDownload = styled.span`
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ a {
+ color: ${colorPrimary};
+ cursor: pointer;
+ text-decoration: none;
+
+ &:focus,
+ &:hover,
+ &:active {
+ color: ${colorPrimary};
+ box-shadow: 0;
+ }
+ }
+`;
+
+const PresentationContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1;
+`;
+
+const Presentation = styled.div`
+ order: 1;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ overflow: hidden;
+ position: relative;
+`;
+
+const SvgContainer = styled.div`
+ width: 100%;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+`;
+
+const WhiteboardSizeAvailable = styled.div`
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ z-index: -1;
+`;
+
+const PresentationToolbar = styled.div`
+ display: flex;
+ overflow-x: visible;
+ order: 2;
+ position: absolute;
+ bottom: 0;
+`;
+
+const ToastSeparator = styled(ToastStyled.Separator)``;
+
+export default {
+ VisuallyHidden,
+ PresentationSvg,
+ PresentationFullscreenButton,
+ InnerToastWrapper,
+ ToastIcon,
+ IconWrapper,
+ ToastTextContent,
+ PresentationName,
+ ToastDownload,
+ PresentationContainer,
+ Presentation,
+ SvgContainer,
+ WhiteboardSizeAvailable,
+ PresentationToolbar,
+ ToastSeparator,
+};
diff --git a/src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx b/src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx
new file mode 100644
index 00000000..fbbd9595
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/raisehand-notifier/component.jsx
@@ -0,0 +1,203 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { toast } from 'react-toastify';
+import Icon from '/imports/ui/components/common/icon/component';
+import { ENTER } from '/imports/utils/keyCodes';
+import Styled from './styles';
+import { Meteor } from 'meteor/meteor';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const messages = defineMessages({
+ lowerHandsLabel: {
+ id: 'app.statusNotifier.lowerHands',
+ description: 'text displayed to clear all raised hands',
+ },
+ lowerHandDescOneUser: {
+ id: 'app.statusNotifier.lowerHandDescOneUser',
+ description: 'text displayed to clear a single user raised hands',
+ },
+ raisedHandsTitle: {
+ id: 'app.statusNotifier.raisedHandsTitle',
+ description: 'heading for raised hands toast',
+ },
+ raisedHandDesc: {
+ id: 'app.statusNotifier.raisedHandDesc',
+ description: 'label for multiple users with raised hands',
+ },
+ raisedHandDescOneUser: {
+ id: 'app.statusNotifier.raisedHandDescOneUser',
+ description: 'label for a single user with raised hand',
+ },
+ and: {
+ id: 'app.statusNotifier.and',
+ description: 'used as conjunction word',
+ },
+});
+
+const MAX_AVATAR_COUNT = 3;
+
+class RaiseHandNotifier extends Component {
+ constructor(props) {
+ super(props);
+
+ this.statusNotifierId = null;
+
+ this.audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename + Meteor.settings.public.app.instanceId}/resources/sounds/bbb-handRaise.mp3`);
+
+ this.renderRaisedHands = this.renderRaisedHands.bind(this);
+ this.getRaisedHandNames = this.getRaisedHandNames.bind(this);
+ this.raisedHandAvatars = this.raisedHandAvatars.bind(this);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ raiseHandUsers, raiseHandAudioAlert, raiseHandPushAlert, isViewer, isPresenter,
+ } = this.props;
+
+ if (isViewer && !isPresenter) {
+ if (this.statusNotifierId) toast.dismiss(this.statusNotifierId);
+ return false;
+ }
+
+ if (raiseHandUsers.length === 0) {
+ return this.statusNotifierId ? toast.dismiss(this.statusNotifierId) : null;
+ }
+
+ if (raiseHandAudioAlert && raiseHandUsers.length > prevProps.raiseHandUsers.length) {
+ this.audio.play();
+ }
+
+ if (raiseHandPushAlert) {
+ if (this.statusNotifierId) {
+ return toast.update(this.statusNotifierId, {
+ render: this.renderRaisedHands(),
+ });
+ }
+
+ this.statusNotifierId = toast(this.renderRaisedHands(), {
+ onClose: () => { this.statusNotifierId = null; },
+ autoClose: false,
+ closeOnClick: false,
+ closeButton: false,
+ className: 'raiseHandToast',
+ });
+ }
+
+ return true;
+ }
+
+ getRaisedHandNames() {
+ const { raiseHandUsers, intl } = this.props;
+ if (raiseHandUsers.length === 0) return '';
+
+ const _names = raiseHandUsers.map((u) => u.name);
+ const { length } = _names;
+ const and = intl.formatMessage(messages.and);
+ let formattedNames = '';
+
+ switch (length) {
+ case 1:
+ formattedNames = _names;
+ break;
+ case 2:
+ formattedNames = _names.join(` ${and} `);
+ break;
+ case 3:
+ formattedNames = _names.slice(0, length - 1).join(', ');
+ formattedNames += ` ${and} ${_names.slice(length - 1)}`;
+ break;
+ default:
+ formattedNames = _names.slice(0, MAX_AVATAR_COUNT).join(', ');
+ formattedNames += ` ${and} ${length - MAX_AVATAR_COUNT}+ `;
+ break;
+ }
+
+ const raisedHandMessageString = length === 1
+ ? messages.raisedHandDescOneUser : messages.raisedHandDesc;
+ return intl.formatMessage(raisedHandMessageString, { 0: formattedNames });
+ }
+
+ raisedHandAvatars() {
+ const { raiseHandUsers, lowerUserHands, intl } = this.props;
+ let users = raiseHandUsers;
+ if (raiseHandUsers.length > MAX_AVATAR_COUNT) users = users.slice(0, MAX_AVATAR_COUNT);
+
+ const avatars = users.map((u) => (
+
+ lowerUserHands(u.userId)}
+ onKeyDown={(e) => (e.keyCode === ENTER ? lowerUserHands(u.userId) : null)}
+ data-test="avatarsWrapperAvatar"
+ moderator={u.role === ROLE_MODERATOR}
+ avatar={u.avatar}
+ >
+ {u.name.slice(0, 2)}
+
+
+ ));
+
+ if (raiseHandUsers.length > MAX_AVATAR_COUNT) {
+ avatars.push(
+
+ {raiseHandUsers.length}
+ ,
+ );
+ }
+
+ return avatars;
+ }
+
+ renderRaisedHands() {
+ const { raiseHandUsers, intl, lowerUserHands } = this.props;
+ const formattedRaisedHands = this.getRaisedHandNames();
+ return (
+
+
+
+
+
+
+
+ {this.raisedHandAvatars()}
+
+
+ {intl.formatMessage(messages.raisedHandsTitle)}
+ {formattedRaisedHands}
+
+
+
{
+ raiseHandUsers.map((u) => lowerUserHands(u.userId));
+ }}
+ data-test="raiseHandRejection"
+ />
+
+ );
+ }
+
+ render() { return null; }
+}
+
+export default injectIntl(RaiseHandNotifier);
+
+RaiseHandNotifier.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ lowerUserHands: PropTypes.func.isRequired,
+ raiseHandUsers: PropTypes.instanceOf(Array).isRequired,
+ raiseHandAudioAlert: PropTypes.bool.isRequired,
+ raiseHandPushAlert: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx b/src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx
new file mode 100644
index 00000000..95f11939
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/raisehand-notifier/container.jsx
@@ -0,0 +1,47 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users';
+import Settings from '/imports/ui/services/settings';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import { makeCall } from '/imports/ui/services/api';
+import RaiseHandNotifier from './component';
+
+const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
+
+const StatusNotifierContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const isViewer = currentUser.role === ROLE_VIEWER;
+ const isPresenter = currentUser.presenter;
+ return (
+
+ );
+};
+
+export default withTracker(() => {
+ const AppSettings = Settings.application;
+ const raiseHandUsers = Users.find({
+ meetingId: Auth.meetingID,
+ raiseHand: true,
+ }, {
+ fields: {
+ raiseHandTime: 1, raiseHand: 1, userId: 1, name: 1, color: 1, role: 1, avatar: 1,
+ },
+ sort: { raiseHandTime: 1 },
+ }).fetch();
+ const lowerUserHands = (userId) => makeCall('changeRaiseHand', false, userId);
+
+ return {
+ lowerUserHands,
+ raiseHandUsers,
+ raiseHandAudioAlert: AppSettings.raiseHandAudioAlerts,
+ raiseHandPushAlert: AppSettings.raiseHandPushAlerts,
+ };
+})(StatusNotifierContainer);
diff --git a/src/2.7.12/imports/ui/components/raisehand-notifier/styles.js b/src/2.7.12/imports/ui/components/raisehand-notifier/styles.js
new file mode 100644
index 00000000..0c3b1d95
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/raisehand-notifier/styles.js
@@ -0,0 +1,151 @@
+import styled from 'styled-components';
+import {
+ avatarSide,
+ borderSize,
+ avatarInset,
+ smPaddingX,
+ toastIconSide,
+ toastMargin,
+ toastMarginMobile,
+ avatarWrapperOffset,
+ jumboPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorWhite,
+ colorGrayLighter,
+ colorGrayLight,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeXL,
+ fontSizeSmall,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ smallOnly,
+} from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+import ToastStyled from '/imports/ui/components/common/toast/styles';
+
+const Avatar = styled.div`
+ cursor: pointer;
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ width: ${avatarSide};
+ height: ${avatarSide};
+ color: ${colorWhite};
+ border-radius: 50%;
+ border: solid ${borderSize} ${colorWhite};
+ margin-left: ${avatarInset};
+ text-align: center;
+ padding: 5px 0;
+
+ &:hover,
+ &:focus {
+ border: solid ${borderSize} ${colorGrayLighter};
+ }
+`;
+
+const AvatarsExtra = styled.div`
+ background-color: ${colorGrayLight};
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ width: ${avatarSide};
+ height: ${avatarSide};
+ color: ${colorWhite};
+ border-radius: 50%;
+ border: solid ${borderSize} ${colorWhite};
+ margin-left: ${avatarInset};
+ text-align: center;
+ padding: 5px 0;
+`;
+
+const ToastIcon = styled.div`
+ margin-right: ${smPaddingX};
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: ${smPaddingX};
+ }
+`;
+
+const IconWrapper = styled.div`
+ background-color: ${colorPrimary};
+ width: ${toastIconSide};
+ height: ${toastIconSide};
+ border-radius: 50%;
+
+ & > i {
+ position: relative;
+ color: ${colorWhite};
+ top: ${toastMargin};
+ left: ${toastMargin};
+ font-size: ${fontSizeXL};
+
+ [dir="rtl"] & {
+ left: 0;
+ right: 10px;
+ }
+ @media ${smallOnly} {
+ {
+ top: ${toastMarginMobile};
+ left: ${toastMarginMobile};
+
+ [dir="rtl"] & {
+ left: 0;
+ right: ${toastMargin};
+ }
+ }
+ }
+ }
+`;
+
+const AvatarsWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ position: absolute;
+ top: ${avatarWrapperOffset};
+ right: 1rem;
+ left: auto;
+
+ [dir="rtl"] & {
+ left: ${jumboPaddingY};
+ right: auto;
+ }
+`;
+
+const ToastMessage = styled.div`
+ font-size: ${fontSizeSmall};
+ margin-top: ${toastMargin};
+
+ & > div {
+ font-weight: bold;
+ }
+`;
+
+const ClearButton = styled(Button)`
+ position: relative;
+ width: 100%;
+ margin-top: ${toastMargin};
+ color: ${colorPrimary};
+
+ &:focus,
+ &:hover,
+ &:active {
+ color: ${colorPrimary};
+ box-shadow: 0;
+ }
+`;
+
+const ToastSeparator = styled(ToastStyled.Separator)``;
+
+export default {
+ Avatar,
+ AvatarsExtra,
+ ToastIcon,
+ IconWrapper,
+ AvatarsWrapper,
+ ToastMessage,
+ ClearButton,
+ ToastSeparator,
+};
diff --git a/src/2.7.12/imports/ui/components/recording/component.jsx b/src/2.7.12/imports/ui/components/recording/component.jsx
new file mode 100644
index 00000000..772e5283
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/recording/component.jsx
@@ -0,0 +1,95 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component';
+
+const intlMessages = defineMessages({
+ startTitle: {
+ id: 'app.recording.startTitle',
+ description: 'start recording title',
+ },
+ stopTitle: {
+ id: 'app.recording.stopTitle',
+ description: 'stop recording title',
+ },
+ resumeTitle: {
+ id: 'app.recording.resumeTitle',
+ description: 'resume recording title',
+ },
+ startDescription: {
+ id: 'app.recording.startDescription',
+ description: 'start recording description',
+ },
+ stopDescription: {
+ id: 'app.recording.stopDescription',
+ description: 'stop recording description',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ toggleRecording: PropTypes.func.isRequired,
+ recordingTime: PropTypes.number,
+ recordingStatus: PropTypes.bool,
+ amIModerator: PropTypes.bool,
+ isMeteorConnected: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ recordingTime: -1,
+ recordingStatus: false,
+ amIModerator: false,
+};
+
+class RecordingComponent extends PureComponent {
+ render() {
+ const {
+ intl,
+ recordingStatus,
+ recordingTime,
+ amIModerator,
+ toggleRecording,
+ isMeteorConnected,
+ isOpen,
+ onRequestClose,
+ priority,
+ setIsOpen,
+ } = this.props;
+
+ let title;
+
+ if (!recordingStatus) {
+ title = recordingTime >= 0 ? intl.formatMessage(intlMessages.resumeTitle)
+ : intl.formatMessage(intlMessages.startTitle);
+ } else {
+ title = intl.formatMessage(intlMessages.stopTitle);
+ }
+
+ if (!amIModerator) return null;
+
+ const description = intl.formatMessage(!recordingStatus
+ ? intlMessages.startDescription
+ : intlMessages.stopDescription);
+
+ return (
+
+ );
+ }
+}
+
+RecordingComponent.propTypes = propTypes;
+RecordingComponent.defaultProps = defaultProps;
+
+export default injectIntl(RecordingComponent);
diff --git a/src/2.7.12/imports/ui/components/recording/container.jsx b/src/2.7.12/imports/ui/components/recording/container.jsx
new file mode 100644
index 00000000..bf9eb4ee
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/recording/container.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { makeCall } from '/imports/ui/services/api';
+import { RecordMeetings } from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import RecordingComponent from './component';
+
+const RecordingContainer = props => ;
+
+export default withTracker(({ setIsOpen }) => {
+ const { recording, time } = RecordMeetings.findOne({ meetingId: Auth.meetingID });
+
+ return ({
+ toggleRecording: () => {
+ makeCall('toggleRecording');
+ setIsOpen(false);
+ },
+
+ recordingStatus: recording,
+ recordingTime: time,
+ isMeteorConnected: Meteor.status().connected,
+
+ });
+})(RecordingContainer);
diff --git a/src/2.7.12/imports/ui/components/reload-button/component.jsx b/src/2.7.12/imports/ui/components/reload-button/component.jsx
new file mode 100644
index 00000000..fa868df9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/reload-button/component.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import Styled from './styles';
+
+const ReloadButtonComponent = ({
+ handleReload,
+ label,
+}) => (
+
+
+
+);
+
+export default ReloadButtonComponent;
diff --git a/src/2.7.12/imports/ui/components/reload-button/styles.js b/src/2.7.12/imports/ui/components/reload-button/styles.js
new file mode 100644
index 00000000..882731a7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/reload-button/styles.js
@@ -0,0 +1,39 @@
+import styled from 'styled-components';
+import { colorTransparent } from '/imports/ui/stylesheets/styled-components/palette';
+import Button from '/imports/ui/components/common/button/component';
+
+const Wrapper = styled.div`
+ background-color: ${colorTransparent};
+ cursor: pointer;
+ border: 0;
+ z-index: 2;
+ margin: 2px;
+`;
+
+const ReloadButton = styled(Button)`
+ &,
+ &:active,
+ &:hover,
+ &:focus {
+ border: none !important;
+
+ i {
+ border: none !important;
+ background-color: ${colorTransparent} !important;
+ }
+ }
+ padding: 5px;
+
+ &:hover {
+ border: 0;
+ }
+
+ i {
+ font-size: 1rem;
+ }
+`;
+
+export default {
+ Wrapper,
+ ReloadButton,
+};
diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/collection.js b/src/2.7.12/imports/ui/components/screenreader-alert/collection.js
new file mode 100644
index 00000000..021409dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenreader-alert/collection.js
@@ -0,0 +1,3 @@
+const ScreenReaderAlertCollection = new Mongo.Collection('Screenreader-alert', { connection: null });
+
+export default ScreenReaderAlertCollection;
diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/component.jsx b/src/2.7.12/imports/ui/components/screenreader-alert/component.jsx
new file mode 100644
index 00000000..9e9947a8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenreader-alert/component.jsx
@@ -0,0 +1,18 @@
+import { createPortal } from 'react-dom';
+import { useEffect } from 'react';
+import { removeAlert } from './service';
+
+const ARIA_ALERT_EXT_TIMEOUT = 15000;
+
+const ScreenReaderAlert = ({ olderAlert }) => {
+ useEffect(() => {
+ if (olderAlert) setTimeout(() => removeAlert(olderAlert.id), ARIA_ALERT_EXT_TIMEOUT);
+ }, [olderAlert?.id]);
+
+ const ariaAlertsElement = document.getElementById('aria-polite-alert');
+ const shouldAddAlert = olderAlert && olderAlert.text && ariaAlertsElement !== null;
+
+ return shouldAddAlert ? createPortal(olderAlert.text, ariaAlertsElement) : null;
+};
+
+export default ScreenReaderAlert;
diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/container.jsx b/src/2.7.12/imports/ui/components/screenreader-alert/container.jsx
new file mode 100644
index 00000000..d5e1e71b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenreader-alert/container.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import ScreenReaderAlert from './component';
+import ScreenReaderAlertCollection from './collection';
+
+const ScreenReaderAlertContainer = ({ ...props }) => {
+ return (
+
+ );
+};
+
+export default withTracker(() => {
+ const olderAlert = ScreenReaderAlertCollection
+ .findOne({}, { sort: { insertTime: +1 } });
+
+ return { olderAlert };
+})(ScreenReaderAlertContainer);
diff --git a/src/2.7.12/imports/ui/components/screenreader-alert/service.js b/src/2.7.12/imports/ui/components/screenreader-alert/service.js
new file mode 100644
index 00000000..9e8de17d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenreader-alert/service.js
@@ -0,0 +1,19 @@
+import ScreenReaderAlertCollection from './collection';
+import { uniqueId } from '/imports/utils/string-utils';
+
+export const addNewAlert = (text) => {
+ const payload = {
+ id: uniqueId('alert-'),
+ insertedTime: Date.now(),
+ text,
+ };
+
+ return ScreenReaderAlertCollection.insert(payload);
+};
+
+export const removeAlert = (id) => ScreenReaderAlertCollection.remove({ id });
+
+export default {
+ addNewAlert,
+ removeAlert,
+};
diff --git a/src/2.7.12/imports/ui/components/screenshare/component.jsx b/src/2.7.12/imports/ui/components/screenshare/component.jsx
new file mode 100644
index 00000000..94af6b95
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/component.jsx
@@ -0,0 +1,578 @@
+import React from 'react';
+import { injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import { debounce } from '/imports/utils/debounce';
+import { Session } from 'meteor/session';
+import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container';
+import SwitchButtonContainer from './switch-button/container';
+import Styled from './styles';
+import VolumeSlider from '../external-video-player/volume-slider/component';
+import AutoplayOverlay from '../media/autoplay-overlay/component';
+import logger from '/imports/startup/client/logger';
+import playAndRetry from '/imports/utils/mediaElementPlayRetry';
+import { notify } from '/imports/ui/services/notification';
+import {
+ SCREENSHARE_MEDIA_ELEMENT_NAME,
+ isMediaFlowing,
+ screenshareHasEnded,
+ screenshareHasStarted,
+ setOutputDeviceId,
+ getMediaElement,
+ getMediaElementDimensions,
+ attachLocalPreviewStream,
+ setVolume,
+ getVolume,
+ getStats,
+} from '/imports/ui/components/screenshare/service';
+import {
+ isStreamStateHealthy,
+ subscribeToStreamStateChange,
+ unsubscribeFromStreamStateChange,
+} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+import Settings from '/imports/ui/services/settings';
+import deviceInfo from '/imports/utils/deviceInfo';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
+const MOBILE_HOVER_TIMEOUT = 5000;
+const MEDIA_FLOW_PROBE_INTERVAL = 500;
+const SCREEN_SIZE_DISPATCH_INTERVAL = 500;
+
+class ScreenshareComponent extends React.Component {
+ static renderScreenshareContainerInside(mainText) {
+ return (
+
+ {mainText}
+
+ );
+ }
+
+ constructor(props) {
+ super();
+ this.state = {
+ loaded: false,
+ autoplayBlocked: false,
+ mediaFlowing: false,
+ switched: false,
+ // Volume control hover toolbar
+ showHoverToolBar: false,
+ };
+
+ this.onLoadedData = this.onLoadedData.bind(this);
+ this.onLoadedMetadata = this.onLoadedMetadata.bind(this);
+ this.onVideoResize = this.onVideoResize.bind(this);
+ this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this);
+ this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
+ this.failedMediaElements = [];
+ this.onStreamStateChange = this.onStreamStateChange.bind(this);
+ this.onSwitched = this.onSwitched.bind(this);
+ this.handleOnVolumeChanged = this.handleOnVolumeChanged.bind(this);
+ this.dispatchScreenShareSize = this.dispatchScreenShareSize.bind(this);
+ this.handleOnMuted = this.handleOnMuted.bind(this);
+ this.dispatchScreenShareSize = this.dispatchScreenShareSize.bind(this);
+ this.debouncedDispatchScreenShareSize = debounce(
+ this.dispatchScreenShareSize,
+ SCREEN_SIZE_DISPATCH_INTERVAL,
+ { leading: false, trailing: true },
+ );
+
+ const { locales, icon } = props;
+ this.locales = locales;
+ this.icon = icon;
+
+ this.volume = getVolume();
+ this.mobileHoverSetTimeout = null;
+ this.mediaFlowMonitor = null;
+ }
+
+ componentDidMount() {
+ const {
+ isLayoutSwapped,
+ layoutContextDispatch,
+ intl,
+ isPresenter,
+ startPreviewSizeBig,
+ outputDeviceId,
+ isSharedNotesPinned,
+ } = this.props;
+
+ screenshareHasStarted(isPresenter, { outputDeviceId });
+ // Autoplay failure handling
+ window.addEventListener('screensharePlayFailed', this.handlePlayElementFailed);
+ // Stream health state tracker to propagate UI changes on reconnections
+ subscribeToStreamStateChange('screenshare', this.onStreamStateChange);
+ // Attaches the local stream if it exists to serve as the local presenter preview
+ attachLocalPreviewStream(getMediaElement());
+
+ this.setState({ switched: startPreviewSizeBig });
+
+ notify(intl.formatMessage(this.locales.started), 'info', this.icon);
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_HAS_SCREEN_SHARE,
+ value: true,
+ });
+
+ if (isLayoutSwapped) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: true,
+ });
+ }
+ Session.set('pinnedNotesLastState', isSharedNotesPinned);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { isPresenter, outputDeviceId } = this.props;
+ if (prevProps.isPresenter && !isPresenter) {
+ screenshareHasEnded();
+ }
+
+ if (prevProps.outputDeviceId !== outputDeviceId && !isPresenter) {
+ setOutputDeviceId(outputDeviceId);
+ }
+ }
+
+ componentWillUnmount() {
+ const {
+ intl,
+ fullscreenContext,
+ layoutContextDispatch,
+ toggleSwapLayout,
+ pinSharedNotes,
+ } = this.props;
+ screenshareHasEnded();
+ window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed);
+ unsubscribeFromStreamStateChange('screenshare', this.onStreamStateChange);
+
+ if (Settings.dataSaving.viewScreenshare) {
+ notify(intl.formatMessage(this.locales.ended), 'info', this.icon);
+ } else {
+ notify(intl.formatMessage(this.locales.endedDueToDataSaving), 'info', this.icon);
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_HAS_SCREEN_SHARE,
+ value: false,
+ });
+
+ if (fullscreenContext) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_FULLSCREEN_ELEMENT,
+ value: {
+ element: '',
+ group: '',
+ },
+ });
+ }
+
+ this.clearMediaFlowingMonitor();
+ layoutContextDispatch({
+ type: ACTIONS.SET_PRESENTATION_IS_OPEN,
+ value: Session.get('presentationLastState'),
+ });
+
+ pinSharedNotes(Session.get('pinnedNotesLastState'));
+ }
+
+ clearMediaFlowingMonitor() {
+ if (this.mediaFlowMonitor) {
+ Meteor.clearInterval(this.mediaFlowMonitor);
+ this.mediaFlowMonitor = null;
+ }
+ }
+
+ handleAllowAutoplay() {
+ const { autoplayBlocked } = this.state;
+
+ logger.info({
+ logCode: 'screenshare_autoplay_allowed',
+ }, 'Screenshare media autoplay allowed by the user');
+
+ window.removeEventListener('screensharePlayFailed', this.handlePlayElementFailed);
+ while (this.failedMediaElements.length) {
+ const mediaElement = this.failedMediaElements.shift();
+ if (mediaElement) {
+ const played = playAndRetry(mediaElement);
+ if (!played) {
+ logger.error({
+ logCode: 'screenshare_autoplay_handling_failed',
+ }, 'Screenshare autoplay handling failed to play media');
+ } else {
+ logger.info({
+ logCode: 'screenshare_viewer_media_play_success',
+ }, 'Screenshare viewer media played successfully');
+ }
+ }
+ }
+ if (autoplayBlocked) { this.setState({ autoplayBlocked: false }); }
+ }
+
+ handlePlayElementFailed(e) {
+ const { mediaElement } = e.detail;
+ const { autoplayBlocked } = this.state;
+
+ e.stopPropagation();
+ this.failedMediaElements.push(mediaElement);
+ if (!autoplayBlocked) {
+ logger.info({
+ logCode: 'screenshare_autoplay_prompt',
+ }, 'Prompting user for action to play screenshare media');
+
+ this.setState({ autoplayBlocked: true });
+ }
+ }
+
+ async monitorMediaFlow() {
+ let previousStats = await getStats();
+ this.mediaFlowMonitor = Meteor.setInterval(async () => {
+ const { mediaFlowing: prevMediaFlowing } = this.state;
+ let mediaFlowing;
+
+ const currentStats = await getStats();
+
+ try {
+ mediaFlowing = isMediaFlowing(previousStats, currentStats);
+ } catch (error) {
+ // Stats processing failed for whatever reason - maintain previous state
+ mediaFlowing = prevMediaFlowing;
+ logger.warn({
+ logCode: 'screenshare_media_monitor_stats_failed',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, 'Failed to collect screenshare stats, flow monitor');
+ }
+
+ previousStats = currentStats;
+
+ if (prevMediaFlowing !== mediaFlowing) this.setState({ mediaFlowing });
+ }, MEDIA_FLOW_PROBE_INTERVAL);
+ }
+
+ dispatchScreenShareSize() {
+ const {
+ layoutContextDispatch,
+ } = this.props;
+
+ const { width, height } = getMediaElementDimensions();
+ const value = {
+ width,
+ height,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ };
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_SCREEN_SHARE_SIZE,
+ value,
+ });
+ }
+
+ onLoadedMetadata() {
+ const element = getMediaElement();
+
+ // Track HTMLVideo's resize event to propagate stream size changes to the
+ // layout engine. See this.onVideoResize;
+ if (element && typeof element.onresize !== 'function') {
+ element.onresize = this.onVideoResize;
+ }
+
+ // Dispatch the initial stream size to the layout engine
+ this.dispatchScreenShareSize();
+ }
+
+ onLoadedData() {
+ this.setState({ loaded: true });
+ }
+
+ onSwitched() {
+ this.setState((prevState) => ({ switched: !prevState.switched }));
+ }
+
+ handleOnVolumeChanged(volume) {
+ this.volume = volume;
+ setVolume(volume);
+ }
+
+ handleOnMuted(muted) {
+ if (muted) {
+ setVolume(0);
+ } else {
+ setVolume(this.volume);
+ }
+ }
+
+ onVideoResize() {
+ // Debounced version of the dispatcher to pace things out - we don't want
+ // to hog the CPU just for resize recalculations...
+ this.debouncedDispatchScreenShareSize();
+ }
+
+ onStreamStateChange(event) {
+ const { streamState } = event.detail;
+ const { mediaFlowing } = this.state;
+
+ const isStreamHealthy = isStreamStateHealthy(streamState);
+ event.stopPropagation();
+
+ if (isStreamHealthy) {
+ this.clearMediaFlowingMonitor();
+ // Current state is media not flowing - stream is now healthy so flip it
+ if (!mediaFlowing) this.setState({ mediaFlowing: isStreamHealthy });
+ } else if (this.mediaFlowMonitor == null) this.monitorMediaFlow();
+ }
+
+ renderFullscreenButton() {
+ const { intl, fullscreenElementId, fullscreenContext } = this.props;
+
+ if (!ALLOW_FULLSCREEN) return null;
+
+ return (
+
+ );
+ }
+
+ renderAutoplayOverlay() {
+ const { intl } = this.props;
+
+ return (
+
+ );
+ }
+
+ renderSwitchButton() {
+ const { showSwitchPreviewSizeButton } = this.props;
+ const { switched } = this.state;
+
+ if (!showSwitchPreviewSizeButton) return null;
+
+ return (
+
+ );
+ }
+
+ renderMobileVolumeControlOverlay () {
+ return (
+ { this.overlay = ref; }}
+ onTouchStart={() => {
+ clearTimeout(this.mobileHoverSetTimeout);
+ this.setState({ showHoverToolBar: true });
+ }}
+ onTouchEnd={() => {
+ this.mobileHoverSetTimeout = setTimeout(
+ () => this.setState({ showHoverToolBar: false }),
+ MOBILE_HOVER_TIMEOUT,
+ );
+ }}
+ />
+ );
+ }
+
+ renderVolumeSlider() {
+ const { showHoverToolBar } = this.state;
+
+ let toolbarStyle = 'hoverToolbar';
+
+ if (deviceInfo.isMobile && !showHoverToolBar) {
+ toolbarStyle = 'dontShowMobileHoverToolbar';
+ }
+
+ if (deviceInfo.isMobile && showHoverToolBar) {
+ toolbarStyle = 'showMobileHoverToolbar';
+ }
+
+ return [(
+
+
+
+ ),
+ (deviceInfo.isMobile) && this.renderMobileVolumeControlOverlay(),
+ ];
+ }
+
+ renderVideo(switched) {
+ const { isGloballyBroadcasting } = this.props;
+ const { mediaFlowing } = this.state;
+
+ return (
+ {
+ this.videoTag = ref;
+ }}
+ muted
+ />
+ );
+ }
+
+ renderScreensharePresenter() {
+ const { switched } = this.state;
+ const { isGloballyBroadcasting, intl } = this.props;
+
+ return (
+ { this.screenshareContainer = ref; }}
+ >
+ {isGloballyBroadcasting && this.renderSwitchButton()}
+ {this.renderVideo(switched)}
+
+ {
+ isGloballyBroadcasting
+ ? (
+
+ {!switched
+ && ScreenshareComponent.renderScreenshareContainerInside(
+ intl.formatMessage(this.locales.presenterSharingLabel),
+ )}
+
+ )
+ : ScreenshareComponent.renderScreenshareContainerInside(
+ intl.formatMessage(this.locales.presenterLoadingLabel),
+ )
+ }
+
+ );
+ }
+
+ renderScreenshareDefault() {
+ const { intl, enableVolumeControl } = this.props;
+ const { loaded } = this.state;
+
+ return (
+ {
+ this.screenshareContainer = ref;
+ }}
+ id="screenshareContainer"
+ >
+ {loaded && this.renderFullscreenButton()}
+ {this.renderVideo(true)}
+ {loaded && enableVolumeControl && this.renderVolumeSlider() }
+
+
+ {
+ !loaded
+ ? ScreenshareComponent.renderScreenshareContainerInside(
+ intl.formatMessage(this.locales.viewerLoadingLabel),
+ )
+ : null
+ }
+
+
+ );
+ }
+
+ render() {
+ const { loaded, autoplayBlocked, mediaFlowing} = this.state;
+ const {
+ isPresenter,
+ isGloballyBroadcasting,
+ top,
+ left,
+ right,
+ width,
+ height,
+ zIndex,
+ fullscreenContext,
+ } = this.props;
+
+ // Conditions to render the (re)connecting dots and the unhealthy stream
+ // grayscale:
+ // 1 - The local media tag has not received any stream data yet
+ // 2 - The user is a presenter and the stream wasn't globally broadcasted yet
+ // 3 - The media was loaded, the stream was globally broadcasted BUT the stream
+ // state transitioned to an unhealthy stream. tl;dr: screen sharing reconnection
+ const shouldRenderConnectingState = !loaded
+ || (isPresenter && !isGloballyBroadcasting)
+ || (!mediaFlowing && loaded && isGloballyBroadcasting);
+
+ const display = (width > 0 && height > 0) ? 'inherit' : 'none';
+ const { animations } = Settings.application;
+
+ return (
+
+ {(shouldRenderConnectingState)
+ && (
+
+
+
+
+
+
+
+ )}
+ {autoplayBlocked ? this.renderAutoplayOverlay() : null}
+ {isPresenter ? this.renderScreensharePresenter() : this.renderScreenshareDefault()}
+
+ );
+ }
+}
+
+export default injectIntl(ScreenshareComponent);
+
+ScreenshareComponent.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isPresenter: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ enableVolumeControl: PropTypes.bool.isRequired,
+ outputDeviceId: PropTypes.string,
+};
diff --git a/src/2.7.12/imports/ui/components/screenshare/container.jsx b/src/2.7.12/imports/ui/components/screenshare/container.jsx
new file mode 100644
index 00000000..1f3f148d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/container.jsx
@@ -0,0 +1,160 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import {
+ getSharingContentType,
+ getBroadcastContentType,
+ isScreenGloballyBroadcasting,
+ isCameraAsContentGloballyBroadcasting,
+ isScreenBroadcasting,
+ isCameraAsContentBroadcasting,
+} from './service';
+import ScreenshareComponent from './component';
+import { layoutSelect, layoutSelectOutput, layoutDispatch } from '../layout/context';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import AudioService from '/imports/ui/components/audio/service';
+import { shouldEnableVolumeControl } from './service';
+import MediaService from '/imports/ui/components/media/service';
+import { defineMessages } from 'react-intl';
+
+const screenshareIntlMessages = defineMessages({
+ // SCREENSHARE
+ label: {
+ id: 'app.screenshare.screenShareLabel',
+ description: 'screen share area element label',
+ },
+ presenterLoadingLabel: {
+ id: 'app.screenshare.presenterLoadingLabel',
+ },
+ viewerLoadingLabel: {
+ id: 'app.screenshare.viewerLoadingLabel',
+ },
+ presenterSharingLabel: {
+ id: 'app.screenshare.presenterSharingLabel',
+ },
+ autoplayBlockedDesc: {
+ id: 'app.media.screenshare.autoplayBlockedDesc',
+ },
+ autoplayAllowLabel: {
+ id: 'app.media.screenshare.autoplayAllowLabel',
+ },
+ started: {
+ id: 'app.media.screenshare.start',
+ description: 'toast to show when a screenshare has started',
+ },
+ ended: {
+ id: 'app.media.screenshare.end',
+ description: 'toast to show when a screenshare has ended',
+ },
+ endedDueToDataSaving: {
+ id: 'app.media.screenshare.endDueToDataSaving',
+ description: 'toast to show when a screenshare has ended by changing data savings option',
+ }
+});
+
+const cameraAsContentIntlMessages = defineMessages({
+ // CAMERA AS CONTENT
+ label: {
+ id: 'app.cameraAsContent.cameraAsContentLabel',
+ description: 'screen share area element label',
+ },
+ presenterLoadingLabel: {
+ id: 'app.cameraAsContent.presenterLoadingLabel',
+ },
+ viewerLoadingLabel: {
+ id: 'app.cameraAsContent.viewerLoadingLabel',
+ },
+ presenterSharingLabel: {
+ id: 'app.cameraAsContent.presenterSharingLabel',
+ },
+ autoplayBlockedDesc: {
+ id: 'app.media.cameraAsContent.autoplayBlockedDesc',
+ },
+ autoplayAllowLabel: {
+ id: 'app.media.cameraAsContent.autoplayAllowLabel',
+ },
+ started: {
+ id: 'app.media.cameraAsContent.start',
+ description: 'toast to show when camera as content has started',
+ },
+ ended: {
+ id: 'app.media.cameraAsContent.end',
+ description: 'toast to show when camera as content has ended',
+ },
+ endedDueToDataSaving: {
+ id: 'app.media.cameraAsContent.endDueToDataSaving',
+ description: 'toast to show when camera as content has ended by changing data savings option',
+ },
+});
+import NotesService from '/imports/ui/components/notes/service';
+
+const ScreenshareContainer = (props) => {
+ const screenShare = layoutSelectOutput((i) => i.screenShare);
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { element } = fullscreen;
+ const fullscreenElementId = 'Screenshare';
+ const fullscreenContext = (element === fullscreenElementId);
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const isPresenter = currentUser.presenter;
+
+ const info = {
+ screenshare: {
+ icon: "desktop",
+ locales: screenshareIntlMessages,
+ startPreviewSizeBig: false,
+ showSwitchPreviewSizeButton: true,
+ },
+ camera: {
+ icon: "video",
+ locales: cameraAsContentIntlMessages,
+ startPreviewSizeBig: true,
+ showSwitchPreviewSizeButton: false,
+ },
+ };
+
+ const getContentType = () => {
+ return isPresenter ? getSharingContentType() : getBroadcastContentType();
+ }
+ const contentTypeInfo = info[getContentType()];
+ const defaultInfo = info.camera;
+ const selectedInfo = contentTypeInfo ? contentTypeInfo : defaultInfo;
+
+ if (isScreenBroadcasting() || isCameraAsContentBroadcasting()) {
+ return (
+
+ );
+ }
+ return null;
+};
+
+const LAYOUT_CONFIG = Meteor.settings.public.layout;
+
+export default withTracker(() => {
+ return {
+ isGloballyBroadcasting: isScreenGloballyBroadcasting() || isCameraAsContentGloballyBroadcasting(),
+ toggleSwapLayout: MediaService.toggleSwapLayout,
+ hidePresentationOnJoin: getFromUserSettings('bbb_hide_presentation_on_join', LAYOUT_CONFIG.hidePresentationOnJoin),
+ enableVolumeControl: shouldEnableVolumeControl(),
+ outputDeviceId: AudioService.outputDeviceId(),
+ isSharedNotesPinned: MediaService.shouldShowSharedNotes(),
+ pinSharedNotes: NotesService.pinSharedNotes,
+ };
+})(ScreenshareContainer);
diff --git a/src/2.7.12/imports/ui/components/screenshare/service.js b/src/2.7.12/imports/ui/components/screenshare/service.js
new file mode 100644
index 00000000..8f8ae5b7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/service.js
@@ -0,0 +1,427 @@
+import Screenshare from '/imports/api/screenshare';
+import KurentoBridge from '/imports/api/screenshare/client/bridge';
+import BridgeService from '/imports/api/screenshare/client/bridge/service';
+import Settings from '/imports/ui/services/settings';
+import logger from '/imports/startup/client/logger';
+import { stopWatching } from '/imports/ui/components/external-video-player/service';
+import Meetings from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import AudioService from '/imports/ui/components/audio/service';
+import { Meteor } from "meteor/meteor";
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+import ConnectionStatusService from '/imports/ui/components/connection-status/service';
+import browserInfo from '/imports/utils/browserInfo';
+import NotesService from '/imports/ui/components/notes/service';
+
+const VOLUME_CONTROL_ENABLED = Meteor.settings.public.kurento.screenshare.enableVolumeControl;
+const SCREENSHARE_MEDIA_ELEMENT_NAME = 'screenshareVideo';
+
+const DEFAULT_SCREENSHARE_STATS_TYPES = [
+ 'outbound-rtp',
+ 'inbound-rtp',
+];
+
+const CONTENT_TYPE_CAMERA = "camera";
+const CONTENT_TYPE_SCREENSHARE = "screenshare";
+
+let _isSharingScreen = false;
+const _isSharingDep = {
+ value: false,
+ tracker: new Tracker.Dependency(),
+};
+
+const _sharingContentTypeDep = {
+ value: false,
+ tracker: new Tracker.Dependency(),
+};
+
+const _cameraAsContentDeviceIdTypeDep = {
+ value: '',
+ tracker: new Tracker.Dependency(),
+};
+
+const isSharing = () => {
+ _isSharingDep.tracker.depend();
+ return _isSharingDep.value;
+};
+
+const setIsSharing = (isSharing) => {
+ if (_isSharingDep.value !== isSharing) {
+ _isSharingDep.value = isSharing;
+ _isSharingDep.tracker.changed();
+ }
+};
+
+const setSharingContentType = (contentType) => {
+ if (_sharingContentTypeDep.value !== contentType) {
+ _sharingContentTypeDep.value = contentType;
+ _sharingContentTypeDep.tracker.changed();
+ }
+}
+
+const getSharingContentType = () => {
+ _sharingContentTypeDep.tracker.depend();
+ return _sharingContentTypeDep.value;
+};
+
+const getCameraAsContentDeviceId = () => {
+ _cameraAsContentDeviceIdTypeDep.tracker.depend();
+ return _cameraAsContentDeviceIdTypeDep.value;
+};
+
+const setCameraAsContentDeviceId = (deviceId) => {
+ if (_cameraAsContentDeviceIdTypeDep.value !== deviceId) {
+ _cameraAsContentDeviceIdTypeDep.value = deviceId;
+ _cameraAsContentDeviceIdTypeDep.tracker.changed();
+ }
+};
+
+const _trackStreamTermination = (stream, handler) => {
+ if (typeof stream !== 'object' || typeof handler !== 'function') {
+ throw new TypeError('Invalid trackStreamTermination arguments');
+ }
+ let _handler = handler;
+
+ // Dirty, but effective way of checking whether the browser supports the 'inactive'
+ // event. If the oninactive interface is null, it can be overridden === supported.
+ // If undefined, it's not; so we fallback to the track 'ended' event.
+ // The track ended listener should probably be reviewed once we create
+ // thin wrapper classes for MediaStreamTracks as well, because we'll want a single
+ // media stream holding multiple tracks in the future
+ if (stream.oninactive !== undefined) {
+ if (typeof stream.oninactive === 'function') {
+ const oldHandler = stream.oninactive;
+ _handler = () => {
+ oldHandler();
+ handler();
+ };
+ }
+ stream.addEventListener('inactive', handler, { once: true });
+ } else {
+ const track = MediaStreamUtils.getVideoTracks(stream)[0];
+ if (track) {
+ track.addEventListener('ended', handler, { once: true });
+ if (typeof track.onended === 'function') {
+ const oldHandler = track.onended;
+ _handler = () => {
+ oldHandler();
+ handler();
+ };
+ }
+ track.onended = _handler;
+ }
+ }
+};
+
+const _isStreamActive = (stream) => {
+ const tracksAreActive = !stream.getTracks().some(track => track.readyState === 'ended');
+
+ return tracksAreActive && stream.active;
+}
+
+const _handleStreamTermination = () => {
+ screenshareHasEnded();
+};
+
+// A simplified, trackable version of isScreenBroadcasting that DOES NOT
+// account for the presenter's local sharing state.
+// It reflects the GLOBAL screen sharing state (akka-apps)
+const isScreenGloballyBroadcasting = () => {
+ const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_SCREENSHARE },
+ { fields: { 'screenshare.stream': 1 } });
+
+ return (!screenshareEntry ? false : !!screenshareEntry.screenshare.stream);
+}
+
+// A simplified, trackable version of isCameraContentBroadcasting that DOES NOT
+// account for the presenter's local sharing state.
+// It reflects the GLOBAL camera as content sharing state (akka-apps)
+const isCameraAsContentGloballyBroadcasting = () => {
+ const cameraAsContentEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_CAMERA },
+ { fields: { 'screenshare.stream': 1 } });
+
+ return (!cameraAsContentEntry ? false : !!cameraAsContentEntry.screenshare.stream);
+}
+
+// when the meeting information has been updated check to see if it was
+// screensharing. If it has changed either trigger a call to receive video
+// and display it, or end the call and hide the video
+const isScreenBroadcasting = () => {
+ const sharing = isSharing() && getSharingContentType() == CONTENT_TYPE_SCREENSHARE;
+ const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_SCREENSHARE },
+ { fields: { 'screenshare.stream': 1 } });
+ const screenIsShared = !screenshareEntry ? false : !!screenshareEntry.screenshare.stream;
+
+ if (screenIsShared && isSharing) {
+ setIsSharing(false);
+ }
+
+ return sharing || screenIsShared;
+};
+
+// when the meeting information has been updated check to see if it was
+// sharing camera as content. If it has changed either trigger a call to receive video
+// and display it, or end the call and hide the video
+const isCameraAsContentBroadcasting = () => {
+ const sharing = isSharing() && getSharingContentType() === CONTENT_TYPE_CAMERA;
+ const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID, "screenshare.contentType": CONTENT_TYPE_CAMERA },
+ { fields: { 'screenshare.stream': 1 } });
+ const cameraAsContentIsShared = !screenshareEntry ? false : !!screenshareEntry.screenshare.stream;
+
+ if (cameraAsContentIsShared && isSharing) {
+ setIsSharing(false);
+ }
+
+ return sharing || cameraAsContentIsShared;
+};
+
+
+const screenshareHasAudio = () => {
+ const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'screenshare.hasAudio': 1 } });
+
+ if (!screenshareEntry) {
+ return false;
+ }
+
+ return !!screenshareEntry.screenshare.hasAudio;
+}
+
+const getBroadcastContentType = () => {
+ const screenshareEntry = Screenshare.findOne({meetindId: Auth.meedingID},
+ { fields: { 'screenshare.contentType': 1} });
+
+ if (!screenshareEntry) {
+ // defaults to contentType: "camera"
+ return CONTENT_TYPE_CAMERA;
+ }
+
+ return screenshareEntry.screenshare.contentType;
+}
+
+const screenshareHasEnded = () => {
+ if (isSharing()) {
+ setIsSharing(false);
+ }
+ if (getSharingContentType() === CONTENT_TYPE_CAMERA) {
+ setCameraAsContentDeviceId('');
+ }
+
+ KurentoBridge.stop();
+};
+
+const getMediaElement = () => {
+ return document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME);
+}
+
+const getMediaElementDimensions = () => {
+ const element = getMediaElement();
+ return {
+ width: element?.videoWidth ?? 0,
+ height: element?.videoHeight ?? 0,
+ };
+};
+
+const setVolume = (volume) => {
+ KurentoBridge.setVolume(volume);
+};
+
+const getVolume = () => KurentoBridge.getVolume();
+
+const shouldEnableVolumeControl = () => VOLUME_CONTROL_ENABLED && screenshareHasAudio();
+
+const attachLocalPreviewStream = (mediaElement) => {
+ const {isTabletApp} = browserInfo;
+ if (isTabletApp) {
+ // We don't show preview for mobile app, as the stream is only available in native code
+ return;
+ }
+ const stream = KurentoBridge.gdmStream;
+ if (stream && mediaElement) {
+ // Always muted, presenter preview.
+ BridgeService.screenshareLoadAndPlayMediaStream(stream, mediaElement, true);
+ }
+};
+
+const setOutputDeviceId = (outputDeviceId) => {
+ const screenShareElement = document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME);
+ const sinkIdSupported = screenShareElement && typeof screenShareElement.setSinkId === 'function';
+ const srcStream = screenShareElement?.srcObject;
+
+ if (typeof outputDeviceId === 'string'
+ && sinkIdSupported
+ && screenShareElement.sinkId !== outputDeviceId
+ && srcStream
+ && srcStream.getAudioTracks().length > 0) {
+ try {
+ screenShareElement.setSinkId(outputDeviceId);
+ logger.debug({
+ logCode: 'screenshare_output_device_change',
+ extraInfo: {
+ newDeviceId: outputDeviceId,
+ },
+ }, `Screenshare output device changed: to ${outputDeviceId || 'default'}`);
+ } catch (error) {
+ logger.error({
+ logCode: 'screenshare_output_device_change_failure',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ newDeviceId: outputDeviceId,
+ },
+ }, `Error changing screenshare output device - {${error.name}: ${error.message}}`);
+ }
+ }
+};
+
+const screenshareHasStarted = (isPresenter, options = {}) => {
+ // Presenter's screen preview is local, so skip
+ if (!isPresenter) {
+ viewScreenshare({ outputDeviceId: options.outputDeviceId });
+ }
+};
+
+const shareScreen = async (isPresenter, onFail, options = {}) => {
+ if (isCameraAsContentBroadcasting()) {
+ screenshareHasEnded();
+ }
+ // stop external video share if running
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID });
+
+ if (meeting && meeting.externalVideoUrl) {
+ stopWatching();
+ }
+
+ try {
+ let stream;
+ let contentType = CONTENT_TYPE_SCREENSHARE;
+ if (options.stream == null) {
+ stream = await BridgeService.getScreenStream();
+ } else {
+ contentType = CONTENT_TYPE_CAMERA;
+ stream = options.stream;
+ }
+ _trackStreamTermination(stream, _handleStreamTermination);
+
+ if (!isPresenter) {
+ MediaStreamUtils.stopMediaStreamTracks(stream);
+ return;
+ }
+
+ await KurentoBridge.share(stream, onFail, contentType);
+
+ // Stream might have been disabled in the meantime. I love badly designed
+ // async components like this screen sharing bridge :) - prlanzarin 09 May 22
+ if (!_isStreamActive(stream)) {
+ _handleStreamTermination();
+ return;
+ }
+
+ // Close Shared Notes if open.
+ NotesService.pinSharedNotes(false);
+
+ setSharingContentType(contentType);
+ setIsSharing(true);
+ } catch (error) {
+ onFail(error);
+ }
+};
+
+const viewScreenshare = (options = {}) => {
+ const hasAudio = screenshareHasAudio();
+ KurentoBridge.view({ hasAudio, outputDeviceId: options.outputDeviceId })
+ .catch((error) => {
+ logger.error({
+ logCode: 'screenshare_view_failed',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, 'Screenshare viewer failure');
+ });
+};
+
+const screenShareEndAlert = () => AudioService
+ .playAlertSound(`${Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId}`
+ + '/resources/sounds/ScreenshareOff.mp3');
+
+const dataSavingSetting = () => Settings.dataSaving.viewScreenshare;
+
+/**
+ * Get stats about all active screenshare peers.
+ *
+ * For more information see:
+ * - https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats
+ * - https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
+
+ * @param {Array[String]} statsType - An array containing valid RTCStatsType
+ * values to include in the return object
+ *
+ * @returns {Object} The information about each active screen sharing peer.
+ * The returned format follows the format returned by video's service
+ * getStats, which considers more than one peer connection to be returned.
+ * The format is given by:
+ * {
+ * peerIdString: RTCStatsReport
+ * }
+ */
+const getStats = async (statsTypes = DEFAULT_SCREENSHARE_STATS_TYPES) => {
+ const screenshareStats = {};
+ const peer = KurentoBridge.getPeerConnection();
+
+ if (!peer) return null;
+
+ const peerStats = await peer.getStats();
+
+ peerStats.forEach((stat) => {
+ if (statsTypes.includes(stat.type)) {
+ screenshareStats[stat.type] = stat;
+ }
+ });
+
+ return { screenshareStats };
+};
+
+// This method may throw errors
+const isMediaFlowing = (previousStats, currentStats) => {
+ const bpsData = ConnectionStatusService.calculateBitsPerSecond(
+ currentStats?.screenshareStats,
+ previousStats?.screenshareStats,
+ );
+ const bpsDataAggr = Object.values(bpsData)
+ .reduce((sum, partialBpsData = 0) => sum + parseFloat(partialBpsData), 0);
+
+ return bpsDataAggr > 0;
+};
+
+export {
+ SCREENSHARE_MEDIA_ELEMENT_NAME,
+ isMediaFlowing,
+ isScreenBroadcasting,
+ isCameraAsContentBroadcasting,
+ screenshareHasEnded,
+ screenshareHasStarted,
+ screenshareHasAudio,
+ getBroadcastContentType,
+ shareScreen,
+ screenShareEndAlert,
+ dataSavingSetting,
+ isSharing,
+ setIsSharing,
+ setSharingContentType,
+ getSharingContentType,
+ getMediaElement,
+ getMediaElementDimensions,
+ attachLocalPreviewStream,
+ isScreenGloballyBroadcasting,
+ isCameraAsContentGloballyBroadcasting,
+ getStats,
+ setVolume,
+ getVolume,
+ shouldEnableVolumeControl,
+ setCameraAsContentDeviceId,
+ getCameraAsContentDeviceId,
+ setOutputDeviceId,
+};
diff --git a/src/2.7.12/imports/ui/components/screenshare/styles.js b/src/2.7.12/imports/ui/components/screenshare/styles.js
new file mode 100644
index 00000000..b5f948aa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/styles.js
@@ -0,0 +1,105 @@
+import styled from 'styled-components';
+import {
+ colorWhite,
+ colorContentBackground,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import SpinnerStyles from '/imports/ui/components/common/loading-screen/styles';
+
+const ScreenshareContainerInside = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ text-align: center;
+`;
+
+const MainText = styled.h1`
+ color: ${colorWhite};
+ font-size: 1.3rem;
+ font-weight: 600;
+`;
+
+const ScreenshareVideo = styled.video`
+ ${({ unhealthyStream }) => unhealthyStream && `
+ filter: grayscale(50%) opacity(50%);
+ `}
+`;
+
+const ScreenshareContainer = styled.div`
+ position: relative;
+ background-color: ${colorContentBackground};
+ width: 100%;
+ height: 100%;
+
+ ${({ switched }) => !switched && `
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ `}
+`;
+
+const ScreenshareContainerDefault = styled.div`
+ position: absolute;
+ align-items: center;
+ justify-content: center;
+ padding-top: 4rem;
+`;
+
+const SpinnerWrapper = styled.div`
+ position: absolute;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+`;
+
+const Spinner = styled(SpinnerStyles.Spinner)``;
+
+const Bounce1 = styled(SpinnerStyles.Bounce1)``;
+
+const Bounce2 = styled(SpinnerStyles.Bounce2)``;
+
+const MobileControlsOverlay = styled.span`
+ position: absolute;
+ top:0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: transparent;
+`;
+
+const HoverToolbar = styled.div`
+ ${({ toolbarStyle }) => toolbarStyle === 'hoverToolbar' && `
+ display: none;
+
+ #screenshareContainer:hover > & {
+ display: flex;
+ }
+ `}
+
+ ${({ toolbarStyle }) => toolbarStyle === 'dontShowMobileHoverToolbar' && `
+ display: none;
+ `}
+
+ ${({ toolbarStyle }) => toolbarStyle === 'showMobileHoverToolbar' && `
+ display: flex;
+ z-index: 2;
+ `}
+`;
+
+export default {
+ ScreenshareContainerInside,
+ MainText,
+ ScreenshareVideo,
+ ScreenshareContainer,
+ ScreenshareContainerDefault,
+ SpinnerWrapper,
+ Spinner,
+ Bounce1,
+ Bounce2,
+ MobileControlsOverlay,
+ HoverToolbar,
+};
diff --git a/src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx b/src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx
new file mode 100644
index 00000000..e7bf4ab4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/switch-button/component.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ switchButtonShrink: {
+ id: 'app.switchButton.shrinkLabel',
+ description: 'shrink label',
+ },
+ switchButtonExpand: {
+ id: 'app.switchButton.expandLabel',
+ description: 'expand label',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ dark: PropTypes.bool,
+ bottom: PropTypes.bool,
+ handleSwitch: PropTypes.func,
+ switched: PropTypes.bool,
+};
+
+const defaultProps = {
+ dark: false,
+ bottom: false,
+ handleSwitch: () => {},
+ switched: false,
+};
+
+const SwitchButtonComponent = (props) => {
+ const {
+ intl,
+ dark,
+ bottom,
+ handleSwitch,
+ switched,
+ } = props;
+ const formattedLabel = intl.formatMessage(switched
+ ? intlMessages.switchButtonShrink
+ : intlMessages.switchButtonExpand);
+
+ return (
+
+
+
+ );
+};
+
+SwitchButtonComponent.propTypes = propTypes;
+SwitchButtonComponent.defaultProps = defaultProps;
+
+export default injectIntl(SwitchButtonComponent);
diff --git a/src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx b/src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx
new file mode 100644
index 00000000..8b3e5ef1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/switch-button/container.jsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import SwitchButtonComponent from './component';
+
+const SwitchButtonContainer = props => ;
+
+export default props => (
+
+);
diff --git a/src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js b/src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js
new file mode 100644
index 00000000..94f1c68f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/screenshare/switch-button/styles.js
@@ -0,0 +1,70 @@
+import styled from 'styled-components';
+import {
+ colorWhite,
+ colorTransparent,
+ colorBlack,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import Button from '/imports/ui/components/common/button/component';
+
+const SwitchButtonWrapper = styled.div`
+ position: absolute;
+ right: 0;
+ left: auto;
+ background-color: ${colorTransparent};
+ cursor: pointer;
+ border: 0;
+ z-index: 2;
+ margin: 2px;
+
+ [dir="rtl"] & {
+ right: auto;
+ left: 1.75rem;
+ }
+
+ ${({ dark }) => dark && `
+ background-color: rgba(0,0,0,.3);
+
+ & button i {
+ color: ${colorWhite};
+ }
+ `}
+
+ ${({ dark }) => !dark && `
+ background-color: ${colorTransparent};
+
+ & button i {
+ color: ${colorBlack};
+ }
+ `}
+
+ ${({ bottom }) => bottom && `
+ bottom: 0;
+ `}
+
+ ${({ bottom }) => !bottom && `
+ top: 0;
+ `}
+`;
+
+const SwitchButton = styled(Button)`
+ padding: 5px;
+
+ &,
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: ${colorTransparent} !important;
+ border: none !important;
+
+ i {
+ border: none !important;
+ background-color: ${colorTransparent} !important;
+ font-size: 1rem;
+ }
+ }
+`;
+
+export default {
+ SwitchButtonWrapper,
+ SwitchButton,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/component.jsx b/src/2.7.12/imports/ui/components/settings/component.jsx
new file mode 100644
index 00000000..c9f63456
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/component.jsx
@@ -0,0 +1,348 @@
+import React, { Component } from 'react';
+import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import DataSaving from '/imports/ui/components/settings/submenus/data-saving/component';
+import Application from '/imports/ui/components/settings/submenus/application/component';
+import Notification from '/imports/ui/components/settings/submenus/notification/component';
+import Transcription from '/imports/ui/components/settings/submenus/transcription/component';
+import { clone } from 'radash';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import { formatLocaleCode } from '/imports/utils/string-utils';
+
+const intlMessages = defineMessages({
+ appTabLabel: {
+ id: 'app.settings.applicationTab.label',
+ description: 'label for application tab',
+ },
+ audioTabLabel: {
+ id: 'app.settings.audioTab.label',
+ description: 'label for audio tab',
+ },
+ videoTabLabel: {
+ id: 'app.settings.videoTab.label',
+ description: 'label for video tab',
+ },
+ usersTabLabel: {
+ id: 'app.settings.usersTab.label',
+ description: 'label for participants tab',
+ },
+ SettingsLabel: {
+ id: 'app.settings.main.label',
+ description: 'General settings label',
+ },
+ CancelLabel: {
+ id: 'app.settings.main.cancel.label',
+ description: 'Discard the changes and close the settings menu',
+ },
+ CancelLabelDesc: {
+ id: 'app.settings.main.cancel.label.description',
+ description: 'Settings modal cancel button description',
+ },
+ SaveLabel: {
+ id: 'app.settings.main.save.label',
+ description: 'Save the changes and close the settings menu',
+ },
+ SaveLabelDesc: {
+ id: 'app.settings.main.save.label.description',
+ description: 'Settings modal save button label',
+ },
+ notificationLabel: {
+ id: 'app.submenu.notification.SectionTitle', // set menu label identical to section title
+ description: 'label for notification tab',
+ },
+ dataSavingLabel: {
+ id: 'app.settings.dataSavingTab.label',
+ description: 'label for data savings tab',
+ },
+ transcriptionLabel: {
+ id: 'app.settings.transcriptionTab.label',
+ description: 'label for transcriptions tab',
+ },
+ savedAlertLabel: {
+ id: 'app.settings.save-notification.label',
+ description: 'label shown in toast when settings are saved',
+ },
+ on: {
+ id: 'app.switch.onLabel',
+ description: 'label for toggle switch on state',
+ },
+ off: {
+ id: 'app.switch.offLabel',
+ description: 'label for toggle switch off state',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ dataSaving: PropTypes.shape({
+ viewParticipantsWebcams: PropTypes.bool,
+ viewScreenshare: PropTypes.bool,
+ }).isRequired,
+ transcription: PropTypes.shape({
+ partialUtterances: PropTypes.bool,
+ minUtteraceLength: PropTypes.number,
+ }).isRequired,
+ application: PropTypes.shape({
+ chatAudioAlerts: PropTypes.bool,
+ chatPushAlerts: PropTypes.bool,
+ userJoinAudioAlerts: PropTypes.bool,
+ userLeaveAudioAlerts: PropTypes.bool,
+ userLeavePushAlerts: PropTypes.bool,
+ guestWaitingAudioAlerts: PropTypes.bool,
+ guestWaitingPushAlerts: PropTypes.bool,
+ paginationEnabled: PropTypes.bool,
+ darkTheme: PropTypes.bool,
+ fallbackLocale: PropTypes.string,
+ fontSize: PropTypes.string,
+ locale: PropTypes.string,
+ microphoneConstraints: PropTypes.objectOf(Object),
+ }).isRequired,
+ updateSettings: PropTypes.func.isRequired,
+ availableLocales: PropTypes.objectOf(PropTypes.array).isRequired,
+ showToggleLabel: PropTypes.bool.isRequired,
+ isReactionsEnabled: PropTypes.bool.isRequired,
+ isGladiaEnabled: PropTypes.bool.isRequired,
+};
+
+class Settings extends Component {
+ static setHtmlFontSize(size) {
+ document.getElementsByTagName('html')[0].style.fontSize = size;
+ }
+
+ constructor(props) {
+ super(props);
+
+ const {
+ dataSaving, application, transcription, selectedTab,
+ } = props;
+
+ this.state = {
+ current: {
+ dataSaving: clone(dataSaving),
+ application: clone(application),
+ transcription: clone(transcription),
+ },
+ saved: {
+ dataSaving: clone(dataSaving),
+ application: clone(application),
+ transcription: clone(transcription),
+ },
+ selectedTab: Number.isFinite(selectedTab) && selectedTab >= 0 && selectedTab <= 2
+ ? selectedTab
+ : 0,
+ };
+
+ this.updateSettings = props.updateSettings;
+ this.handleUpdateSettings = this.handleUpdateSettings.bind(this);
+ this.handleSelectTab = this.handleSelectTab.bind(this);
+ this.displaySettingsStatus = this.displaySettingsStatus.bind(this);
+ }
+
+ componentDidMount() {
+ const { availableLocales } = this.props;
+
+ availableLocales.then((locales) => {
+ this.setState({ allLocales: locales });
+ });
+ }
+
+ handleUpdateSettings(key, newSettings) {
+ const settings = this.state;
+ settings.current[key] = newSettings;
+ this.setState(settings);
+ }
+
+ handleSelectTab(tab) {
+ this.setState({
+ selectedTab: tab,
+ });
+ }
+
+ displaySettingsStatus(status, textOnly = false) {
+ const { intl } = this.props;
+ if (textOnly) {
+ return status ? intl.formatMessage(intlMessages.on)
+ : intl.formatMessage(intlMessages.off);
+ }
+ return (
+
+ {status ? intl.formatMessage(intlMessages.on)
+ : intl.formatMessage(intlMessages.off)}
+
+ );
+ }
+
+ renderModalContent() {
+ const {
+ intl,
+ isModerator,
+ isPresenter,
+ showGuestNotification,
+ showToggleLabel,
+ layoutContextDispatch,
+ selectedLayout,
+ isScreenSharingEnabled,
+ isVideoEnabled,
+ isReactionsEnabled,
+ isGladiaEnabled,
+ } = this.props;
+
+ const {
+ selectedTab,
+ current,
+ allLocales,
+ } = this.state;
+
+ const isDataSavingTabEnabled = isScreenSharingEnabled || isVideoEnabled;
+
+ return (
+
+
+
+
+ {intl.formatMessage(intlMessages.appTabLabel)}
+
+
+
+ {intl.formatMessage(intlMessages.notificationLabel)}
+
+ {isDataSavingTabEnabled
+ ? (
+
+
+ {intl.formatMessage(intlMessages.dataSavingLabel)}
+
+ )
+ : null}
+ {isGladiaEnabled
+ ? (
+
+
+ {intl.formatMessage(intlMessages.transcriptionLabel)}
+
+ )
+ : null}
+
+
+
+
+
+
+
+ {isDataSavingTabEnabled
+ ? (
+
+
+
+ )
+ : null}
+ {isGladiaEnabled
+ ? (
+
+
+
+ )
+ : null}
+
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ setIsOpen,
+ isOpen,
+ priority,
+ } = this.props;
+ const {
+ current,
+ saved,
+ } = this.state;
+ return (
+ {
+ this.updateSettings(current, intlMessages.savedAlertLabel);
+
+ if (saved.application.locale !== current.application.locale) {
+ const { language } = formatLocaleCode(saved.application.locale);
+ document.body.classList.remove(`lang-${language}`);
+ }
+
+ /* We need to use setIsOpen(false) here to prevent submenu state updates,
+ * from re-opening the modal.
+ */
+ setIsOpen(false);
+ },
+ label: intl.formatMessage(intlMessages.SaveLabel),
+ description: intl.formatMessage(intlMessages.SaveLabelDesc),
+ }}
+ dismiss={{
+ callback: () => {
+ Settings.setHtmlFontSize(saved.application.fontSize);
+ document.getElementsByTagName('html')[0].lang = saved.application.locale;
+ setIsOpen(false);
+ },
+ label: intl.formatMessage(intlMessages.CancelLabel),
+ description: intl.formatMessage(intlMessages.CancelLabelDesc),
+ }}
+ {...{
+ isOpen,
+ priority,
+ }}
+ >
+ {this.renderModalContent()}
+
+ );
+ }
+}
+
+Settings.propTypes = propTypes;
+export default injectIntl(Settings);
diff --git a/src/2.7.12/imports/ui/components/settings/container.jsx b/src/2.7.12/imports/ui/components/settings/container.jsx
new file mode 100644
index 00000000..99ec9cca
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/container.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import SettingsService from '/imports/ui/services/settings';
+import Settings from './component';
+import { layoutDispatch } from '../layout/context';
+import { isScreenSharingEnabled } from '/imports/ui/services/features';
+import UserReactionService from '/imports/ui/components/user-reaction/service';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+
+import {
+ getUserRoles,
+ isPresenter,
+ showGuestNotification,
+ updateSettings,
+ getAvailableLocales,
+} from './service';
+
+const SettingsContainer = (props) => {
+ const layoutContextDispatch = layoutDispatch();
+
+ return ;
+};
+
+export default withTracker((props) => ({
+ ...props,
+ audio: SettingsService.audio,
+ dataSaving: SettingsService.dataSaving,
+ application: SettingsService.application,
+ transcription: SettingsService.transcription,
+ updateSettings,
+ availableLocales: getAvailableLocales(),
+ isPresenter: isPresenter(),
+ isModerator: getUserRoles() === 'MODERATOR',
+ showGuestNotification: showGuestNotification(),
+ showToggleLabel: false,
+ isScreenSharingEnabled: isScreenSharingEnabled(),
+ isVideoEnabled: Meteor.settings.public.kurento.enableVideo,
+ isReactionsEnabled: UserReactionService.isEnabled(),
+ isGladiaEnabled: SpeechService.isActive() && SpeechService.isGladia(),
+}))(SettingsContainer);
diff --git a/src/2.7.12/imports/ui/components/settings/service.js b/src/2.7.12/imports/ui/components/settings/service.js
new file mode 100644
index 00000000..2317e467
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/service.js
@@ -0,0 +1,67 @@
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import Settings from '/imports/ui/services/settings';
+import {notify} from '/imports/ui/services/notification';
+import GuestService from '/imports/ui/components/waiting-users/service';
+import SpeechService from '/imports/ui/components/audio/captions/speech/service';
+import Intl from '/imports/ui/services/locale';
+
+const getUserRoles = () => {
+ const user = Users.findOne({
+ userId: Auth.userID,
+ });
+
+ return user?.role;
+};
+
+const isPresenter = () => {
+ const user = Users.findOne({
+ userId: Auth.userID,
+ });
+
+ return user?.presenter;
+};
+
+const showGuestNotification = () => {
+ const guestPolicy = GuestService.getGuestPolicy();
+
+ // Guest notification only makes sense when guest
+ // entrance is being controlled by moderators
+ return guestPolicy === 'ASK_MODERATOR';
+};
+
+const isKeepPushingLayoutEnabled = () => Meteor.settings.public.layout.showPushLayoutToggle;
+
+const updateSettings = (obj, msgDescriptor) => {
+ Object.keys(obj).forEach(k => (Settings[k] = obj[k]));
+ Settings.save();
+
+ if (obj.transcription) {
+ const { partialUtterances, minUtteranceLength } = obj.transcription;
+ SpeechService.setSpeechOptions(partialUtterances, parseInt(minUtteranceLength, 10));
+ }
+
+ if (msgDescriptor) {
+ // prevents React state update on unmounted component
+ setTimeout(() => {
+ Intl.formatMessage(msgDescriptor).then((txt) => {
+ notify(
+ txt,
+ 'info',
+ 'settings',
+ );
+ });
+ }, 0);
+ }
+};
+
+const getAvailableLocales = () => fetch('./locale-list').then(locales => locales.json());
+
+export {
+ getUserRoles,
+ isPresenter,
+ showGuestNotification,
+ updateSettings,
+ isKeepPushingLayoutEnabled,
+ getAvailableLocales,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/styles.js b/src/2.7.12/imports/ui/components/settings/styles.js
new file mode 100644
index 00000000..9e6dbc5a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/styles.js
@@ -0,0 +1,139 @@
+import styled from 'styled-components';
+import {
+ smPaddingX,
+ smPaddingY,
+ mdPaddingY,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import {
+ colorGrayDark,
+ colorPrimary,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ Tab, Tabs, TabList, TabPanel,
+} from 'react-tabs';
+import Icon from '/imports/ui/components/common/icon/component';
+
+const ToggleLabel = styled.span`
+ margin-right: ${smPaddingX};
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+`;
+
+const SettingsTabs = styled(Tabs)`
+ display: flex;
+ flex-flow: row;
+ justify-content: flex-start;
+
+ @media ${smallOnly} {
+ width: 100%;
+ flex-flow: column;
+ }
+`;
+
+const SettingsTabList = styled(TabList)`
+ display: flex;
+ flex-flow: column;
+ margin: 0;
+ border: none;
+ padding: 0;
+ width: calc(100% / 3);
+
+ @media ${smallOnly} {
+ width: 100%;
+ flex-flow: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+`;
+
+const SettingsTabSelector = styled(Tab)`
+ display: flex;
+ flex-flow: row;
+ font-size: 0.9rem;
+ flex: 0 0 auto;
+ justify-content: flex-start;
+ border: none !important;
+ padding: ${mdPaddingY} ${mdPaddingX};
+ color: ${colorGrayDark};
+ border-radius: .2rem;
+ cursor: pointer;
+ margin-bottom: ${smPaddingY};
+ align-items: center;
+ flex-grow: 0;
+ min-width: 0;
+
+ & > span {
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ @media ${smallOnly} {
+ max-width: 100%;
+ margin: 0 ${smPaddingX} 0 0;
+ & > i {
+ display: none;
+ }
+
+ [dir="rtl"] & {
+ margin: 0 0 0 ${smPaddingX};
+ }
+ }
+
+ &.is-selected {
+ color: ${colorWhite};
+ background-color: ${colorPrimary};
+ font-weight: bold;
+
+ & > i {
+ color: ${colorWhite};
+ }
+ }
+`;
+
+const SettingsIcon = styled(Icon)`
+ margin: 0 .5rem 0 0;
+ font-size: ${fontSizeLarge};
+
+ [dir="rtl"] & {
+ margin: 0 0 0 .5rem;
+ }
+`;
+
+const SettingsTabPanel = styled(TabPanel)`
+ display: none;
+ margin: 0 0 0 1rem;
+ width: calc(100% / 3 * 2);
+
+ [dir="rtl"] & {
+ margin: 0 1rem 0 0;
+ }
+
+ &.is-selected {
+ display: block;
+ }
+
+ @media ${smallOnly} {
+ width: 100%;
+ margin: 0;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+`;
+
+export default {
+ ToggleLabel,
+ SettingsTabs,
+ SettingsTabList,
+ SettingsTabSelector,
+ SettingsIcon,
+ SettingsTabPanel,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx
new file mode 100644
index 00000000..8a3a4308
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/application/component.jsx
@@ -0,0 +1,633 @@
+import React from 'react';
+import Button from '/imports/ui/components/common/button/component';
+import Toggle from '/imports/ui/components/common/switch/component';
+import LocalesDropdown from '/imports/ui/components/common/locales-dropdown/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import BaseMenu from '../base/component';
+import Styled from './styles';
+import VideoService from '/imports/ui/components/video-provider/service';
+import WakeLockService from '/imports/ui/components/wake-lock/service';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+import Settings from '/imports/ui/services/settings';
+
+const MIN_FONTSIZE = 0;
+const SHOW_AUDIO_FILTERS = (Meteor.settings.public.app
+ .showAudioFilters === undefined)
+ ? true
+ : Meteor.settings.public.app.showAudioFilters;
+const { animations } = Settings.application;
+
+const intlMessages = defineMessages({
+ applicationSectionTitle: {
+ id: 'app.submenu.application.applicationSectionTitle',
+ description: 'Application section title',
+ },
+ animationsLabel: {
+ id: 'app.submenu.application.animationsLabel',
+ description: 'animations label',
+ },
+ audioFilterLabel: {
+ id: 'app.submenu.application.audioFilterLabel',
+ description: 'audio filters label',
+ },
+ darkThemeLabel: {
+ id: 'app.submenu.application.darkThemeLabel',
+ description: 'dark mode label',
+ },
+ fontSizeControlLabel: {
+ id: 'app.submenu.application.fontSizeControlLabel',
+ description: 'label for font size ontrol',
+ },
+ increaseFontBtnLabel: {
+ id: 'app.submenu.application.increaseFontBtnLabel',
+ description: 'label for button to increase font size',
+ },
+ increaseFontBtnDesc: {
+ id: 'app.submenu.application.increaseFontBtnDesc',
+ description: 'adds descriptive context to increase font size button',
+ },
+ decreaseFontBtnLabel: {
+ id: 'app.submenu.application.decreaseFontBtnLabel',
+ description: 'label for button to reduce font size',
+ },
+ decreaseFontBtnDesc: {
+ id: 'app.submenu.application.decreaseFontBtnDesc',
+ description: 'adds descriptive context to decrease font size button',
+ },
+ languageLabel: {
+ id: 'app.submenu.application.languageLabel',
+ description: 'displayed label for changing application locale',
+ },
+ currentValue: {
+ id: 'app.submenu.application.currentSize',
+ description: 'current value label',
+ },
+ languageOptionLabel: {
+ id: 'app.submenu.application.languageOptionLabel',
+ description: 'default change language option when locales are available',
+ },
+ noLocaleOptionLabel: {
+ id: 'app.submenu.application.noLocaleOptionLabel',
+ description: 'default change language option when no locales available',
+ },
+ paginationEnabledLabel: {
+ id: 'app.submenu.application.paginationEnabledLabel',
+ description: 'enable/disable video pagination',
+ },
+ wbToolbarsAutoHideLabel: {
+ id: 'app.submenu.application.wbToolbarsAutoHideLabel',
+ description: 'enable/disable auto hiding of whitebord toolbars',
+ },
+ wakeLockEnabledLabel: {
+ id: 'app.submenu.application.wakeLockEnabledLabel',
+ description: 'enable/disable wake lock',
+ },
+ layoutOptionLabel: {
+ id: 'app.submenu.application.layoutOptionLabel',
+ description: 'layout options',
+ },
+ pushLayoutLabel: {
+ id: 'app.submenu.application.pushLayoutLabel',
+ description: 'push layout togle',
+ },
+ customLayout: {
+ id: 'app.layout.style.custom',
+ description: 'label for custom layout style',
+ },
+ smartLayout: {
+ id: 'app.layout.style.smart',
+ description: 'label for smart layout style',
+ },
+ presentationFocusLayout: {
+ id: 'app.layout.style.presentationFocus',
+ description: 'label for presentationFocus layout style',
+ },
+ videoFocusLayout: {
+ id: 'app.layout.style.videoFocus',
+ description: 'label for videoFocus layout style',
+ },
+ presentationFocusPushLayout: {
+ id: 'app.layout.style.presentationFocusPush',
+ description: 'label for presentationFocus layout style (push to all)',
+ },
+ videoFocusPushLayout: {
+ id: 'app.layout.style.videoFocusPush',
+ description: 'label for videoFocus layout style (push to all)',
+ },
+ smartPushLayout: {
+ id: 'app.layout.style.smartPush',
+ description: 'label for smart layout style (push to all)',
+ },
+ customPushLayout: {
+ id: 'app.layout.style.customPush',
+ description: 'label for custom layout style (push to all)',
+ },
+ disableLabel: {
+ id: 'app.videoDock.webcamDisableLabelAllCams',
+ },
+ autoCloseReactionsBarLabel: {
+ id: 'app.actionsBar.reactions.autoCloseReactionsBarLabel',
+ },
+});
+
+class ApplicationMenu extends BaseMenu {
+ static setHtmlFontSize(size) {
+ document.getElementsByTagName('html')[0].style.fontSize = size;
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ settingsName: 'application',
+ settings: props.settings,
+ isLargestFontSize: false,
+ isSmallestFontSize: false,
+ showSelect: false,
+ fontSizes: [
+ '12px',
+ '14px',
+ '16px',
+ '18px',
+ '20px',
+ ],
+ audioFilterEnabled: ApplicationMenu.isAudioFilterEnabled(props
+ .settings.microphoneConstraints),
+ };
+ }
+
+ componentDidMount() {
+ this.setInitialFontSize();
+ }
+
+ componentWillUnmount() {
+ // fix Warning: Can't perform a React state update on an unmounted component
+ this.setState = () => {};
+ }
+
+ setInitialFontSize() {
+ const { fontSizes } = this.state;
+ const clientFont = document.getElementsByTagName('html')[0].style.fontSize;
+ const hasFont = fontSizes.includes(clientFont);
+ if (!hasFont) {
+ fontSizes.push(clientFont);
+ fontSizes.sort();
+ }
+ const fontIndex = fontSizes.indexOf(clientFont);
+ this.changeFontSize(clientFont);
+ this.setState({
+ isSmallestFontSize: fontIndex <= MIN_FONTSIZE,
+ isLargestFontSize: fontIndex >= (fontSizes.length - 1),
+ fontSizes,
+ });
+ }
+
+ static isAudioFilterEnabled(_constraints) {
+ if (typeof _constraints === 'undefined') return true;
+
+ const _isConstraintEnabled = (constraintValue) => {
+ switch (typeof constraintValue) {
+ case 'boolean':
+ return constraintValue;
+ case 'string':
+ return constraintValue === 'true';
+ case 'object':
+ return !!(constraintValue.exact || constraintValue.ideal);
+ default:
+ return false;
+ }
+ };
+
+ let isAnyFilterEnabled = true;
+
+ const constraints = _constraints && (typeof _constraints.advanced === 'object')
+ ? _constraints.advanced
+ : _constraints || {};
+
+ isAnyFilterEnabled = Object.values(constraints).find(
+ (constraintValue) => _isConstraintEnabled(constraintValue),
+ );
+
+ return isAnyFilterEnabled;
+ }
+
+ handleAudioFilterChange() {
+ const _audioFilterEnabled = !ApplicationMenu.isAudioFilterEnabled(this
+ .state.settings.microphoneConstraints);
+ const _newConstraints = {
+ autoGainControl: _audioFilterEnabled,
+ echoCancellation: _audioFilterEnabled,
+ noiseSuppression: _audioFilterEnabled,
+ };
+
+ const obj = this.state;
+ obj.settings.microphoneConstraints = _newConstraints;
+ this.handleUpdateSettings(this.state.settings, obj.settings);
+ }
+
+ handleUpdateFontSize(size) {
+ const obj = this.state;
+ obj.settings.fontSize = size;
+ this.handleUpdateSettings(this.state.settingsName, obj.settings);
+ }
+
+ changeFontSize(size) {
+ const { layoutContextDispatch } = this.props;
+ const obj = this.state;
+ obj.settings.fontSize = size;
+ this.setState(obj, () => {
+ ApplicationMenu.setHtmlFontSize(this.state.settings.fontSize);
+ this.handleUpdateFontSize(this.state.settings.fontSize);
+ });
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_FONT_SIZE,
+ value: parseInt(size.slice(0, -2), 10),
+ });
+ }
+
+ handleIncreaseFontSize() {
+ const currentFontSize = this.state.settings.fontSize;
+ const availableFontSizes = this.state.fontSizes;
+ const maxFontSize = availableFontSizes.length - 1;
+ const canIncreaseFontSize = availableFontSizes.indexOf(currentFontSize) < maxFontSize;
+ const fs = canIncreaseFontSize ? availableFontSizes.indexOf(currentFontSize) + 1 : maxFontSize;
+ this.changeFontSize(availableFontSizes[fs]);
+ if (fs === maxFontSize) this.setState({ isLargestFontSize: true });
+ this.setState({ isSmallestFontSize: false });
+ }
+
+ handleDecreaseFontSize() {
+ const currentFontSize = this.state.settings.fontSize;
+ const availableFontSizes = this.state.fontSizes;
+ const canDecreaseFontSize = availableFontSizes.indexOf(currentFontSize) > MIN_FONTSIZE;
+ const fs = canDecreaseFontSize ? availableFontSizes.indexOf(currentFontSize) - 1 : MIN_FONTSIZE;
+ this.changeFontSize(availableFontSizes[fs]);
+ if (fs === MIN_FONTSIZE) this.setState({ isSmallestFontSize: true });
+ this.setState({ isLargestFontSize: false });
+ }
+
+ handleSelectChange(fieldname, e) {
+ const obj = this.state;
+ obj.settings[fieldname] = e.target.value;
+ this.handleUpdateSettings('application', obj.settings);
+ }
+
+ renderAudioFilters() {
+ let audioFilterOption = null;
+
+ if (SHOW_AUDIO_FILTERS) {
+ const { intl, showToggleLabel, displaySettingsStatus } = this.props;
+ const { settings } = this.state;
+ const audioFilterStatus = ApplicationMenu
+ .isAudioFilterEnabled(settings.microphoneConstraints);
+
+ audioFilterOption = (
+
+
+
+
+ {intl.formatMessage(intlMessages.audioFilterLabel)}
+
+
+
+
+
+ {displaySettingsStatus(audioFilterStatus)}
+ this.handleAudioFilterChange()}
+ ariaLabel={`${intl.formatMessage(intlMessages.audioFilterLabel)} - ${displaySettingsStatus(audioFilterStatus, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ );
+ }
+
+ return audioFilterOption;
+ }
+
+ renderPaginationToggle() {
+ // See VideoService's method for an explanation
+ if (!VideoService.shouldRenderPaginationToggle()) return false;
+
+ const { intl, showToggleLabel, displaySettingsStatus } = this.props;
+ const { settings } = this.state;
+
+ return (
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {intl.formatMessage(intlMessages.paginationEnabledLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.paginationEnabled)}
+ this.handleToggle('paginationEnabled')}
+ ariaLabel={`${intl.formatMessage(intlMessages.paginationEnabledLabel)} - ${displaySettingsStatus(settings.paginationEnabled, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ );
+ }
+
+ renderDarkThemeToggle() {
+ const { intl, showToggleLabel, displaySettingsStatus } = this.props;
+ const { settings } = this.state;
+
+ const isDarkThemeEnabled = Meteor.settings.public.app.darkTheme.enabled;
+ if (!isDarkThemeEnabled) return null;
+
+ return (
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {intl.formatMessage(intlMessages.darkThemeLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.darkTheme)}
+ this.handleToggle('darkTheme')}
+ showToggleLabel={showToggleLabel}
+ ariaLabel={`${intl.formatMessage(intlMessages.darkThemeLabel)} - ${displaySettingsStatus(settings.darkTheme, true)}`}
+ data-test="darkModeToggleBtn"
+ />
+
+
+
+ );
+ }
+
+ renderWakeLockToggle() {
+ if (!WakeLockService.isSupported()) return null;
+
+ const { intl, showToggleLabel, displaySettingsStatus } = this.props;
+ const { settings } = this.state;
+
+ return (
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {intl.formatMessage(intlMessages.wakeLockEnabledLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.wakeLock)}
+ this.handleToggle('wakeLock')}
+ ariaLabel={intl.formatMessage(intlMessages.wakeLockEnabledLabel)}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ );
+ }
+
+ render() {
+ const {
+ allLocales,
+ intl,
+ showToggleLabel,
+ displaySettingsStatus,
+ isReactionsEnabled,
+ } = this.props;
+ const {
+ isLargestFontSize, isSmallestFontSize, settings,
+ } = this.state;
+
+ // conversions can be found at http://pxtoem.com
+ const pixelPercentage = {
+ '12px': '75%',
+ // 14px is actually 87.5%, rounding up to show more friendly value
+ '14px': '90%',
+ '16px': '100%',
+ // 18px is actually 112.5%, rounding down to show more friendly value
+ '18px': '110%',
+ '20px': '125%',
+ };
+
+ const ariaValueLabel = intl.formatMessage(intlMessages.currentValue, { 0: `${pixelPercentage[settings.fontSize]}` });
+
+ const showSelect = allLocales && allLocales.length > 0;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.applicationSectionTitle)}
+
+
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {intl.formatMessage(intlMessages.animationsLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.animations)}
+ this.handleToggle('animations')}
+ ariaLabel={`${intl.formatMessage(intlMessages.animationsLabel)} - ${displaySettingsStatus(settings.animations, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {this.renderAudioFilters()}
+ {this.renderPaginationToggle()}
+ {this.renderDarkThemeToggle()}
+ {this.renderWakeLockToggle()}
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {intl.formatMessage(intlMessages.wbToolbarsAutoHideLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.whiteboardToolbarAutoHide)}
+ this.handleToggle('whiteboardToolbarAutoHide')}
+ ariaLabel={`${intl.formatMessage(intlMessages.wbToolbarsAutoHideLabel)} - ${displaySettingsStatus(settings.whiteboardToolbarAutoHide, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.disableLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.selfViewDisable)}
+ this.handleToggle('selfViewDisable')}
+ ariaLabel={`${intl.formatMessage(intlMessages.disableLabel)} - ${displaySettingsStatus(settings.selfViewDisable, false)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {isReactionsEnabled && (
+
+
+
+
+ {intl.formatMessage(intlMessages.autoCloseReactionsBarLabel)}
+
+
+
+
+
+ {displaySettingsStatus(settings.autoCloseReactionsBar)}
+ this.handleToggle('autoCloseReactionsBar')}
+ ariaLabel={`${intl.formatMessage(intlMessages.autoCloseReactionsBarLabel)} - ${displaySettingsStatus(settings.autoCloseReactionsBar, false)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ )}
+
+
+
+
+
+ {intl.formatMessage(intlMessages.languageLabel)}
+
+
+
+
+
+ {showSelect ? (
+
+ this.handleSelectChange('locale', e)}
+ value={settings.locale}
+ elementId="langSelector"
+ ariaLabel={intl.formatMessage(intlMessages.languageLabel)}
+ selectMessage={intl.formatMessage(intlMessages.languageOptionLabel)}
+ />
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {intl.formatMessage(intlMessages.fontSizeControlLabel)}
+
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+
+ {`${pixelPercentage[settings.fontSize]}`}
+
+
+
+
+
+
+
+ this.handleDecreaseFontSize()}
+ color="primary"
+ icon="substract"
+ circle
+ hideLabel
+ label={intl.formatMessage(intlMessages.decreaseFontBtnLabel)}
+ aria-label={`${intl.formatMessage(intlMessages.decreaseFontBtnLabel)}, ${ariaValueLabel}`}
+ disabled={isSmallestFontSize}
+ data-test="decreaseFontSize"
+ />
+
+
+ this.handleIncreaseFontSize()}
+ color="primary"
+ icon="add"
+ circle
+ hideLabel
+ label={intl.formatMessage(intlMessages.increaseFontBtnLabel)}
+ aria-label={`${intl.formatMessage(intlMessages.increaseFontBtnLabel)}, ${ariaValueLabel}`}
+ disabled={isLargestFontSize}
+ data-test="increaseFontSize"
+ />
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(ApplicationMenu);
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/application/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/application/styles.js
new file mode 100644
index 00000000..df286bfd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/application/styles.js
@@ -0,0 +1,102 @@
+import styled from 'styled-components';
+import {
+ colorGrayLabel,
+ colorPrimary,
+ colorWhite,
+ colorGrayLighter,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize, borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general';
+import SpinnerStyles from '/imports/ui/components/common/loading-screen/styles';
+import Styled from '/imports/ui/components/settings/submenus/styles';
+
+const Row = styled(Styled.Row)``;
+
+const Col = styled(Styled.Col)``;
+
+const FormElement = styled(Styled.FormElement)``;
+
+const Label = styled(Styled.Label)``;
+
+const FormElementRight = styled(Styled.FormElementRight)``;
+
+const Select = styled(Styled.Select)``;
+
+const Title = styled(Styled.Title)``;
+
+const Form = styled(Styled.Form)``;
+
+const SpinnerOverlay = styled(SpinnerStyles.Spinner)`
+ & > div {
+ background-color: black;
+ }
+`;
+
+const Bounce1 = styled(SpinnerStyles.Bounce1)``;
+
+const Bounce2 = styled(SpinnerStyles.Bounce2)``;
+
+const Separator = styled.hr`
+ margin: 2.5rem 0;
+ border: 1px solid ${colorGrayLighter};
+ opacity: 0.25;
+`;
+
+const FormElementCenter = styled(Styled.FormElementCenter)``;
+
+const BoldLabel = styled.label`
+ color: ${colorGrayLabel};
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+`;
+
+const PullContentRight = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ flex-flow: row;
+ align-items: center;
+`;
+
+const LocalesDropdownSelect = styled.div`
+ & > select {
+ &:focus {
+ box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary};
+ border-radius: ${borderSize};
+ }
+
+ background-color: ${colorWhite};
+ border: ${borderSize} solid ${colorWhite};
+ border-radius: ${borderSize};
+ border-bottom: 0.1rem solid ${colorGrayLighter};
+ color: ${colorGrayLabel};
+ width: 100%;
+ height: 1.75rem;
+ padding: 1px;
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+ }
+`;
+
+export default {
+ Row,
+ Col,
+ FormElement,
+ Label,
+ FormElementRight,
+ Select,
+ Title,
+ Form,
+ SpinnerOverlay,
+ Bounce1,
+ Bounce2,
+ Separator,
+ FormElementCenter,
+ BoldLabel,
+ PullContentRight,
+ LocalesDropdownSelect,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx
new file mode 100644
index 00000000..0e621b0e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/base/component.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+export default class BaseMenu extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.handleUpdateSettings = props.handleUpdateSettings;
+ }
+
+ handleToggle(key) {
+ const obj = this.state;
+ obj.settings[key] = !this.state.settings[key];
+
+ this.setState(obj, () => {
+ this.handleUpdateSettings(this.state.settingsName, this.state.settings);
+ });
+ }
+
+ handleInput(key, e) {
+ const obj = this.state;
+ obj.settings[key] = e.target.value;
+
+ this.setState(obj, () => {
+ this.handleUpdateSettings(this.state.settingsName, this.state.settings);
+ });
+ }
+}
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx
new file mode 100644
index 00000000..0a753595
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/component.jsx
@@ -0,0 +1,112 @@
+import React from 'react';
+import Toggle from '/imports/ui/components/common/switch/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import BaseMenu from '../base/component';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ dataSavingLabel: {
+ id: 'app.settings.dataSavingTab.label',
+ description: 'label for data savings tab',
+ },
+ webcamLabel: {
+ id: 'app.settings.dataSavingTab.webcam',
+ description: 'webcam toggle',
+ },
+ screenShareLabel: {
+ id: 'app.settings.dataSavingTab.screenShare',
+ description: 'screenshare toggle',
+ },
+ dataSavingDesc: {
+ id: 'app.settings.dataSavingTab.description',
+ description: 'description of data savings tab',
+ },
+});
+
+class DataSaving extends BaseMenu {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ settingsName: 'dataSaving',
+ settings: props.settings,
+ };
+ }
+
+ render() {
+ const {
+ intl,
+ showToggleLabel,
+ displaySettingsStatus,
+ isScreenSharingEnabled,
+ isVideoEnabled,
+ } = this.props;
+
+ const { viewParticipantsWebcams, viewScreenshare } = this.state.settings;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.dataSavingLabel)}
+ {intl.formatMessage(intlMessages.dataSavingDesc)}
+
+
+ {isVideoEnabled
+ ? (
+
+
+
+
+ {intl.formatMessage(intlMessages.webcamLabel)}
+
+
+
+
+
+ {displaySettingsStatus(viewParticipantsWebcams)}
+ this.handleToggle('viewParticipantsWebcams')}
+ ariaLabelledBy="webcamToggle"
+ ariaLabel={`${intl.formatMessage(intlMessages.webcamLabel)} - ${displaySettingsStatus(viewParticipantsWebcams, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ )
+ : null}
+ {isScreenSharingEnabled
+ ? (
+
+
+
+
+ {intl.formatMessage(intlMessages.screenShareLabel)}
+
+
+
+
+
+ {displaySettingsStatus(viewScreenshare)}
+ this.handleToggle('viewScreenshare')}
+ ariaLabelledBy="screenShare"
+ ariaLabel={`${intl.formatMessage(intlMessages.screenShareLabel)} - ${displaySettingsStatus(viewScreenshare, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ )
+ : null}
+
+
+ );
+ }
+}
+
+export default injectIntl(DataSaving);
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js
new file mode 100644
index 00000000..4cf67d41
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/data-saving/styles.js
@@ -0,0 +1,29 @@
+import styled from 'styled-components';
+import Styled from '/imports/ui/components/settings/submenus/styles';
+
+const Title = styled(Styled.Title)``;
+
+const SubTitle = styled(Styled.SubTitle)``;
+
+const Form = styled(Styled.Form)``;
+
+const Row = styled(Styled.Row)``;
+
+const Col = styled(Styled.Col)``;
+
+const FormElement = styled(Styled.FormElement)``;
+
+const FormElementRight = styled(Styled.FormElementRight)``;
+
+const Label = styled(Styled.Label)``;
+
+export default {
+ Title,
+ SubTitle,
+ Form,
+ Row,
+ Col,
+ FormElement,
+ FormElementRight,
+ Label,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx
new file mode 100644
index 00000000..1e5fdff9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/notification/component.jsx
@@ -0,0 +1,264 @@
+import React from 'react';
+import Toggle from '/imports/ui/components/common/switch/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import BaseMenu from '../base/component';
+import Styled from './styles';
+import { isChatEnabled } from '/imports/ui/services/features';
+
+const intlMessages = defineMessages({
+ notificationSectionTitle: {
+ id: 'app.submenu.notification.SectionTitle',
+ description: 'Notification section title',
+ },
+ notificationSectionDesc: {
+ id: 'app.submenu.notification.Desc',
+ description: 'provides extra info for notification section',
+ },
+ audioAlertLabel: {
+ id: 'app.submenu.notification.audioAlertLabel',
+ description: 'audio notification label',
+ },
+ pushAlertLabel: {
+ id: 'app.submenu.notification.pushAlertLabel',
+ description: 'push notifiation label',
+ },
+ messagesLabel: {
+ id: 'app.submenu.notification.messagesLabel',
+ description: 'label for chat messages',
+ },
+ userJoinLabel: {
+ id: 'app.submenu.notification.userJoinLabel',
+ description: 'label for chat messages',
+ },
+ userLeaveLabel: {
+ id: 'app.submenu.notification.userLeaveLabel',
+ description: 'label for user leave notifications',
+ },
+ guestWaitingLabel: {
+ id: 'app.submenu.notification.guestWaitingLabel',
+ description: 'label for guests waiting for approval',
+ },
+ raiseHandLabel: {
+ id: 'app.submenu.notification.raiseHandLabel',
+ description: 'label for raise hand emoji notifications',
+ },
+});
+
+class NotificationMenu extends BaseMenu {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ settingsName: 'notification',
+ settings: props.settings,
+ };
+ }
+
+ render() {
+ const {
+ intl,
+ isModerator,
+ showGuestNotification,
+ showToggleLabel,
+ displaySettingsStatus,
+ } = this.props;
+
+ const { settings } = this.state;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.notificationSectionTitle)}
+
+
+ {intl.formatMessage(intlMessages.notificationSectionDesc)}
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.audioAlertLabel)}
+
+
+ {intl.formatMessage(intlMessages.pushAlertLabel)}
+
+
+
+ {isChatEnabled() ? (
+
+
+
+ {intl.formatMessage(intlMessages.messagesLabel)}
+
+
+
+
+ {displaySettingsStatus(settings.chatAudioAlerts)}
+ this.handleToggle('chatAudioAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.messagesLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.chatAudioAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {displaySettingsStatus(settings.chatPushAlerts)}
+ this.handleToggle('chatPushAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.messagesLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.chatPushAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ data-test="chatPopupAlertsBtn"
+ />
+
+
+
+ ) : null}
+
+
+
+
+ {intl.formatMessage(intlMessages.userJoinLabel)}
+
+
+
+
+ {displaySettingsStatus(settings.userJoinAudioAlerts)}
+ this.handleToggle('userJoinAudioAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.userJoinLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.userJoinAudioAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {displaySettingsStatus(settings.userJoinPushAlerts)}
+ this.handleToggle('userJoinPushAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.userJoinLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.userJoinPushAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ data-test="userJoinPopupAlerts"
+ />
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.userLeaveLabel)}
+
+
+
+
+ {displaySettingsStatus(settings.userLeaveAudioAlerts)}
+ this.handleToggle('userLeaveAudioAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.userLeaveLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.userLeaveAudioAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {displaySettingsStatus(settings.userLeavePushAlerts)}
+ this.handleToggle('userLeavePushAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.userLeaveLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.userLeavePushAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {isModerator && showGuestNotification ? (
+
+
+
+ {intl.formatMessage(intlMessages.guestWaitingLabel)}
+
+
+
+
+ {displaySettingsStatus(settings.guestWaitingAudioAlerts)}
+ this.handleToggle('guestWaitingAudioAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.guestWaitingLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.guestWaitingAudioAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {displaySettingsStatus(settings.guestWaitingPushAlerts)}
+ this.handleToggle('guestWaitingPushAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.guestWaitingLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.guestWaitingPushAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ ) : null}
+
+ {isModerator ? (
+
+
+
+ {intl.formatMessage(intlMessages.raiseHandLabel)}
+
+
+
+
+ {displaySettingsStatus(settings.raiseHandAudioAlerts)}
+ this.handleToggle('raiseHandAudioAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.raiseHandLabel)} ${intl.formatMessage(intlMessages.audioAlertLabel)} - ${displaySettingsStatus(settings.raiseHandAudioAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+ {displaySettingsStatus(settings.raiseHandPushAlerts)}
+ this.handleToggle('raiseHandPushAlerts')}
+ ariaLabel={`${intl.formatMessage(intlMessages.raiseHandLabel)} ${intl.formatMessage(intlMessages.pushAlertLabel)} - ${displaySettingsStatus(settings.raiseHandPushAlerts, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+ ) : null}
+
+
+
+ );
+ }
+}
+
+export default injectIntl(NotificationMenu);
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js
new file mode 100644
index 00000000..36978069
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/notification/styles.js
@@ -0,0 +1,41 @@
+import styled from 'styled-components';
+import Styled from '/imports/ui/components/settings/submenus/styles';
+
+const Title = styled(Styled.Title)``;
+
+const SubTitle = styled(Styled.SubTitle)``;
+
+const Form = styled(Styled.Form)``;
+
+const Row = styled(Styled.Row)``;
+
+const Col = styled(Styled.Col)``;
+
+const FormElement = styled(Styled.FormElement)``;
+
+const FormElementRight = styled(Styled.FormElementRight)``;
+
+const FormElementCenter = styled(Styled.FormElementCenter)``;
+
+const Label = styled(Styled.Label)``;
+
+const ColHeading = styled(Styled.Col)`
+ display: block;
+ text-align: center;
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+`;
+
+export default {
+ Title,
+ SubTitle,
+ Form,
+ Row,
+ Col,
+ FormElement,
+ FormElementRight,
+ FormElementCenter,
+ Label,
+ ColHeading,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/styles.js
new file mode 100644
index 00000000..82dcabf1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/styles.js
@@ -0,0 +1,118 @@
+import styled from 'styled-components';
+import {
+ colorGrayDark,
+ colorGrayLabel,
+ colorPrimary,
+ colorWhite,
+ colorGrayLighter,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize, borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general';
+
+const Title = styled.h3`
+ color: ${colorGrayDark};
+ font-weight: 400;
+ font-size: 1.3rem;
+ margin: 0;
+ margin-bottom: 1.5rem;
+`;
+
+const SubTitle = styled.h4`
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+`;
+
+const Form = styled.div`
+ display: flex;
+ flex-flow: column;
+`;
+
+const Row = styled.div`
+ display: flex;
+ flex-flow: row;
+ flex-grow: 1;
+ justify-content: space-between;
+ margin-bottom: 0.7rem;
+`;
+
+const Col = styled.div`
+ display: flex;
+ flex-grow: 1;
+ flex-basis: 0;
+
+ &:last-child {
+ padding-right: 0;
+ padding-left: 1rem;
+
+ [dir="rtl"] & {
+ padding-right: 0.1rem;
+ padding-left: 0;
+ }
+ }
+`;
+
+const FormElement = styled.div`
+ position: relative;
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+`;
+
+const FormElementRight = styled.div`
+ position: relative;
+ flex-grow: 1;
+ display: flex;
+ justify-content: flex-end;
+ flex-flow: row;
+ align-items: center;
+`;
+
+const FormElementCenter = styled.div`
+ position: relative;
+ display: flex;
+ flex-grow: 1;
+ justify-content: center;
+ flex-flow: row;
+ align-items: center;
+`;
+
+const Label = styled.span`
+ color: ${colorGrayLabel};
+ font-size: 0.9rem;
+ margin-bottom: 0.5rem;
+`;
+
+const Select = styled.select`
+ &:focus {
+ box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary};
+ border-radius: ${borderSize};
+ }
+
+ background-color: ${colorWhite};
+ border: ${borderSize} solid ${colorWhite};
+ border-radius: ${borderSize};
+ border-bottom: 0.1rem solid ${colorGrayLighter};
+ color: ${colorGrayLabel};
+ width: 100%;
+ height: 1.75rem;
+ padding: 1px;
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+`;
+
+export default {
+ Title,
+ SubTitle,
+ Form,
+ Row,
+ Col,
+ FormElement,
+ FormElementRight,
+ FormElementCenter,
+ Label,
+ Select,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx
new file mode 100644
index 00000000..c17b02e6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/transcription/component.jsx
@@ -0,0 +1,97 @@
+import React from 'react';
+import Toggle from '/imports/ui/components/common/switch/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import BaseMenu from '../base/component';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ transcriptionLabel: {
+ id: 'app.submenu.transcription.sectionTitle',
+ },
+ transcriptionDesc: {
+ id: 'app.submenu.transcription.desc',
+ },
+ partialUtterancesLabel: {
+ id: 'app.settings.transcriptionTab.partialUtterances',
+ },
+ minUtteranceLengthLabel: {
+ id: 'app.settings.transcriptionTab.minUtteranceLength',
+ },
+});
+
+class Transcription extends BaseMenu {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ settingsName: 'transcription',
+ settings: props.settings,
+ };
+ }
+
+ render() {
+ const {
+ intl,
+ showToggleLabel,
+ displaySettingsStatus,
+ } = this.props;
+
+ const { partialUtterances, minUtteranceLength } = this.state.settings;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.transcriptionLabel)}
+ {intl.formatMessage(intlMessages.transcriptionDesc)}
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.partialUtterancesLabel)}
+
+
+
+
+
+ this.handleToggle('partialUtterances')}
+ ariaLabelledBy="partialUtterances"
+ ariaLabel={`${intl.formatMessage(intlMessages.partialUtterancesLabel)} - ${displaySettingsStatus(partialUtterances, true)}`}
+ showToggleLabel={showToggleLabel}
+ />
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.minUtteranceLengthLabel)}
+
+
+
+
+
+ this.handleInput('minUtteranceLength', e) }
+ type="number"
+ max="5"
+ min="0"
+ >
+
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(Transcription);
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js
new file mode 100644
index 00000000..4cf67d41
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/transcription/styles.js
@@ -0,0 +1,29 @@
+import styled from 'styled-components';
+import Styled from '/imports/ui/components/settings/submenus/styles';
+
+const Title = styled(Styled.Title)``;
+
+const SubTitle = styled(Styled.SubTitle)``;
+
+const Form = styled(Styled.Form)``;
+
+const Row = styled(Styled.Row)``;
+
+const Col = styled(Styled.Col)``;
+
+const FormElement = styled(Styled.FormElement)``;
+
+const FormElementRight = styled(Styled.FormElementRight)``;
+
+const Label = styled(Styled.Label)``;
+
+export default {
+ Title,
+ SubTitle,
+ Form,
+ Row,
+ Col,
+ FormElement,
+ FormElementRight,
+ Label,
+};
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx b/src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx
new file mode 100644
index 00000000..c9c6e4f5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/video/component.jsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import Toggle from '/imports/ui/components/common/switch/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import BaseMenu from '../base/component';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ videoSectionTitle: {
+ id: 'app.submenu.video.title',
+ description: 'Heading for video submenu section',
+ },
+ videoSourceLabel: {
+ id: 'app.submenu.video.videoSourceLabel',
+ description: 'Label for video source section',
+ },
+ videoOptionLabel: {
+ id: 'app.submenu.video.videoOptionLabel',
+ description: 'default video source option label',
+ },
+ videoQualityLabel: {
+ id: 'app.submenu.video.videoQualityLabel',
+ description: 'Label for video quality section',
+ },
+ qualityOptionLabel: {
+ id: 'app.submenu.video.qualityOptionLabel',
+ description: 'default quality option label',
+ },
+ participantsCamLabel: {
+ id: 'app.submenu.video.participantsCamLabel',
+ description: 'Label for participants cam section',
+ },
+});
+
+class VideoMenu extends BaseMenu {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ settingsName: 'video',
+ settings: props.settings,
+ };
+ }
+
+ render() {
+ const { intl } = this.props;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.videoSectionTitle)}
+
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.videoSourceLabel)}
+
+
+
+ {intl.formatMessage(intlMessages.videoOptionLabel)}
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.videoQualityLabel)}
+
+
+
+ {intl.formatMessage(intlMessages.qualityOptionLabel)}
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.participantsCamLabel)}
+
+
+
+
+
+ this.handleToggle('viewParticipantsWebcams')}
+ ariaLabelledBy="viewCamLabel"
+ ariaLabel={intl.formatMessage(intlMessages.participantsCamLabel)}
+ />
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(VideoMenu);
diff --git a/src/2.7.12/imports/ui/components/settings/submenus/video/styles.js b/src/2.7.12/imports/ui/components/settings/submenus/video/styles.js
new file mode 100644
index 00000000..6a169a65
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/settings/submenus/video/styles.js
@@ -0,0 +1,37 @@
+import styled from 'styled-components';
+import Styled from '/imports/ui/components/settings/submenus/styles';
+import { colorLink } from '/imports/ui/stylesheets/styled-components/palette';
+
+const Title = styled(Styled.Title)``;
+
+const Form = styled(Styled.Form)``;
+
+const Row = styled(Styled.Row)``;
+
+const Col = styled(Styled.Col)``;
+
+const FormElement = styled(Styled.FormElement)``;
+
+const FormElementRight = styled(Styled.FormElementRight)``;
+
+const Label = styled(Styled.Label)``;
+
+const LabelSmall = styled(Label)`
+ color: ${colorLink};
+ font-size: 0.7rem;
+ margin-bottom: 0.3rem;
+`;
+
+const Select = styled(Styled.Select)``;
+
+export default {
+ Title,
+ Form,
+ Row,
+ Col,
+ FormElement,
+ FormElementRight,
+ Label,
+ LabelSmall,
+ Select,
+};
diff --git a/src/2.7.12/imports/ui/components/shortcut-help/component.jsx b/src/2.7.12/imports/ui/components/shortcut-help/component.jsx
new file mode 100644
index 00000000..9ae793be
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/shortcut-help/component.jsx
@@ -0,0 +1,459 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import Styled from './styles';
+import StyledSettings from '../settings/styles';
+import withShortcutHelper from './service';
+import { isChatEnabled } from '/imports/ui/services/features';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.shortcut-help.title',
+ description: 'modal title label',
+ },
+ closeLabel: {
+ id: 'app.shortcut-help.closeLabel',
+ description: 'label for close button',
+ },
+ closeDesc: {
+ id: 'app.shortcut-help.closeDesc',
+ description: 'description for close button',
+ },
+ accessKeyNotAvailable: {
+ id: 'app.shortcut-help.accessKeyNotAvailable',
+ description: 'message shown in place of access key table if not supported',
+ },
+ comboLabel: {
+ id: 'app.shortcut-help.comboLabel',
+ description: 'heading for key combo column',
+ },
+ alternativeLabel: {
+ id: 'app.shortcut-help.alternativeLabel',
+ description: 'heading for key alternatives column',
+ },
+ functionLabel: {
+ id: 'app.shortcut-help.functionLabel',
+ description: 'heading for shortcut function column',
+ },
+ openoptions: {
+ id: 'app.shortcut-help.openOptions',
+ description: 'describes the open options shortcut',
+ },
+ toggleuserlist: {
+ id: 'app.shortcut-help.toggleUserList',
+ description: 'describes the toggle userlist shortcut',
+ },
+ togglemute: {
+ id: 'app.shortcut-help.toggleMute',
+ description: 'describes the toggle mute shortcut',
+ },
+ togglepublicchat: {
+ id: 'app.shortcut-help.togglePublicChat',
+ description: 'describes the toggle public chat shortcut',
+ },
+ hideprivatechat: {
+ id: 'app.shortcut-help.hidePrivateChat',
+ description: 'describes the hide public chat shortcut',
+ },
+ closeprivatechat: {
+ id: 'app.shortcut-help.closePrivateChat',
+ description: 'describes the close private chat shortcut',
+ },
+ openactions: {
+ id: 'app.shortcut-help.openActions',
+ description: 'describes the open actions shortcut',
+ },
+ opendebugwindow: {
+ id: 'app.shortcut-help.openDebugWindow',
+ description: 'describes the open debug window shortcut',
+ },
+ openstatus: {
+ id: 'app.shortcut-help.openStatus',
+ description: 'describes the open status shortcut',
+ },
+ joinaudio: {
+ id: 'app.audio.joinAudio',
+ description: 'describes the join audio shortcut',
+ },
+ leaveaudio: {
+ id: 'app.audio.leaveAudio',
+ description: 'describes the leave audio shortcut',
+ },
+ raisehand: {
+ id: 'app.shortcut-help.raiseHand',
+ description: 'describes the toggle raise hand shortcut',
+ },
+ togglePan: {
+ id: 'app.shortcut-help.togglePan',
+ description: 'describes the toggle pan shortcut',
+ },
+ toggleFullscreen: {
+ id: 'app.shortcut-help.toggleFullscreen',
+ description: 'describes the toggle full-screen shortcut',
+ },
+ nextSlideDesc: {
+ id: 'app.shortcut-help.nextSlideDesc',
+ description: 'describes the next slide shortcut',
+ },
+ previousSlideDesc: {
+ id: 'app.shortcut-help.previousSlideDesc',
+ description: 'describes the previous slide shortcut',
+ },
+ togglePanKey: {
+ id: 'app.shortcut-help.togglePanKey',
+ description: 'describes the toggle pan shortcut key',
+ },
+ toggleFullscreenKey: {
+ id: 'app.shortcut-help.toggleFullscreenKey',
+ description: 'describes the toggle full-screen shortcut key',
+ },
+ nextSlideKey: {
+ id: 'app.shortcut-help.nextSlideKey',
+ description: 'describes the next slide shortcut key',
+ },
+ previousSlideKey: {
+ id: 'app.shortcut-help.previousSlideKey',
+ description: 'describes the previous slide shortcut key',
+ },
+ select: {
+ id: 'app.shortcut-help.select',
+ description: 'describes the selection tool shortcut key',
+ },
+ pencil: {
+ id: 'app.shortcut-help.pencil',
+ description: 'describes the pencil tool shortcut key',
+ },
+ eraser: {
+ id: 'app.shortcut-help.eraser',
+ description: 'describes the eraser tool shortcut key',
+ },
+ rectangle: {
+ id: 'app.shortcut-help.rectangle',
+ description: 'describes the rectangle shape tool shortcut key',
+ },
+ elipse: {
+ id: 'app.shortcut-help.elipse',
+ description: 'describes the elipse shape tool shortcut key',
+ },
+ triangle: {
+ id: 'app.shortcut-help.triangle',
+ description: 'describes the triangle shape tool shortcut key',
+ },
+ line: {
+ id: 'app.shortcut-help.line',
+ description: 'describes the line shape tool shortcut key',
+ },
+ arrow: {
+ id: 'app.shortcut-help.arrow',
+ description: 'describes the arrow shape tool shortcut key',
+ },
+ text: {
+ id: 'app.shortcut-help.text',
+ description: 'describes the text tool shortcut key',
+ },
+ note: {
+ id: 'app.shortcut-help.note',
+ description: 'describes the sticky note shortcut key',
+ },
+ general: {
+ id: 'app.shortcut-help.general',
+ description: 'general tab heading',
+ },
+ presentation: {
+ id: 'app.shortcut-help.presentation',
+ description: 'presentation tab heading',
+ },
+ whiteboard: {
+ id: 'app.shortcut-help.whiteboard',
+ description: 'whiteboard tab heading',
+ },
+ zoomIn: {
+ id: 'app.shortcut-help.zoomIn',
+ description: 'describes the zoom in shortcut key',
+ },
+ zoomOut: {
+ id: 'app.shortcut-help.zoomOut',
+ description: 'describes the zoom out shortcut key',
+ },
+ zoomFit: {
+ id: 'app.shortcut-help.zoomFit',
+ description: 'describes the zoom to fit shortcut key',
+ },
+ zoomSelect: {
+ id: 'app.shortcut-help.zoomSelect',
+ description: 'describes the zoom to selection shortcut key',
+ },
+ flipH: {
+ id: 'app.shortcut-help.flipH',
+ description: 'describes the flip horozontally shortcut key',
+ },
+ flipV: {
+ id: 'app.shortcut-help.flipV',
+ description: 'describes the flip vertically shortcut key',
+ },
+ lock: {
+ id: 'app.shortcut-help.lock',
+ description: 'describes the lock / unlock shape shortcut key',
+ },
+ moveToFront: {
+ id: 'app.shortcut-help.moveToFront',
+ description: 'describes the move to front shortcut key',
+ },
+ moveToBack: {
+ id: 'app.shortcut-help.moveToBack',
+ description: 'describes the move to back shortcut key',
+ },
+ moveForward: {
+ id: 'app.shortcut-help.moveForward',
+ description: 'describes the move forward shortcut key',
+ },
+ moveBackward: {
+ id: 'app.shortcut-help.moveBackward',
+ description: 'describes the move backward shortcut key',
+ },
+ undo: {
+ id: 'app.shortcut-help.undo',
+ description: 'describes the undo shortcut key',
+ },
+ redo: {
+ id: 'app.shortcut-help.redo',
+ description: 'describes the redo shortcut key',
+ },
+ cut: {
+ id: 'app.shortcut-help.cut',
+ description: 'describes the cut shortcut key',
+ },
+ copy: {
+ id: 'app.shortcut-help.copy',
+ description: 'describes the cut shortcut key',
+ },
+ paste: {
+ id: 'app.shortcut-help.paste',
+ description: 'describes the paste shortcut key',
+ },
+ selectAll: {
+ id: 'app.shortcut-help.selectAll',
+ description: 'describes the select all shortcut key',
+ },
+ delete: {
+ id: 'app.shortcut-help.delete',
+ description: 'describes the delete shortcut key',
+ },
+ duplicate: {
+ id: 'app.shortcut-help.duplicate',
+ description: 'describes the duplicate shortcut key',
+ }
+});
+
+
+const renderItem = (func, key) => {
+ return (
+
+ {func}
+ {key}
+
+ );
+}
+
+const renderItemWhiteBoard = (func, key, alt) => {
+ return (
+
+ {func}
+ {key}
+ {alt}
+
+ );
+}
+
+const ShortcutHelpComponent = (props) => {
+ const { intl, shortcuts,
+ isOpen,
+ onRequestClose,
+ priority,
+ } = props;
+ const { browserName } = browserInfo;
+ const { isIos, isMacos } = deviceInfo;
+ const [ selectedTab, setSelectedTab] = React.useState(0);
+
+ let accessMod = null;
+
+ // different browsers use different access modifier keys
+ // on different systems when using accessKey property.
+ // Overview how different browsers behave: https://www.w3schools.com/jsref/prop_html_accesskey.asp
+ switch (browserName) {
+ case 'Chrome':
+ case 'Microsoft Edge':
+ accessMod = 'Alt';
+ break;
+ case 'Firefox':
+ accessMod = 'Alt + Shift';
+ break;
+ default:
+ break;
+ }
+
+ // all Browsers on iOS are using Control + Alt as access modifier
+ if (isIos) {
+ accessMod = 'Control + Alt';
+ }
+ // all Browsers on MacOS are using Control + Option as access modifier
+ if (isMacos) {
+ accessMod = 'Control + Option';
+ }
+
+ const generalShortcutItems = shortcuts.map((shortcut) => {
+ if (!isChatEnabled() && shortcut.descId.indexOf('Chat') !== -1) return null;
+ return renderItem(
+ `${intl.formatMessage(intlMessages[`${shortcut.descId.toLowerCase()}`])}`,
+ `${accessMod} + ${shortcut.accesskey}`
+ );
+ });
+
+ const shortcutItems = [];
+ shortcutItems.push(renderItem(intl.formatMessage(intlMessages.togglePan),
+ intl.formatMessage(intlMessages.togglePanKey)));
+ shortcutItems.push(renderItem(intl.formatMessage(intlMessages.toggleFullscreen),
+ intl.formatMessage(intlMessages.toggleFullscreenKey)));
+ shortcutItems.push(renderItem(intl.formatMessage(intlMessages.nextSlideDesc),
+ intl.formatMessage(intlMessages.nextSlideKey)));
+ shortcutItems.push(renderItem(intl.formatMessage(intlMessages.previousSlideDesc),
+ intl.formatMessage(intlMessages.previousSlideKey)));
+
+ const whiteboardShortcutItems = [];
+ //tools
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.select), '1', 'V'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.pencil), '2', 'D, P'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.eraser), '3', 'E'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.rectangle), '4', 'R'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.elipse), '5', 'O'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.triangle), '6', 'G'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.line), '7', 'L'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.arrow), '8', 'A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.text), '9', 'T'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.note), '0', 'S'));
+ //views
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomIn), 'Ctrl +', 'Ctrl M. Wheel up'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomOut), 'Ctrl -', 'Ctrl M. Wheel down'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomFit), 'Shift 1', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.zoomSelect), 'Shift 2', 'N/A'));
+//transform
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.flipH), 'Shift H', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.flipV), 'Shift V', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.lock), 'Ctrl Shift L', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveToFront), 'Shift ]', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveForward), ']', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveBackward), '[', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.moveToBack), 'Shift [', 'N/A'));
+ //edit
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.undo), 'Ctrl Z', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.redo), 'Ctrl Shift Z', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.cut), 'Ctrl X', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.copy), 'Ctrl C', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.paste), 'Ctrl V', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.selectAll), 'Ctrl A', 'N/A'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.delete), 'Del', 'Backspace'));
+ whiteboardShortcutItems.push(renderItemWhiteBoard(intl.formatMessage(intlMessages.duplicate), 'Ctrl D', 'N/A'));
+
+ return (
+
+ setSelectedTab(tab)}
+ selectedIndex={selectedTab}
+ role="presentation"
+ >
+
+
+
+ {intl.formatMessage(intlMessages.general)}
+
+
+
+
+ {intl.formatMessage(intlMessages.presentation)}
+
+
+
+
+ {intl.formatMessage(intlMessages.whiteboard)}
+
+
+
+
+ {!accessMod ? {intl.formatMessage(intlMessages.accessKeyNotAvailable)}
+ : (
+
+
+
+
+ {intl.formatMessage(intlMessages.functionLabel)}
+ {intl.formatMessage(intlMessages.comboLabel)}
+
+ {generalShortcutItems}
+
+
+
+ )
+ }
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.functionLabel)}
+ {intl.formatMessage(intlMessages.comboLabel)}
+
+ {shortcutItems}
+
+
+
+
+
+
+
+
+
+ {intl.formatMessage(intlMessages.functionLabel)}
+ {intl.formatMessage(intlMessages.comboLabel)}
+ {intl.formatMessage(intlMessages.alternativeLabel)}
+
+ {whiteboardShortcutItems}
+
+
+
+
+
+
+
+ );
+};
+
+ShortcutHelpComponent.defaultProps = {
+ intl: {},
+};
+
+ShortcutHelpComponent.propTypes = {
+ intl: PropTypes.object.isRequired,
+ shortcuts: PropTypes.arrayOf(PropTypes.shape({
+ accesskey: PropTypes.string.isRequired,
+ descId: PropTypes.string.isRequired,
+ })).isRequired,
+};
+
+export default withShortcutHelper(injectIntl(ShortcutHelpComponent));
diff --git a/src/2.7.12/imports/ui/components/shortcut-help/service.jsx b/src/2.7.12/imports/ui/components/shortcut-help/service.jsx
new file mode 100644
index 00000000..a0e983d7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/shortcut-help/service.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+
+const BASE_SHORTCUTS = Meteor.settings.public.app.shortcuts;
+
+const withShortcutHelper = (WrappedComponent, param) => (props) => {
+ const ENABLED_SHORTCUTS = getFromUserSettings('bbb_shortcuts', null);
+ let shortcuts = Object.values(BASE_SHORTCUTS);
+
+ if (ENABLED_SHORTCUTS) {
+ shortcuts = Object.values(BASE_SHORTCUTS).map((el) => {
+ const obj = { ...el };
+ obj.descId = obj.descId.toLowerCase();
+ return obj;
+ }).filter(el => ENABLED_SHORTCUTS.includes(el.descId.toLowerCase()));
+ }
+
+ if (param !== undefined) {
+ if (!Array.isArray(param)) {
+ shortcuts = shortcuts
+ .filter(el => el.descId.toLowerCase() === param.toLowerCase())
+ .map(el => el.accesskey)
+ .pop();
+ } else {
+ shortcuts = shortcuts
+ .filter(el => param.map(p => p.toLowerCase()).includes(el.descId.toLowerCase()))
+ .reduce((acc, current) => {
+ acc[current.descId.toLowerCase()] = current.accesskey;
+ return acc;
+ }, {});
+ }
+ }
+
+ return (
+
+ );
+};
+
+export default withShortcutHelper;
diff --git a/src/2.7.12/imports/ui/components/shortcut-help/styles.js b/src/2.7.12/imports/ui/components/shortcut-help/styles.js
new file mode 100644
index 00000000..6e9170a1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/shortcut-help/styles.js
@@ -0,0 +1,66 @@
+import styled from 'styled-components';
+import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+import { colorOffWhite, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+import { Tabs } from 'react-tabs';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+import StyledSettings from '../settings/styles';
+
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const KeyCell = styled.td`
+ text-align: center;
+ padding: ${smPaddingX};
+ margin: auto;
+ width: 6rem;
+ min-width: 6rem;
+`;
+
+const DescCell = styled.td`
+ padding: ${smPaddingX};
+ margin: auto;
+`;
+
+const ShortcutTable = styled.table`
+ border-collapse: collapse;
+ margin: 0;
+ width: 100%;
+
+ > tbody > tr:nth-child(even) {
+ background-color: ${colorOffWhite};
+ color: ${colorPrimary};
+ }
+`;
+
+const SettingsTabs = styled(Tabs)`
+ display: flex;
+ flex-flow: row;
+ justify-content: flex-start;
+ margin-top: 1rem;
+
+ @media ${smallOnly} {
+ width: 100%;
+ flex-flow: column;
+ }
+`;
+
+const TableWrapper = styled(ScrollboxVertical)`
+ height: 50vh;
+ width: 100%;
+`;
+
+const TabPanel = styled(StyledSettings.SettingsTabPanel)`
+ margin-top: ${smPaddingX};
+
+ @media ${smallOnly} {
+ padding: 0;
+ }
+`;
+
+export default {
+ KeyCell,
+ DescCell,
+ ShortcutTable,
+ SettingsTabs,
+ TableWrapper,
+ TabPanel,
+};
diff --git a/src/2.7.12/imports/ui/components/sidebar-content/component.jsx b/src/2.7.12/imports/ui/components/sidebar-content/component.jsx
new file mode 100644
index 00000000..6628f83a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/sidebar-content/component.jsx
@@ -0,0 +1,162 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import Resizable from 're-resizable';
+import { ACTIONS, PANELS } from '../layout/enums';
+import ChatContainer from '/imports/ui/components/chat/container';
+import NotesContainer from '/imports/ui/components/notes/container';
+import PollContainer from '/imports/ui/components/poll/container';
+import CaptionsContainer from '/imports/ui/components/captions/container';
+import BreakoutRoomContainer from '/imports/ui/components/breakout-room/container';
+import TimerContainer from '/imports/ui/components/timer/container';
+import WaitingUsersPanel from '/imports/ui/components/waiting-users/container';
+import Styled from './styles';
+import ErrorBoundary from '/imports/ui/components/common/error-boundary/component';
+import FallbackView from '/imports/ui/components/common/fallback-errors/fallback-view/component';
+
+const propTypes = {
+ top: PropTypes.number.isRequired,
+ left: PropTypes.number,
+ right: PropTypes.number,
+ zIndex: PropTypes.number.isRequired,
+ minWidth: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ maxWidth: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ isResizable: PropTypes.bool.isRequired,
+ resizableEdge: PropTypes.objectOf(PropTypes.bool).isRequired,
+ contextDispatch: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ left: null,
+ right: null,
+};
+
+const SidebarContent = (props) => {
+ const {
+ top,
+ left,
+ right,
+ zIndex,
+ minWidth,
+ width,
+ maxWidth,
+ minHeight,
+ height,
+ maxHeight,
+ isResizable,
+ resizableEdge,
+ contextDispatch,
+ sidebarContentPanel,
+ amIPresenter,
+ isSharedNotesPinned,
+ } = props;
+
+ const [resizableWidth, setResizableWidth] = useState(width);
+ const [resizableHeight, setResizableHeight] = useState(height);
+ const [isResizing, setIsResizing] = useState(false);
+ const [resizeStartWidth, setResizeStartWidth] = useState(0);
+ const [resizeStartHeight, setResizeStartHeight] = useState(0);
+
+ useEffect(() => {
+ if (!isResizing) {
+ setResizableWidth(width);
+ setResizableHeight(height);
+ }
+ }, [width, height]);
+
+ const setSidebarContentSize = (dWidth, dHeight) => {
+ const newWidth = resizeStartWidth + dWidth;
+ const newHeight = resizeStartHeight + dHeight;
+
+ setResizableWidth(newWidth);
+ setResizableHeight(newHeight);
+
+ contextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_SIZE,
+ value: {
+ width: newWidth,
+ height: newHeight,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ },
+ });
+ };
+
+ const smallSidebar = width < (maxWidth / 2);
+ const pollDisplay = sidebarContentPanel === PANELS.POLL ? 'inherit' : 'none';
+
+ return (
+ {
+ setIsResizing(true);
+ setResizeStartWidth(resizableWidth);
+ setResizeStartHeight(resizableHeight);
+ }}
+ onResize={(...[, , , delta]) => setSidebarContentSize(delta.width, delta.height)}
+ onResizeStop={() => {
+ setIsResizing(false);
+ setResizeStartWidth(0);
+ setResizeStartHeight(0);
+ }}
+ style={{
+ position: 'absolute',
+ top,
+ left,
+ right,
+ zIndex,
+ width,
+ height,
+ }}
+ handleStyles={{
+ left: { height: '100vh' },
+ right: { height: '100vh' },
+ }}
+ >
+ {sidebarContentPanel === PANELS.CHAT
+ && (
+
+
+
+ )}
+ {!isSharedNotesPinned && (
+
+ )}
+ {sidebarContentPanel === PANELS.CAPTIONS && }
+ {sidebarContentPanel === PANELS.BREAKOUT && }
+ {sidebarContentPanel === PANELS.TIMER && }
+ {sidebarContentPanel === PANELS.WAITING_USERS && }
+ {sidebarContentPanel === PANELS.POLL && (
+
+
+
+ )}
+
+ );
+};
+
+SidebarContent.propTypes = propTypes;
+SidebarContent.defaultProps = defaultProps;
+export default SidebarContent;
diff --git a/src/2.7.12/imports/ui/components/sidebar-content/container.jsx b/src/2.7.12/imports/ui/components/sidebar-content/container.jsx
new file mode 100644
index 00000000..f567749b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/sidebar-content/container.jsx
@@ -0,0 +1,26 @@
+import React, { useContext } from 'react';
+import SidebarContent from './component';
+import { layoutSelectInput, layoutSelectOutput, layoutDispatch } from '../layout/context';
+import { UsersContext } from '../components-data/users-context/context';
+import Auth from '/imports/ui/services/auth';
+
+const SidebarContentContainer = () => {
+ const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
+ const sidebarContentOutput = layoutSelectOutput((i) => i.sidebarContent);
+ const layoutContextDispatch = layoutDispatch();
+ const { sidebarContentPanel } = sidebarContentInput;
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
+
+ return (
+
+ );
+};
+
+export default SidebarContentContainer;
diff --git a/src/2.7.12/imports/ui/components/sidebar-content/styles.js b/src/2.7.12/imports/ui/components/sidebar-content/styles.js
new file mode 100644
index 00000000..04c43f15
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/sidebar-content/styles.js
@@ -0,0 +1,43 @@
+import styled from 'styled-components';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { borderSize, navbarHeight } from '/imports/ui/stylesheets/styled-components/general';
+import { smallOnly, mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints';
+
+const Poll = styled.div`
+ position: absolute;
+ display: flex;
+ flex-flow: column;
+ overflow-y: auto;
+ overflow-x: hidden;
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ order: 2;
+ height: 100%;
+ background-color: ${colorWhite};
+ min-width: 20em;
+ padding: 1rem;
+
+ @media ${smallOnly} {
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 5;
+ height: auto;
+ top: ${navbarHeight};
+ overflow: auto;
+ &.no-padding {
+ padding: 0;
+ }
+ }
+
+ @media ${mediumUp} {
+ position: relative;
+ order: 1;
+ }
+`;
+
+export default {
+ Poll,
+};
diff --git a/src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx b/src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx
new file mode 100644
index 00000000..583ed73c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/sidebar-navigation/component.jsx
@@ -0,0 +1,110 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import Resizable from 're-resizable';
+import { ACTIONS } from '../layout/enums';
+import UserListContainer from '../user-list/container';
+
+const propTypes = {
+ top: PropTypes.number.isRequired,
+ left: PropTypes.number,
+ right: PropTypes.number,
+ zIndex: PropTypes.number.isRequired,
+ minWidth: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ maxWidth: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ isResizable: PropTypes.bool.isRequired,
+ resizableEdge: PropTypes.objectOf(PropTypes.bool).isRequired,
+ contextDispatch: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ left: null,
+ right: null,
+};
+
+const SidebarNavigation = (props) => {
+ const {
+ top,
+ left,
+ right,
+ zIndex,
+ minWidth,
+ width,
+ maxWidth,
+ height,
+ isResizable,
+ resizableEdge,
+ contextDispatch,
+ } = props;
+
+ const [resizableWidth, setResizableWidth] = useState(width);
+ const [isResizing, setIsResizing] = useState(false);
+ const [resizeStartWidth, setResizeStartWidth] = useState(0);
+
+ useEffect(() => {
+ if (!isResizing) setResizableWidth(width);
+ }, [width]);
+
+ const setSidebarNavWidth = (dWidth) => {
+ const newWidth = resizeStartWidth + dWidth;
+
+ setResizableWidth(newWidth);
+
+ contextDispatch({
+ type: ACTIONS.SET_SIDEBAR_NAVIGATION_SIZE,
+ value: {
+ width: newWidth,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ },
+ });
+ };
+
+ return (
+ {
+ setIsResizing(true);
+ setResizeStartWidth(resizableWidth);
+ }}
+ onResize={(...[, , , delta]) => setSidebarNavWidth(delta.width)}
+ onResizeStop={() => {
+ setIsResizing(false);
+ setResizeStartWidth(0);
+ }}
+ style={{
+ position: 'absolute',
+ top,
+ left,
+ right,
+ zIndex,
+ width,
+ height,
+ }}
+ >
+
+
+ );
+};
+
+SidebarNavigation.propTypes = propTypes;
+SidebarNavigation.defaultProps = defaultProps;
+export default SidebarNavigation;
diff --git a/src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx b/src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx
new file mode 100644
index 00000000..cb122057
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/sidebar-navigation/container.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { layoutDispatch, layoutSelectOutput } from '../layout/context';
+import SidebarNavigation from './component';
+
+const SidebarNavigationContainer = () => {
+ const sidebarNavigation = layoutSelectOutput((i) => i.sidebarNavigation);
+ const layoutContextDispatch = layoutDispatch();
+
+ if (sidebarNavigation.display === false) return null;
+
+ return (
+
+ );
+};
+
+export default SidebarNavigationContainer;
diff --git a/src/2.7.12/imports/ui/components/subscriptions/component.jsx b/src/2.7.12/imports/ui/components/subscriptions/component.jsx
new file mode 100644
index 00000000..908b85dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/subscriptions/component.jsx
@@ -0,0 +1,167 @@
+import { Component } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import logger from '/imports/startup/client/logger';
+import GroupChat from '/imports/api/group-chat';
+import Annotations from '/imports/api/annotations';
+import Users from '/imports/api/users';
+import { Annotations as AnnotationsLocal } from '/imports/ui/components/whiteboard/service';
+import {
+ localCollectionRegistry,
+} from '/client/collection-mirror-initializer';
+import SubscriptionRegistry, { subscriptionReactivity } from '../../services/subscription-registry/subscriptionRegistry';
+import { isChatEnabled } from '/imports/ui/services/features';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const TYPING_INDICATOR_ENABLED = CHAT_CONFIG.typingIndicator.enabled;
+const SUBSCRIPTIONS = [
+ 'users', 'meetings', 'polls', 'presentations', 'slides', 'slide-positions', 'captions',
+ 'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat',
+ 'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'meeting-time-remaining',
+ 'local-settings', 'users-typing', 'record-meetings', 'video-streams',
+ 'connection-status', 'voice-call-states', 'external-video-meetings', 'breakouts', 'breakouts-history',
+ 'pads', 'pads-sessions', 'pads-updates', 'notifications', 'audio-captions',
+ 'layout-meetings', 'user-reaction', 'timer',
+];
+const {
+ localBreakoutsSync,
+ localBreakoutsHistorySync,
+ localGuestUsersSync,
+ localMeetingsSync,
+ localUsersSync,
+} = localCollectionRegistry;
+
+const EVENT_NAME = 'bbb-group-chat-messages-subscription-has-stoppped';
+const EVENT_NAME_SUBSCRIPTION_READY = 'bbb-group-chat-messages-subscriptions-ready';
+
+let oldRole = '';
+
+class Subscriptions extends Component {
+ componentDidUpdate() {
+ const { subscriptionsReady } = this.props;
+ if (subscriptionsReady) {
+ Session.set('subscriptionsReady', true);
+ const event = new Event(EVENT_NAME_SUBSCRIPTION_READY);
+ window.dispatchEvent(event);
+ Session.set('globalIgnoreDeletes', false);
+ }
+ }
+
+ render() {
+ const { children } = this.props;
+ return children;
+ }
+}
+
+export default withTracker(() => {
+ const { credentials } = Auth;
+ const { meetingId, requesterUserId } = credentials;
+ const userWillAuth = Session.get('userWillAuth');
+ // This if exist because when a unauth user try to subscribe to a publisher
+ // it returns a empty collection to the subscription
+ // and not rerun when the user is authenticated
+ if (userWillAuth) return {};
+
+ if (Session.get('codeError')) {
+ return {
+ subscriptionsReady: true,
+ };
+ }
+
+ const subscriptionErrorHandler = {
+ onError: (error) => {
+ logger.error({
+ logCode: 'startup_client_subscription_error',
+ extraInfo: { error },
+ }, 'Error while subscribing to collections');
+ Session.set('codeError', error.error);
+ },
+ };
+
+ const currentUser = Users.findOne({ intId: requesterUserId }, { fields: { role: 1 } });
+
+ let subscriptionsHandlers = SUBSCRIPTIONS.map((name) => {
+ let subscriptionHandlers = subscriptionErrorHandler;
+ if ((!TYPING_INDICATOR_ENABLED && name.indexOf('typing') !== -1)
+ || (!isChatEnabled() && name.indexOf('chat') !== -1)) return null;
+
+ if (name === 'users') {
+ subscriptionHandlers = {
+ ...subscriptionHandlers,
+ onStop: () => {
+ const event = new Event(EVENT_NAME);
+ window.dispatchEvent(event);
+ },
+ };
+ }
+
+ return SubscriptionRegistry.createSubscription(name, subscriptionHandlers);
+ });
+
+ if (currentUser && (oldRole !== currentUser?.role)) {
+ // stop subscription from the client-side as the server-side only watch moderators
+ if (oldRole === 'VIEWER' && currentUser?.role === 'MODERATOR') {
+ // let this withTracker re-execute when a subscription is stopped
+ subscriptionReactivity.depend();
+ localBreakoutsSync.setIgnoreDeletes(true);
+ localBreakoutsHistorySync.setIgnoreDeletes(true);
+ localGuestUsersSync.setIgnoreDeletes(true);
+ localMeetingsSync.setIgnoreDeletes(true);
+ localUsersSync.setIgnoreDeletes(true);
+ // Prevent data being removed by subscription stop
+ // stop role dependent subscriptions
+ [
+ SubscriptionRegistry.getSubscription('meetings'),
+ SubscriptionRegistry.getSubscription('users'),
+ SubscriptionRegistry.getSubscription('breakouts'),
+ SubscriptionRegistry.getSubscription('breakouts-history'),
+ SubscriptionRegistry.getSubscription('connection-status'),
+ SubscriptionRegistry.getSubscription('guestUser'),
+ ].forEach((item) => {
+ if (item) item.stop();
+ });
+ }
+ oldRole = currentUser?.role;
+ }
+
+ subscriptionsHandlers = subscriptionsHandlers.filter(obj => obj);
+ const ready = subscriptionsHandlers.every(handler => handler.ready());
+ let groupChatMessageHandler = {};
+
+ if (isChatEnabled() && ready) {
+ const subHandler = {
+ ...subscriptionErrorHandler,
+ };
+
+ groupChatMessageHandler = Meteor.subscribe('group-chat-msg', subHandler);
+ }
+
+ // TODO: Refactor all the late subscribers
+ let usersPersistentDataHandler = {};
+ if (ready) {
+ usersPersistentDataHandler = Meteor.subscribe('users-persistent-data');
+ const annotationsHandler = Meteor.subscribe('annotations', {
+ onReady: () => {
+ AnnotationsLocal.remove({});
+ Annotations.find({}, { reactive: false }).forEach((a) => {
+ try {
+ AnnotationsLocal.insert(a);
+ } catch (e) {
+ // TODO
+ }
+ });
+ annotationsHandler.stop();
+ },
+ ...subscriptionErrorHandler,
+ });
+
+ Object.values(localCollectionRegistry).forEach(
+ (localCollection) => localCollection.checkForStaleData(),
+ );
+ }
+
+ return {
+ subscriptionsReady: ready,
+ subscriptionsHandlers,
+ };
+})(Subscriptions);
diff --git a/src/2.7.12/imports/ui/components/text-input/component.jsx b/src/2.7.12/imports/ui/components/text-input/component.jsx
new file mode 100644
index 00000000..d513e4dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/text-input/component.jsx
@@ -0,0 +1,181 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import { isMobile } from '/imports/utils/deviceInfo';
+import logger from '/imports/startup/client/logger';
+import ClickOutside from '/imports/ui/components/click-outside/component';
+import Styled from './styles';
+
+const EMOJI_BUTTON = Meteor.settings.public.app.enableEmojiButton;
+
+const propTypes = {
+ placeholder: PropTypes.string,
+ send: PropTypes.func.isRequired,
+ emojiPickerDown: PropTypes.bool,
+};
+
+const defaultProps = {
+ placeholder: '',
+ send: () => logger.warn({ logCode: 'text_input_send_function' }, `Missing`),
+ emojiPickerDown: false,
+ enableEmoji: true,
+};
+
+const messages = defineMessages({
+ sendLabel: {
+ id: 'app.textInput.sendLabel',
+ description: 'Text input send button label',
+ },
+});
+
+class TextInput extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ message: '',
+ showEmojiPicker: false
+ };
+ }
+
+ hasClickOutsideActions() {
+ return this.emojiEnabled();
+ }
+
+ handleOnChange(e) {
+ const message = e.target.value;
+ this.setState({ message });
+ }
+
+ handleOnClick() {
+ const { send } = this.props;
+ const { message } = this.state;
+
+ send(message);
+ this.setState({
+ message: '',
+ showEmojiPicker: false,
+ });
+ }
+
+ handleOnKeyDown(e) {
+ if (e.keyCode === 13 && e.shiftKey === false) {
+ e.preventDefault();
+ this.handleOnClick();
+ } else if (e.keyCode === 27) { //Escape key
+ const { showEmojiPicker } = this.state;
+
+ //if the emoji picker is opened, close it
+ if (showEmojiPicker) {
+ this.setState({ showEmojiPicker: false });
+ }
+ }
+ }
+
+ handleEmojiButtonClick() {
+ const { showEmojiPicker } = this.state;
+
+ if (this.textarea) this.textarea.focus();
+ this.setState({ showEmojiPicker: !showEmojiPicker });
+ }
+
+ handleEmojiSelect(emojiObject) {
+ const { message } = this.state;
+ if (this.textarea) this.textarea.focus();
+ this.setState({ message: message + emojiObject.native });
+ }
+
+ handleClickOutside() {
+ if (this.emojiEnabled()) {
+ const { showEmojiPicker } = this.state;
+ if (showEmojiPicker) {
+ this.setState({ showEmojiPicker: false });
+ }
+ }
+ }
+
+ emojiEnabled() {
+ if (isMobile) return false;
+
+ const { enableEmoji } = this.props;
+ return enableEmoji && EMOJI_BUTTON;
+ }
+
+ renderEmojiPicker() {
+ const { showEmojiPicker } = this.state;
+
+ if (this.emojiEnabled() && showEmojiPicker) {
+ return (
+ this.handleEmojiSelect(emojiObject)}
+ />
+ );
+ }
+
+ return null;
+ }
+
+ renderEmojiButton = () => {
+ if (!this.emojiEnabled()) return null;
+
+ return (
+ this.handleEmojiButtonClick()}
+ >
+
+
+ );
+ }
+
+ renderInput() {
+ const {
+ intl,
+ maxLength,
+ placeholder,
+ emojiPickerDown,
+ } = this.props;
+
+ const { message } = this.state;
+
+ return (
+
+ this.handleOnChange(e)}
+ onKeyDown={(e) => this.handleOnKeyDown(e)}
+ onPaste={(e) => { e.stopPropagation(); }}
+ onCut={(e) => { e.stopPropagation(); }}
+ onCopy={(e) => { e.stopPropagation(); }}
+ placeholder={placeholder}
+ value={message}
+ />
+ this.handleOnClick()}
+ />
+
+ );
+ }
+
+ render() {
+ return this.hasClickOutsideActions() ? (
+ this.handleClickOutside()}
+ >
+ {this.renderInput()}
+
+ ) : this.renderInput();
+ }
+}
+
+TextInput.propTypes = propTypes;
+TextInput.defaultProps = defaultProps;
+
+export default injectIntl(TextInput);
diff --git a/src/2.7.12/imports/ui/components/text-input/styles.js b/src/2.7.12/imports/ui/components/text-input/styles.js
new file mode 100644
index 00000000..92f4fd0f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/text-input/styles.js
@@ -0,0 +1,79 @@
+import styled from 'styled-components';
+import {
+ smPaddingX,
+ smPaddingY,
+ borderRadius,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorText,
+ colorGrayLighter,
+ colorBlueLight,
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import TextareaAutosize from 'react-autosize-textarea';
+import Button from '/imports/ui/components/common/button/component';
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+const TextArea = styled(TextareaAutosize)`
+ flex: 1;
+ background: #fff;
+ background-clip: padding-box;
+ margin: 0;
+ color: ${colorText};
+ -webkit-appearance: none;
+ padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25);
+ resize: none;
+ transition: none;
+ border-radius: ${borderRadius};
+ font-size: ${fontSizeBase};
+ min-height: 2.5rem;
+ max-height: 10rem;
+ border: 1px solid ${colorGrayLighter};
+ box-shadow: 0 0 0 1px ${colorGrayLighter};
+
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ }
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+`;
+
+const TextInputButton = styled(Button)`
+ margin:0 0 0 ${smPaddingX};
+ align-self: center;
+ font-size: 0.9rem;
+
+ [dir="rtl"] & {
+ margin: 0 ${smPaddingX} 0 0;
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+`;
+
+export default {
+ Wrapper,
+ TextArea,
+ TextInputButton,
+};
diff --git a/src/2.7.12/imports/ui/components/timer/component.jsx b/src/2.7.12/imports/ui/components/timer/component.jsx
new file mode 100644
index 00000000..01c995b7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/component.jsx
@@ -0,0 +1,449 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Service from './service';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ hideTimerLabel: {
+ id: 'app.timer.hideTimerLabel',
+ description: 'Label for hiding timer button',
+ },
+ title: {
+ id: 'app.timer.title',
+ description: 'Title for timer',
+ },
+ stopwatch: {
+ id: 'app.timer.button.stopwatch',
+ description: 'Stopwatch switch button',
+ },
+ timer: {
+ id: 'app.timer.button.timer',
+ description: 'Timer switch button',
+ },
+ start: {
+ id: 'app.timer.button.start',
+ description: 'Timer start button',
+ },
+ stop: {
+ id: 'app.timer.button.stop',
+ description: 'Timer stop button',
+ },
+ reset: {
+ id: 'app.timer.button.reset',
+ description: 'Timer reset button',
+ },
+ hours: {
+ id: 'app.timer.hours',
+ description: 'Timer hours label',
+ },
+ minutes: {
+ id: 'app.timer.minutes',
+ description: 'Timer minutes label',
+ },
+ seconds: {
+ id: 'app.timer.seconds',
+ description: 'Timer seconds label',
+ },
+ songs: {
+ id: 'app.timer.music',
+ description: 'Music title label',
+ },
+ noTrack: {
+ id: 'app.timer.noTrack',
+ description: 'No track radio label',
+ },
+ track1: {
+ id: 'app.timer.track1',
+ description: 'Track 1 radio label',
+ },
+ track2: {
+ id: 'app.timer.track2',
+ description: 'Track 2 radio label',
+ },
+ track3: {
+ id: 'app.timer.track3',
+ description: 'Track 3 radio label',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ timer: PropTypes.shape({
+ stopwatch: PropTypes.bool,
+ running: PropTypes.bool,
+ time: PropTypes.string,
+ accumulated: PropTypes.number,
+ timestamp: PropTypes.number,
+ }).isRequired,
+ layoutContextDispatch: PropTypes.shape().isRequired,
+ timeOffset: PropTypes.number.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+ isActive: PropTypes.bool.isRequired,
+ isModerator: PropTypes.bool.isRequired,
+ currentTrack: PropTypes.string.isRequired,
+ isResizing: PropTypes.bool.isRequired,
+};
+
+class Timer extends Component {
+ static handleOnTrackChange(event) {
+ Service.setTrack(event.target.value);
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.timeRef = React.createRef();
+ this.interval = null;
+
+ this.updateTime = this.updateTime.bind(this);
+ }
+
+ componentDidMount() {
+ const { timer } = this.props;
+ const { running } = timer;
+
+ const { current } = this.timeRef;
+ if (current && running) {
+ this.interval = setInterval(this.updateTime, Service.getInterval());
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const { timer } = this.props;
+ const { timer: prevTimer } = prevProps;
+
+ this.updateInterval(prevTimer, timer);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval);
+ }
+
+ handleControlClick() {
+ const { timer } = this.props;
+
+ if (timer.running) {
+ Service.stopTimer();
+ } else {
+ Service.startTimer();
+ }
+ }
+
+ handleOnHoursChange(event) {
+ const { timer } = this.props;
+ const { target } = event;
+
+ if (target && target.value) {
+ const hours = parseInt(target.value, 10);
+ Service.setHours(hours, timer.time);
+ }
+ }
+
+ handleOnMinutesChange(event) {
+ const { timer } = this.props;
+ const { target } = event;
+
+ if (target && target.value) {
+ const minutes = parseInt(target.value, 10);
+ Service.setMinutes(minutes, timer.time);
+ }
+ }
+
+ handleOnSecondsChange(event) {
+ const { timer } = this.props;
+ const { target } = event;
+
+ if (target && target.value) {
+ const seconds = parseInt(target.value, 10);
+ Service.setSeconds(seconds, timer.time);
+ }
+ }
+
+ handleSwitchToStopwatch() {
+ const { timer } = this.props;
+
+ if (!timer.stopwatch) {
+ Service.switchTimer(true);
+ }
+ }
+
+ handleSwitchToTimer() {
+ const { timer } = this.props;
+
+ if (timer.stopwatch) {
+ Service.switchTimer(false);
+ }
+ }
+
+ getTime() {
+ const {
+ timer,
+ timeOffset,
+ } = this.props;
+
+ const {
+ stopwatch,
+ running,
+ time,
+ accumulated,
+ timestamp,
+ } = timer;
+
+ const elapsedTime = Service.getElapsedTime(running, timestamp, timeOffset, accumulated);
+
+ let updatedTime;
+ if (stopwatch) {
+ updatedTime = elapsedTime;
+ } else {
+ updatedTime = Math.max(time - elapsedTime, 0);
+ }
+
+ return Service.getTimeAsString(updatedTime, stopwatch);
+ }
+
+ updateTime() {
+ const { current } = this.timeRef;
+ if (current) {
+ current.textContent = this.getTime();
+ }
+ }
+
+ updateInterval(prevTimer, timer) {
+ const { running } = timer;
+ const { running: prevRunning } = prevTimer;
+
+ if (!prevRunning && running) {
+ this.interval = setInterval(this.updateTime, Service.getInterval());
+ }
+
+ if (prevRunning && !running) {
+ clearInterval(this.interval);
+ }
+ }
+
+ renderControls() {
+ const {
+ intl,
+ timer,
+ } = this.props;
+
+ const { running } = timer;
+
+ const label = running ? intlMessages.stop : intlMessages.start;
+ const color = running ? 'danger' : 'primary';
+
+ return (
+
+ this.handleControlClick()}
+ />
+ Service.resetTimer()}
+ />
+
+ );
+ }
+
+ renderSongSelectorRadios() {
+ const {
+ intl,
+ timer,
+ currentTrack,
+ } = this.props;
+
+ const {
+ stopwatch,
+ } = timer;
+
+ return (
+
+
+ {intl.formatMessage(intlMessages.songs)}
+
+
+ {Service.TRACKS.map((track) => (
+
+
+
+ {intl.formatMessage(intlMessages[track])}
+
+
+ ))}
+
+
+ );
+ }
+
+ renderTimer() {
+ const {
+ intl,
+ timer,
+ } = this.props;
+
+ const {
+ time,
+ stopwatch,
+ } = timer;
+
+ if (stopwatch) return this.renderControls();
+
+ const timeArray = Service.getTimeAsString(time).split(':');
+
+ const hasHours = timeArray.length === 3;
+
+ const hours = hasHours ? timeArray[0] : '00';
+ const minutes = hasHours ? timeArray[1] : timeArray[0];
+ const seconds = hasHours ? timeArray[2] : timeArray[1];
+
+ return (
+
+
+
+ this.handleOnHoursChange(event)}
+ />
+
+ {intl.formatMessage(intlMessages.hours)}
+
+
+ :
+
+ this.handleOnMinutesChange(event)}
+ />
+
+ {intl.formatMessage(intlMessages.minutes)}
+
+
+ :
+
+ this.handleOnSecondsChange(event)}
+ />
+
+ {intl.formatMessage(intlMessages.seconds)}
+
+
+
+ { Service.isMusicEnabled()
+ ? this.renderSongSelectorRadios() : null}
+ {this.renderControls()}
+
+ );
+ }
+
+ renderContent() {
+ const {
+ intl,
+ isResizing,
+ timer,
+ } = this.props;
+
+ const { stopwatch } = timer;
+
+ return (
+
+
+ {this.getTime()}
+
+
+ this.handleSwitchToStopwatch()}
+ color={stopwatch ? 'primary' : 'secondary'}
+ />
+ this.handleSwitchToTimer()}
+ color={!stopwatch ? 'primary' : 'secondary'}
+ />
+
+ {this.renderTimer()}
+
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ isRTL,
+ isActive,
+ isModerator,
+ layoutContextDispatch,
+ timer,
+ } = this.props;
+
+ if (!isActive || !isModerator) {
+ Service.closePanel(layoutContextDispatch);
+ return null;
+ }
+
+ const { stopwatch } = timer;
+ const message = stopwatch ? intlMessages.stopwatch : intlMessages.timer;
+
+ return (
+
+
+
+ Service.closePanel(layoutContextDispatch)}
+ aria-label={intl.formatMessage(intlMessages.hideTimerLabel)}
+ label={intl.formatMessage(message)}
+ icon={isRTL ? 'right_arrow' : 'left_arrow'}
+ />
+
+
+ {this.renderContent()}
+
+ );
+ }
+}
+
+Timer.propTypes = propTypes;
+
+export default injectWbResizeEvent(injectIntl(Timer));
diff --git a/src/2.7.12/imports/ui/components/timer/container.jsx b/src/2.7.12/imports/ui/components/timer/container.jsx
new file mode 100644
index 00000000..af2883df
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/container.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Timer from './component';
+import Service from './service';
+import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context';
+
+const TimerContainer = ({ children, ...props }) => {
+ const layoutContextDispatch = layoutDispatch();
+ const cameraDock = layoutSelectInput((i) => i.cameraDock);
+ const { isResizing } = cameraDock;
+ return (
+
+ {children}
+
+ );
+};
+
+export default withTracker(() => {
+ const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
+ return {
+ isRTL,
+ isActive: Service.isActive(),
+ isModerator: Service.isModerator(),
+ timeOffset: Service.getTimeOffset(),
+ timer: Service.getTimer(),
+ currentTrack: Service.getCurrentTrack(),
+ };
+})(TimerContainer);
diff --git a/src/2.7.12/imports/ui/components/timer/indicator/component.jsx b/src/2.7.12/imports/ui/components/timer/indicator/component.jsx
new file mode 100644
index 00000000..01e2c7ad
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/indicator/component.jsx
@@ -0,0 +1,460 @@
+import React, { Component } from 'react';
+import Icon from '/imports/ui/components/common/icon/component';
+import TimerService from '/imports/ui/components/timer/service';
+import logger from '/imports/startup/client/logger';
+import Tooltip from '/imports/ui/components/common/tooltip/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const CDN = Meteor.settings.public.app.cdn;
+const BASENAME = Meteor.settings.public.app.basename;
+const HOST = CDN + BASENAME;
+const trackName = Meteor.settings.public.timer.music;
+const TAB_TIMER_INDICATOR = Meteor.settings.public.timer.tabIndicator;
+
+const propTypes = {
+ timer: PropTypes.shape({
+ stopwatch: PropTypes.bool,
+ running: PropTypes.bool,
+ time: PropTypes.number,
+ accumulated: PropTypes.number,
+ timestamp: PropTypes.number,
+ }).isRequired,
+ isTimerActive: PropTypes.bool.isRequired,
+ isMusicActive: PropTypes.bool.isRequired,
+ sidebarNavigationIsOpen: PropTypes.bool.isRequired,
+ sidebarContentIsOpen: PropTypes.bool.isRequired,
+ timeOffset: PropTypes.number.isRequired,
+ isModerator: PropTypes.bool.isRequired,
+ currentTrack: PropTypes.bool.isRequired,
+};
+
+const intlMessages = defineMessages({
+ toolTipTimerStopped: {
+ id: 'app.timer.toolTipTimerStopped',
+ },
+ toolTipTimerRunning: {
+ id: 'app.timer.toolTipTimerRunning',
+ },
+ toolTipStopwatchStopped: {
+ id: 'app.timer.toolTipStopwatchStopped',
+ },
+ toolTipStopwatchRunning: {
+ id: 'app.timer.toolTipStopwatchRunning',
+ },
+ toolTipTimerStoppedMod: {
+ id: 'app.timer.toolTipTimerStoppedMod',
+ },
+ toolTipTimerRunningMod: {
+ id: 'app.timer.toolTipTimerRunningMod',
+ },
+ toolTipStopwatchStoppedMod: {
+ id: 'app.timer.toolTipStopwatchStoppedMod',
+ },
+ toolTipStopwatchRunningMod: {
+ id: 'app.timer.toolTipStopwatchRunningMod',
+ },
+});
+
+class Indicator extends Component {
+ constructor(props) {
+ super(props);
+
+ this.timeRef = React.createRef();
+ this.interval = null;
+
+ this.alarm = null;
+ this.music = null;
+
+ // We need to avoid trigger on mount
+ this.triggered = true;
+
+ this.alreadyNotified = false;
+
+ this.updateTime = this.updateTime.bind(this);
+ }
+
+ componentDidMount() {
+ const { timer } = this.props;
+ const { running } = timer;
+
+ this.alarm = new Audio(`${HOST}/resources/sounds/alarm.mp3`);
+ this.setUpMusic();
+
+ this.triggered = this.initTriggered();
+
+ const { current } = this.timeRef;
+ if (current && running) {
+ this.interval = setInterval(this.updateTime, TimerService.getInterval());
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const { timer, isTimerActive } = this.props;
+ const { timer: prevTimer, isTimerActive: prevTimerActive } = prevProps;
+
+ if (this.shouldPlayMusic()) {
+ this.playMusic();
+ }
+
+ if (!isTimerActive && prevTimerActive) {
+ this.updateTabTitleTimer(true, this.getTime());
+ }
+
+ this.updateInterval(prevTimer, timer);
+ this.updateAlarmTrigger(prevTimer, timer);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.interval);
+ this.stopMusic();
+ }
+
+ getTime() {
+ const {
+ timer,
+ timeOffset,
+ } = this.props;
+
+ const {
+ stopwatch,
+ running,
+ time,
+ accumulated,
+ timestamp,
+ } = timer;
+
+ const elapsedTime = TimerService.getElapsedTime(running, timestamp, timeOffset, accumulated);
+
+ let updatedTime;
+ if (stopwatch) {
+ updatedTime = elapsedTime;
+ } else {
+ updatedTime = Math.max(time - elapsedTime, 0);
+ }
+
+ if (this.shouldNotifyTimerEnded(updatedTime)) {
+ TimerService.timerEnded();
+ }
+
+ if (this.shouldStopMusic(updatedTime)) {
+ this.stopMusic();
+ }
+
+ if (this.soundAlarm(updatedTime)) {
+ this.play();
+ }
+
+ return TimerService.getTimeAsString(updatedTime, stopwatch);
+ }
+
+ setUpMusic() {
+ const { currentTrack } = this.props;
+ if (trackName[currentTrack] === undefined) return;
+ if (this.music === null) {
+ this.music = new Audio(`${HOST}/resources/sounds/${trackName[currentTrack]}.mp3`);
+ this.music.volume = TimerService.getMusicVolume();
+ this.music.addEventListener('timeupdate', () => {
+ const buffer = 0.19;
+ // Start playing the music before it ends to make the loop gapless
+ if (this.music.currentTime > this.music.duration - buffer) {
+ this.music.currentTime = 0;
+ this.music.play();
+ }
+ });
+ } else {
+ this.music.src = `${HOST}/resources/sounds/${trackName[currentTrack]}.mp3`;
+ }
+ this.music.track = currentTrack;
+ }
+
+ updateInterval(prevTimer, timer) {
+ const { running } = timer;
+ const { running: prevRunning } = prevTimer;
+
+ if (!prevRunning && running) {
+ this.interval = setInterval(this.updateTime, TimerService.getInterval());
+ }
+
+ if (prevRunning && !running) {
+ clearInterval(this.interval);
+ }
+ }
+
+ updateAlarmTrigger(prevTimer, timer) {
+ const {
+ accumulated,
+ timestamp,
+ } = timer;
+
+ const { timestamp: prevTimestamp } = prevTimer;
+
+ const reseted = timestamp !== prevTimestamp && accumulated === 0;
+
+ if (reseted) {
+ this.triggered = false;
+ this.alreadyNotified = false;
+ }
+ }
+
+ initTriggered() {
+ const {
+ timer,
+ timeOffset,
+ } = this.props;
+
+ const {
+ stopwatch,
+ running,
+ } = timer;
+
+ if (stopwatch || !running) return false;
+
+ const {
+ time,
+ accumulated,
+ timestamp,
+ } = timer;
+
+ const elapsedTime = TimerService.getElapsedTime(running, timestamp, timeOffset, accumulated);
+ const updatedTime = Math.max(time - elapsedTime, 0);
+
+ if (updatedTime === 0) return true;
+
+ return false;
+ }
+
+ play() {
+ if (this.alarm && !this.triggered) {
+ this.triggered = true;
+ this.alarm.play().catch((error) => {
+ logger.error({
+ logCode: 'timer_sound_error',
+ extraInfo: { error },
+ }, `Timer beep failed: ${error}`);
+ });
+ }
+ }
+
+ soundAlarm(time) {
+ const { timer } = this.props;
+ const {
+ running,
+ stopwatch,
+ } = timer;
+
+ const enabled = TimerService.isAlarmEnabled();
+ const zero = time === 0;
+
+ return enabled && running && zero && !stopwatch;
+ }
+
+ playMusic() {
+ const handleUserInteraction = () => {
+ this.music.play();
+ window.removeEventListener('click', handleUserInteraction);
+ window.removeEventListener('auxclick', handleUserInteraction);
+ window.removeEventListener('keydown', handleUserInteraction);
+ window.removeEventListener('touchstart', handleUserInteraction);
+ };
+
+ const playMusicAfterUserInteraction = () => {
+ window.addEventListener('click', handleUserInteraction);
+ window.addEventListener('auxclick', handleUserInteraction);
+ window.addEventListener('keydown', handleUserInteraction);
+ window.addEventListener('touchstart', handleUserInteraction);
+ };
+
+ this.handleUserInteraction = handleUserInteraction;
+
+ if (this.music !== null) {
+ this.music.play()
+ .catch((error) => {
+ if (error.name === 'NotAllowedError') {
+ playMusicAfterUserInteraction();
+ }
+ });
+ }
+ }
+
+ stopMusic() {
+ if (this.music !== null) {
+ this.music.pause();
+ this.music.currentTime = 0;
+ window.removeEventListener('click', this.handleUserInteraction);
+ window.removeEventListener('auxclick', this.handleUserInteraction);
+ window.removeEventListener('keydown', this.handleUserInteraction);
+ window.removeEventListener('touchstart', this.handleUserInteraction);
+ }
+ }
+
+ shouldPlayMusic() {
+ const {
+ timer,
+ isMusicActive,
+ } = this.props;
+
+ const {
+ running,
+ stopwatch,
+ } = timer;
+ const validMusic = this.music != null;
+ if (!validMusic) return false;
+
+ const musicIsPlaying = !this.music.paused;
+
+ return !musicIsPlaying && isMusicActive && running && !stopwatch;
+ }
+
+ shouldStopMusic(time) {
+ const {
+ timer,
+ isTimerActive,
+ isMusicActive,
+ currentTrack,
+ } = this.props;
+
+ const {
+ running,
+ stopwatch,
+ } = timer;
+
+ const zero = time === 0;
+ const validMusic = this.music != null;
+ const musicIsPlaying = validMusic && !this.music.paused;
+ const trackChanged = this.music?.track !== currentTrack;
+
+ const reachedZeroOrStopped = (running && zero) || (!running);
+
+ return musicIsPlaying
+ && (!isMusicActive || stopwatch || reachedZeroOrStopped || !isTimerActive || trackChanged);
+ }
+
+ shouldNotifyTimerEnded(time) {
+ const { timer } = this.props;
+ const {
+ running,
+ stopwatch,
+ } = timer;
+
+ if (stopwatch || !running) return false;
+
+ const reachedZero = time === 0;
+
+ if (reachedZero && !this.alreadyNotified) {
+ this.alreadyNotified = true;
+ return true;
+ }
+ return false;
+ }
+
+ updateTime() {
+ const { current } = this.timeRef;
+ if (current) {
+ current.textContent = this.getTime();
+ this.updateTabTitleTimer(false, current.textContent);
+ }
+ }
+
+ updateTabTitleTimer(deactivation, timeString) {
+ if (!TAB_TIMER_INDICATOR) return;
+
+ const matchTimerString = /\[[0-9][0-9]:[0-9][0-9]:[0-9][0-9]\]/g;
+
+ if (deactivation) {
+ document.title = document.title.replace(matchTimerString, '');
+ } else {
+ if (RegExp(matchTimerString).test(document.title)) {
+ document.title = document.title.replace(matchTimerString, '');
+ document.title = '[' + timeString + '] ' + document.title;
+ } else {
+ document.title = '[' + timeString + '] ' + document.title;
+ }
+ }
+ }
+
+ render() {
+ const { isTimerActive } = this.props;
+ const time = this.getTime();
+ if (!isTimerActive) return null;
+
+ const {
+ isModerator,
+ timer,
+ sidebarNavigationIsOpen,
+ sidebarContentIsOpen,
+ currentTrack,
+ intl,
+ } = this.props;
+ const { stopwatch, running } = timer;
+ const trackChanged = this.music?.track !== currentTrack;
+ if (trackChanged) {
+ this.setUpMusic();
+ }
+
+ const onClick = running ? TimerService.stopTimer : TimerService.startTimer;
+ this.updateTabTitleTimer(false, time);
+
+ const title = () => {
+ if (isModerator) {
+ if (stopwatch && !running) {
+ return intl.formatMessage(intlMessages.toolTipStopwatchStoppedMod);
+ } if (stopwatch && running) {
+ return intl.formatMessage(intlMessages.toolTipStopwatchRunningMod);
+ } if (!stopwatch) {
+ if (running) {
+ return intl.formatMessage(intlMessages.toolTipTimerRunningMod);
+ } if (!running) {
+ return intl.formatMessage(intlMessages.toolTipTimerStoppedMod);
+ }
+ }
+ }
+ if (stopwatch && !running) {
+ return intl.formatMessage(intlMessages.toolTipStopwatchStopped);
+ } if (stopwatch && running) {
+ return intl.formatMessage(intlMessages.toolTipStopwatchRunning);
+ } if (!stopwatch) {
+ if (running) {
+ return intl.formatMessage(intlMessages.toolTipTimerRunning);
+ } if (!running) {
+ return intl.formatMessage(intlMessages.toolTipTimerStopped);
+ }
+ }
+ };
+ return (
+
+
+
+
+
+
+
+
+
+ {time}
+
+
+
+
+
+
+ );
+ }
+}
+
+Indicator.propTypes = propTypes;
+
+export default injectIntl(Indicator);
diff --git a/src/2.7.12/imports/ui/components/timer/indicator/container.jsx b/src/2.7.12/imports/ui/components/timer/indicator/container.jsx
new file mode 100644
index 00000000..4e8e2e35
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/indicator/container.jsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Indicator from './component';
+import TimerService from '/imports/ui/components/timer/service';
+import { layoutSelectInput } from '/imports/ui/components/layout/context';
+
+const IndicatorContainer = (props) => {
+ const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const sidebarNavigationIsOpen = sidebarNavigation.isOpen;
+ const sidebarContentIsOpen = sidebarContent.isOpen;
+
+ return (
+
+ );
+};
+
+export default withTracker(() => ({
+ timer: TimerService.getTimer(),
+ timeOffset: TimerService.getTimeOffset(),
+ isModerator: TimerService.isModerator(),
+ isTimerActive: TimerService.isActive(),
+ isMusicActive: TimerService.isMusicActive(),
+ currentTrack: TimerService.getCurrentTrack(),
+}))(IndicatorContainer);
diff --git a/src/2.7.12/imports/ui/components/timer/indicator/styles.js b/src/2.7.12/imports/ui/components/timer/indicator/styles.js
new file mode 100644
index 00000000..810f449c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/indicator/styles.js
@@ -0,0 +1,132 @@
+import styled from 'styled-components';
+import { phoneLandscape, smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { borderRadius } from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorSuccess,
+ colorDanger,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeBase, fontSizeXS } from '/imports/ui/stylesheets/styled-components/typography';
+
+const colorTimerRunning = `${colorSuccess}`;
+const colorTimerStopped = `${colorDanger}`;
+const timerMarginSM = '.5rem';
+const timerPaddingSM = '.25rem';
+const timerPaddingXL = '1.62rem';
+const timerMaxWidth = '10rem';
+const timerFontWeight = '400';
+const timerBorderRadius = '2rem';
+
+const TimerWrapper = styled.div`
+ overflow: hidden;
+ margin-left: auto;
+`;
+
+const Timer = styled.div`
+ margin-top: 0.5rem;
+ display: flex;
+ max-height: ${timerPaddingXL});
+`;
+
+const timerRunning = `
+ background-color: ${colorTimerRunning};
+ border: solid 2px ${colorTimerRunning};
+`;
+
+const timerStopped = `
+ background-color: ${colorTimerStopped};
+ border: solid 2px ${colorTimerStopped};
+`;
+
+const disabledStyle = `
+ cursor: default;
+`;
+
+const hiddenStyle = `
+ @media ${smallOnly} {
+ visibility: hidden;
+ }
+`;
+
+const TimerButton = styled.div`
+ @include highContrastOutline();
+ cursor: pointer;
+ color: white;
+ font-weight: ${timerFontWeight};
+ border-radius: ${timerBorderRadius} ${timerBorderRadius};
+ font-size: ${fontSizeBase};
+ margin-left: ${borderRadius};
+ margin-right: ${borderRadius};
+
+ @media ${phoneLandscape} {
+ height: 1rem;
+ }
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: ${timerMaxWidth};
+
+ @media ${phoneLandscape} {
+ font-size: ${fontSizeXS};
+ }
+ }
+
+ i {
+ font-size: var(--font-size-small);
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+
+ @media ${phoneLandscape} {
+ height: ${timerMarginSM};
+ width: ${timerMarginSM};
+ font-size: ${fontSizeXS};
+ }
+ }
+
+ ${({ running }) => (running ? timerRunning : timerStopped)};
+ ${({ disabled }) => disabled && disabledStyle};
+ ${({ hide }) => hide && hiddenStyle};
+`;
+
+const time = `
+ box-sizing: border-box;
+ display: flex;
+ align-self: center;
+ padding: 0 ${timerPaddingSM} 0 0;
+`;
+
+const TimerContent = styled.div`
+ ${time}
+ display: flex;
+
+ [dir="ltr"] & {
+ span:first-child {
+ padding: 0 ${timerPaddingSM};
+ }
+ }
+
+ [dir="rtl"] & {
+ span:last-child {
+ padding: 0 ${timerPaddingSM};
+ }
+ }
+`;
+
+const TimerIcon = styled.span`
+ ${time}
+`;
+
+const TimerTime = styled.span`
+ ${time}
+`;
+
+export default {
+ TimerWrapper,
+ Timer,
+ TimerButton,
+ TimerContent,
+ TimerIcon,
+ TimerTime,
+};
diff --git a/src/2.7.12/imports/ui/components/timer/service.js b/src/2.7.12/imports/ui/components/timer/service.js
new file mode 100644
index 00000000..dcdce7d4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/service.js
@@ -0,0 +1,348 @@
+import { Meteor } from 'meteor/meteor';
+import Timer from '/imports/api/timer';
+import Auth from '/imports/ui/services/auth';
+import { makeCall } from '/imports/ui/services/api';
+import { Session } from 'meteor/session';
+import Users from '/imports/api/users';
+import Logger from '/imports/startup/client/logger';
+import { ACTIONS, PANELS } from '../layout/enums';
+
+const TIMER_CONFIG = Meteor.settings.public.timer;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const OFFSET_INTERVAL = TIMER_CONFIG.interval.offset;
+
+const MILLI_IN_HOUR = 3600000;
+const MILLI_IN_MINUTE = 60000;
+const MILLI_IN_SECOND = 1000;
+
+const MAX_HOURS = 23;
+
+const TRACKS = [
+ 'noTrack',
+ 'track1',
+ 'track2',
+ 'track3',
+];
+
+const isMusicEnabled = () => TIMER_CONFIG.music.enabled;
+
+const getCurrentTrack = () => {
+ const timer = Timer.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { track: 1 } },
+ );
+
+ if (timer) return isMusicEnabled() && timer.track;
+
+ return false;
+};
+
+const isEnabled = () => TIMER_CONFIG.enabled;
+
+const getMaxHours = () => MAX_HOURS;
+
+const isAlarmEnabled = () => isEnabled() && TIMER_CONFIG.alarm;
+
+const isMusicActive = () => getCurrentTrack() !== TRACKS[0];
+
+const getMusicVolume = () => TIMER_CONFIG.music.volume;
+
+const getMusicTrack = () => TIMER_CONFIG.music.track;
+
+const isActive = () => {
+ const timer = Timer.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { active: 1 } },
+ );
+
+ if (timer) return timer.active;
+ return false;
+};
+
+const getDefaultTime = () => TIMER_CONFIG.time * MILLI_IN_MINUTE;
+
+const getInterval = () => TIMER_CONFIG.interval.clock;
+
+const isRunning = () => {
+ const timer = Timer.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { running: 1 } },
+ );
+
+ if (timer) return timer.running;
+ return false;
+};
+
+const isStopwatch = () => {
+ const timer = Timer.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { stopwatch: 1 } },
+ );
+
+ if (timer) return timer.stopwatch;
+ return false;
+};
+
+const startTimer = () => makeCall('startTimer');
+
+const stopTimer = () => makeCall('stopTimer');
+
+const switchTimer = (stopwatch) => makeCall('switchTimer', stopwatch);
+
+const setTimer = (time) => makeCall('setTimer', time);
+
+const resetTimer = () => makeCall('resetTimer');
+
+const activateTimer = (layoutContextDispatch) => {
+ makeCall('activateTimer');
+ //Set an observer to switch to timer tab as soon as the timer is activated
+ const handle =Timer.find({ meetingId: Auth.meetingID }).observeChanges({
+ changed(id, timer) {
+ if (timer.active === true) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.TIMER,
+ });
+ }
+ handle.stop();
+ }
+ });
+ };
+
+const deactivateTimer = () => makeCall('deactivateTimer');
+
+const timerEnded = () => makeCall('timerEnded');
+
+const setTrack = (track) => {
+ makeCall('setTrack', track);
+};
+
+const fetchTimeOffset = () => {
+ const t0 = Date.now();
+
+ makeCall('getServerTime').then((result) => {
+ if (result === 0) return;
+ const t3 = Date.now();
+
+ const ts = result;
+ const rtt = t3 - t0;
+ const timeOffset = Math.round(ts - rtt / 2 - t0);
+
+ Session.set('timeOffset', timeOffset);
+ });
+};
+
+const getTimeOffset = () => {
+ const timeOffset = Session.get('timeOffset');
+
+ if (timeOffset) return timeOffset;
+
+ return 0;
+};
+
+const getElapsedTime = (running, timestamp, timeOffset, accumulated) => {
+ if (!running) return accumulated;
+
+ const now = Date.now();
+
+ return accumulated + Math.abs(now - timestamp + timeOffset);
+};
+
+const getStopwatch = () => {
+ const timer = Timer.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { stopwatch: 1 } },
+ );
+
+ if (timer) return timer.stopwatch;
+
+ return true;
+};
+
+const getTimer = () => {
+ const timer = Timer.findOne(
+ { meetingId: Auth.meetingID },
+ {
+ fields:
+ {
+ stopwatch: 1,
+ running: 1,
+ time: 1,
+ accumulated: 1,
+ timestamp: 1,
+ },
+ },
+ );
+
+ if (timer) {
+ const {
+ stopwatch,
+ running,
+ time,
+ accumulated,
+ timestamp,
+ } = timer;
+
+ return {
+ stopwatch,
+ running,
+ time,
+ accumulated,
+ timestamp,
+ };
+ }
+
+ return {
+ stopwatch: true,
+ running: false,
+ time: getDefaultTime(),
+ accumulated: 0,
+ timestamp: 0,
+ };
+};
+
+const getTimeAsString = (time) => {
+ const milliseconds = time;
+
+ const hours = Math.floor(milliseconds / MILLI_IN_HOUR);
+ const mHours = hours * MILLI_IN_HOUR;
+
+ const minutes = Math.floor((milliseconds - mHours) / MILLI_IN_MINUTE);
+ const mMinutes = minutes * MILLI_IN_MINUTE;
+
+ const seconds = Math.floor((milliseconds - mHours - mMinutes) / MILLI_IN_SECOND);
+
+ let timeAsString = '';
+
+ if (hours < 10) {
+ timeAsString += `0${hours}:`;
+ } else {
+ timeAsString += `${hours}:`;
+ }
+
+ if (minutes < 10) {
+ timeAsString += `0${minutes}:`;
+ } else {
+ timeAsString += `${minutes}:`;
+ }
+
+ if (seconds < 10) {
+ timeAsString += `0${seconds}`;
+ } else {
+ timeAsString += `${seconds}`;
+ }
+
+ return timeAsString;
+};
+
+const closePanel = (layoutContextDispatch) => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+};
+
+const togglePanel = (sidebarContentPanel, layoutContextDispatch) => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: sidebarContentPanel !== PANELS.TIMER,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: sidebarContentPanel === PANELS.TIMER
+ ? PANELS.NONE
+ : PANELS.TIMER,
+ });
+};
+
+const isModerator = () => Users.findOne(
+ { userId: Auth.userID },
+ { fields: { role: 1 } },
+).role === ROLE_MODERATOR;
+
+const setHours = (hours, time) => {
+ if (!Number.isNaN(hours) && hours >= 0 && hours <= MAX_HOURS) {
+ const currentHours = Math.floor(time / MILLI_IN_HOUR);
+
+ const diff = (hours - currentHours) * MILLI_IN_HOUR;
+ setTimer(time + diff);
+ } else {
+ Logger.warn('Invalid time');
+ }
+};
+
+const setMinutes = (minutes, time) => {
+ if (!Number.isNaN(minutes) && minutes >= 0 && minutes <= 59) {
+ const currentHours = Math.floor(time / MILLI_IN_HOUR);
+ const mHours = currentHours * MILLI_IN_HOUR;
+
+ const currentMinutes = Math.floor((time - mHours) / MILLI_IN_MINUTE);
+
+ const diff = (minutes - currentMinutes) * MILLI_IN_MINUTE;
+ setTimer(time + diff);
+ } else {
+ Logger.warn('Invalid time');
+ }
+};
+
+const setSeconds = (seconds, time) => {
+ if (!Number.isNaN(seconds) && seconds >= 0 && seconds <= 59) {
+ const currentHours = Math.floor(time / MILLI_IN_HOUR);
+ const mHours = currentHours * MILLI_IN_HOUR;
+
+ const currentMinutes = Math.floor((time - mHours) / MILLI_IN_MINUTE);
+ const mMinutes = currentMinutes * MILLI_IN_MINUTE;
+
+ const currentSeconds = Math.floor((time - mHours - mMinutes) / MILLI_IN_SECOND);
+
+ const diff = (seconds - currentSeconds) * MILLI_IN_SECOND;
+ setTimer(time + diff);
+ } else {
+ Logger.warn('Invalid time');
+ }
+};
+
+export default {
+ OFFSET_INTERVAL,
+ TRACKS,
+ isActive,
+ isEnabled,
+ isMusicEnabled,
+ isMusicActive,
+ getCurrentTrack,
+ getMusicVolume,
+ getMusicTrack,
+ isRunning,
+ isStopwatch,
+ isAlarmEnabled,
+ startTimer,
+ stopTimer,
+ switchTimer,
+ setHours,
+ setMinutes,
+ setSeconds,
+ resetTimer,
+ activateTimer,
+ deactivateTimer,
+ fetchTimeOffset,
+ setTrack,
+ getTimeOffset,
+ getElapsedTime,
+ getInterval,
+ getMaxHours,
+ getStopwatch,
+ getTimer,
+ getTimeAsString,
+ closePanel,
+ togglePanel,
+ isModerator,
+ timerEnded,
+};
diff --git a/src/2.7.12/imports/ui/components/timer/styles.js b/src/2.7.12/imports/ui/components/timer/styles.js
new file mode 100644
index 00000000..13776c37
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/timer/styles.js
@@ -0,0 +1,266 @@
+import styled from 'styled-components';
+import {
+ borderSize,
+ borderSizeLarge,
+ mdPaddingX,
+ mdPaddingY,
+ pollHeaderOffset,
+ toastContentWidth,
+ borderRadius,
+} from '../../stylesheets/styled-components/general';
+import {
+ colorGrayDark,
+ colorGrayLighter,
+ colorGrayLightest,
+ colorGray,
+ colorBlueLight,
+ colorWhite,
+ colorPrimary,
+} from '../../stylesheets/styled-components/palette';
+import { TextElipsis } from '../../stylesheets/styled-components/placeholders';
+import Button from '/imports/ui/components/common/button/component';
+
+const TimerSidebarContent = styled.div`
+ background-color: ${colorWhite};
+ padding:
+ ${mdPaddingX}
+ ${mdPaddingY}
+ ${mdPaddingX}
+ ${mdPaddingX};
+
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ justify-content: space-around;
+ overflow: hidden;
+ height: 100%;
+ transform: translateZ(0);
+`;
+
+const TimerHeader = styled.header`
+ position: relative;
+ top: ${pollHeaderOffset};
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+`;
+
+const TimerTitle = styled.div`
+ ${TextElipsis};
+ flex: 1;
+
+ & > button, button:hover {
+ max-width: ${toastContentWidth};
+ }
+`;
+
+const TimerMinimizeButton = styled(Button)`
+ position: relative;
+ background-color: ${colorWhite};
+ display: block;
+ margin: ${borderSizeLarge};
+ margin-bottom: ${borderSize};
+ padding-left: 0;
+ padding-right: inherit;
+
+ [dir="rtl"] & {
+ padding-left: inherit;
+ padding-right: 0;
+ }
+
+ > i {
+ color: ${colorGrayDark};
+ font-size: smaller;
+
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+
+ &:hover {
+ background-color: ${colorWhite};
+ }
+`;
+
+const TimerContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+`;
+
+const TimerCurrent = styled.span`
+ border-bottom: 1px solid ${colorGrayLightest};
+ border-top: 1px solid ${colorGrayLightest};
+ display: flex;
+ font-size: xxx-large;
+ justify-content: center;
+`;
+
+const TimerType = styled.div`
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ padding-top: 2rem;
+`;
+
+const TimerSwitchButton = styled(Button)`
+ width: 100%;
+ height: 2rem;
+ margin: 0 .5rem;
+`;
+
+const StopwatchTime = styled.div`
+ display: flex;
+ margin-top: 4rem;
+ width: 100%;
+ height: 3rem;
+ font-size: x-large;
+ justify-content: center;
+
+ input {
+ width: 5rem;
+ }
+`;
+
+const StopwatchTimeInput = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ .label {
+ display: flex;
+ font-size: small;
+ justify-content: center;
+ }
+`;
+
+const StopwatchTimeInputLabel = styled.div`
+ display: flex;
+ font-size: small;
+ justify-content: center;
+`;
+
+const StopwatchTimeColon = styled.span`
+ align-self: center;
+ padding: 0 .25rem;
+`;
+
+const TimerSongsWrapper = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-flow: column;
+ margin-top: 4rem;
+ margin-bottom: -2rem;
+`;
+
+const TimerRow = `
+ display: flex;
+ flex-flow: row;
+ flex-grow: 1;
+`;
+
+const TimerCol = `
+ display: flex;
+ flex-flow: column;
+ flex-grow: 1;
+ flex-basis: 0;
+`;
+const TimerSongsTitle = styled.div`
+ ${TimerRow}
+ display: flex;
+ font-weight: bold;
+ font-size: 1.1rem;
+ opacity: ${({ stopwatch }) => (stopwatch ? '50%' : '100%')}
+`;
+
+const TimerTracks = styled.div`
+ ${TimerCol}
+ display: flex;
+ margin-top: 0.8rem;
+ margin-bottom: 2rem;
+
+ .row {
+ margin: 0.5rem auto;
+ }
+
+ label {
+ display: flex;
+ }
+
+ input {
+ margin: auto 0.5rem;
+ }
+`;
+
+const TimerTrackItem = styled.div`
+ ${TimerRow}
+`;
+
+const TimerControls = styled.div`
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ margin-top: 4rem;
+`;
+
+const TimerControlButton = styled(Button)`
+ width: 6rem;
+ margin: 0 1rem;
+`;
+
+const TimerInput = styled.input`
+ flex: 1;
+ border: 1px solid ${colorGrayLighter};
+ width: 50%;
+ text-align: center;
+ padding: .25rem;
+ border-radius: ${borderRadius};
+ background-clip: padding-box;
+ outline: none;
+
+ &::placeholder {
+ color: ${colorGray};
+ opacity: 1;
+ }
+
+ &:focus {
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:disabled,
+ &[disabled] {
+ cursor: not-allowed;
+ opacity: .75;
+ background-color: rgba(167,179,189,0.25);
+ }
+`;
+
+export default {
+ TimerSidebarContent,
+ TimerHeader,
+ TimerTitle,
+ TimerMinimizeButton,
+ TimerContent,
+ TimerCurrent,
+ TimerType,
+ TimerSwitchButton,
+ StopwatchTime,
+ StopwatchTimeInput,
+ StopwatchTimeInputLabel,
+ StopwatchTimeColon,
+ TimerSongsWrapper,
+ TimerSongsTitle,
+ TimerTracks,
+ TimerTrackItem,
+ TimerControls,
+ TimerControlButton,
+ TimerInput,
+};
diff --git a/src/2.7.12/imports/ui/components/user-avatar/component.jsx b/src/2.7.12/imports/ui/components/user-avatar/component.jsx
new file mode 100644
index 00000000..8b3f223b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-avatar/component.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Settings from '/imports/ui/services/settings';
+import Styled from './styles';
+
+const propTypes = {
+ children: PropTypes.node,
+ moderator: PropTypes.bool,
+ presenter: PropTypes.bool,
+ talking: PropTypes.bool,
+ muted: PropTypes.bool,
+ listenOnly: PropTypes.bool,
+ voice: PropTypes.bool,
+ noVoice: PropTypes.bool,
+ color: PropTypes.string,
+ emoji: PropTypes.bool,
+ avatar: PropTypes.string,
+ className: PropTypes.string,
+ isSkeleton: PropTypes.bool,
+};
+
+const defaultProps = {
+ children: <>>,
+ moderator: false,
+ presenter: false,
+ talking: false,
+ muted: false,
+ listenOnly: false,
+ voice: false,
+ noVoice: false,
+ color: '#000',
+ emoji: false,
+ avatar: '',
+ className: '',
+ isSkeleton: false,
+};
+
+const { animations } = Settings.application;
+
+const UserAvatar = ({
+ children,
+ moderator,
+ presenter,
+ className,
+ talking,
+ muted,
+ listenOnly,
+ color,
+ voice,
+ emoji,
+ avatar,
+ noVoice,
+ whiteboardAccess,
+ isSkeleton,
+}) => (
+ <>
+ {isSkeleton && ({children} )}
+
+ {!isSkeleton && (
+
+
+
+
+ {avatar.length !== 0 && !emoji
+ ? (
+
+
+
+ ) : (
+
+ {children}
+
+ )}
+
+ )}
+ >
+);
+
+UserAvatar.propTypes = propTypes;
+UserAvatar.defaultProps = defaultProps;
+
+export default UserAvatar;
diff --git a/src/2.7.12/imports/ui/components/user-avatar/styles.js b/src/2.7.12/imports/ui/components/user-avatar/styles.js
new file mode 100644
index 00000000..e47ecd30
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-avatar/styles.js
@@ -0,0 +1,279 @@
+import styled, { css, keyframes } from 'styled-components';
+import {
+ userIndicatorsOffset,
+ mdPaddingY,
+ indicatorPadding,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorPrimary,
+ colorWhite,
+ userListBg,
+ colorSuccess,
+ colorDanger,
+ colorOffWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const Content = styled.div`
+ color: ${colorWhite};
+ top: 50%;
+ position: absolute;
+ text-align: center;
+ left: 0;
+ right: 0;
+ font-size: 110%;
+ text-transform: capitalize;
+
+ &,
+ & > * {
+ line-height: 0; // to keep centralized vertically
+ }
+`;
+
+const Image = styled.div`
+ display: flex;
+ height: 100%;
+ width: 100%;
+ justify-content: center;
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+`;
+
+const Img = styled.img`
+ object-fit: cover;
+ overflow: hidden;
+
+ ${({ moderator }) => moderator && `
+ border-radius: 3px;
+ `}
+
+ ${({ moderator }) => !moderator && `
+ border-radius: 50%;
+ `}
+`;
+
+const pulse = keyframes`
+ 0% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: scale(1.5);
+ }
+`;
+
+const Talking = styled.div`
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ border-radius: inherit;
+
+ ${({ talking }) => talking && css`
+ background-color: currentColor;
+ `}
+
+ ${({ talking, animations }) => talking && animations && css`
+ animation: ${pulse} 1s infinite ease-in;
+ `}
+
+ &::before {
+ ${({ talking, animations }) => talking && !animations && `
+ content: '';
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background-color: currentColor;
+ border-radius: inherit;
+ box-shadow: 0 0 0 4px currentColor;
+ opacity: .5;
+ `}
+ }
+`;
+
+const Avatar = styled.div`
+ position: relative;
+ height: 2.25rem;
+ width: 2.25rem;
+ border-radius: 50%;
+ text-align: center;
+ font-size: .85rem;
+ border: 2px solid transparent;
+ user-select: none;
+
+ ${({ animations }) => animations && `
+ transition: .3s ease-in-out;
+ `}
+
+ &:after,
+ &:before {
+ content: "";
+ position: absolute;
+ width: 0;
+ height: 0;
+ color: inherit;
+ top: auto;
+ left: auto;
+ bottom: ${userIndicatorsOffset};
+ right: ${userIndicatorsOffset};
+ border: 1.5px solid ${userListBg};
+ border-radius: 50%;
+ background-color: ${colorSuccess};
+ color: ${colorWhite};
+ opacity: 0;
+ font-family: 'bbb-icons';
+ font-size: .65rem;
+ line-height: 0;
+ text-align: center;
+ z-index: 1;
+
+ [dir="rtl"] & {
+ left: ${userIndicatorsOffset};
+ right: auto;
+ padding-left: 0;
+ }
+
+ ${({ animations }) => animations && `
+ transition: .3s ease-in-out;
+ `}
+ }
+
+ ${({ moderator }) => moderator && `
+ border-radius: 5px;
+ `}
+
+ ${({ presenter }) => presenter && `
+ &:before {
+ content: "\\00a0\\e90b\\00a0";
+ opacity: 1;
+ top: ${userIndicatorsOffset};
+ left: ${userIndicatorsOffset};
+ bottom: auto;
+ right: auto;
+ border-radius: 5px;
+ background-color: ${colorPrimary};
+ height: 1.2rem;
+ width: 1.2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: ${userIndicatorsOffset};
+ }
+ }
+ `}
+
+ ${({ whiteboardAccess }) => whiteboardAccess && `
+ &:before {
+ content: "\\00a0\\e925\\00a0";
+ border-radius: 50% !important;
+ opacity: 1;
+ top: ${userIndicatorsOffset};
+ left: ${userIndicatorsOffset};
+ bottom: auto;
+ right: auto;
+ border-radius: 5px;
+ background-color: ${colorPrimary};
+ height: 1.2rem;
+ width: 1.2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: ${userIndicatorsOffset};
+ transform: scale(-1, 1);
+ }
+ }
+ `}
+
+ ${({ voice }) => voice && `
+ &:after {
+ content: "\\00a0\\e931\\00a0";
+ background-color: ${colorSuccess};
+ top: 1.375rem;
+ left: 1.375rem;
+ right: auto;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: 1.375rem;
+ }
+ opacity: 1;
+ width: 1.2rem;
+ height: 1.2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ `}
+
+ ${({ muted }) => muted && `
+ &:after {
+ content: "\\00a0\\e932\\00a0";
+ background-color: ${colorDanger};
+ opacity: 1;
+ width: 1.2rem;
+ height: 1.2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ `}
+
+ ${({ listenOnly }) => listenOnly && `
+ &:after {
+ content: "\\00a0\\e90c\\00a0";
+ opacity: 1;
+ width: 1.2rem;
+ height: 1.2rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+ `}
+
+ ${({ noVoice }) => noVoice && `
+ &:after {
+ content: "";
+ background-color: ${colorOffWhite};
+ top: 1.375rem;
+ left: 1.375rem;
+ right: auto;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: 1.375rem;
+ }
+
+ opacity: 1;
+ width: 1.2rem;
+ height: 1.2rem;
+ }
+ `}
+`;
+
+const Skeleton = styled.div`
+ & .react-loading-skeleton {
+ height: 2.25rem;
+ width: 2.25rem;
+ }
+`;
+
+export default {
+ Content,
+ Image,
+ Img,
+ Talking,
+ Avatar,
+ Skeleton,
+};
diff --git a/src/2.7.12/imports/ui/components/user-info/component.jsx b/src/2.7.12/imports/ui/components/user-info/component.jsx
new file mode 100644
index 00000000..d4f9c1ed
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-info/component.jsx
@@ -0,0 +1,70 @@
+import React, { Component } from 'react';
+import { defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+
+import Service from './service';
+
+import Styled from './styles';
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ meetingId: PropTypes.string.isRequired,
+ requesterUserId: PropTypes.string.isRequired,
+};
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.user-info.title',
+ description: 'User info title label',
+ },
+});
+
+class UserInfoComponent extends Component {
+ renderUserInfo() {
+ const { UserInfo } = this.props;
+ const userInfoList = UserInfo.map((user, index, array) => {
+ const infoList = user.userInfo.map((info) => {
+ const key = Object.keys(info)[0];
+ return (
+
+ {key}
+ {info[key]}
+
+ );
+ });
+ if (array.length > 1) {
+ infoList.unshift(
+
+ {`User ${index + 1}`}
+ ,
+ );
+ }
+ return infoList;
+ });
+ return (
+
+
+ {userInfoList}
+
+
+ );
+ }
+
+ render() {
+ const { intl } = this.props;
+ return (
+ Service.handleCloseUserInfo()}
+ >
+ {this.renderUserInfo()}
+
+ );
+ }
+}
+
+UserInfoComponent.propTypes = propTypes;
+
+export default UserInfoComponent;
diff --git a/src/2.7.12/imports/ui/components/user-info/container.jsx b/src/2.7.12/imports/ui/components/user-info/container.jsx
new file mode 100644
index 00000000..2d9c7509
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-info/container.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+import { injectIntl } from 'react-intl';
+import UserInfo from './component';
+
+const UserInfoContainer = props => ;
+
+export default injectIntl(UserInfoContainer);
diff --git a/src/2.7.12/imports/ui/components/user-info/service.js b/src/2.7.12/imports/ui/components/user-info/service.js
new file mode 100644
index 00000000..f561bb9d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-info/service.js
@@ -0,0 +1,7 @@
+import { makeCall } from '/imports/ui/services/api';
+
+export default {
+ handleCloseUserInfo: () => {
+ makeCall('removeUserInformation');
+ },
+};
diff --git a/src/2.7.12/imports/ui/components/user-info/styles.js b/src/2.7.12/imports/ui/components/user-info/styles.js
new file mode 100644
index 00000000..3ead0b64
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-info/styles.js
@@ -0,0 +1,33 @@
+import styled from 'styled-components';
+import { mdPaddingX, borderSize } from '/imports/ui/stylesheets/styled-components/general';
+import { colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
+
+const KeyCell = styled.td`
+ padding: ${mdPaddingX};
+ border: ${borderSize} solid ${colorGrayLighter};
+`;
+
+const ValueCell = styled.td`
+ padding: ${mdPaddingX};
+ border: ${borderSize} solid ${colorGrayLighter};
+`;
+
+const UserInfoTable = styled.table`
+ border: ${borderSize} solid ${colorGrayLighter};
+ border-collapse: collapse;
+ border: none;
+
+ width: 90%;
+ margin: auto;
+
+ table-layout: fixed;
+
+ & > td {
+ word-wrap: break-word;
+ }`;
+
+export default {
+ KeyCell,
+ ValueCell,
+ UserInfoTable,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx b/src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx
new file mode 100644
index 00000000..7cef3fb3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/captions-list-item/component.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from '/imports/ui/components/common/icon/component';
+import CaptionsService from '/imports/ui/components/captions/service';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import { PANELS, ACTIONS } from '/imports/ui/components/layout/enums';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ locale: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ tabIndex: PropTypes.number.isRequired,
+};
+
+const intlMessages = defineMessages({
+ captionLabel: {
+ id: 'app.captions.label',
+ description: 'used for captions button aria label',
+ },
+ captionTitle: {
+ id: 'app.captions.title',
+ description: 'title for the transcription pad button on the sidebar',
+ },
+});
+
+const CaptionsListItem = (props) => {
+ const {
+ intl,
+ locale,
+ name,
+ tabIndex,
+ sidebarContentPanel,
+ layoutContextDispatch,
+ } = props;
+
+ const handleClickToggleCaptions = () => {
+ if (sidebarContentPanel !== PANELS.CAPTIONS) {
+ CaptionsService.setCaptionsLocale(locale);
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CAPTIONS,
+ });
+ } else {
+ const captionsLocale = CaptionsService.getCaptionsLocale();
+ if (captionsLocale !== locale) {
+ CaptionsService.setCaptionsLocale(locale);
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ }
+ }
+ };
+
+ return (
+ {}}
+ >
+
+ {intl.formatMessage(intlMessages.captionTitle)}
+
+ );
+};
+
+CaptionsListItem.propTypes = propTypes;
+
+export default injectIntl(CaptionsListItem);
diff --git a/src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js b/src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js
new file mode 100644
index 00000000..d9cf463a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/captions-list-item/styles.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+
+const ListItem = styled(StyledContent.ListItem)``;
+
+export default {
+ ListItem,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx b/src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx
new file mode 100644
index 00000000..85f7e7b3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/chat-list-item/component.jsx
@@ -0,0 +1,205 @@
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { debounce } from '/imports/utils/debounce';
+import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
+import Styled from './styles';
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+import { ACTIONS, PANELS } from '../../layout/enums';
+import Icon from '/imports/ui/components/common/icon/component';
+
+const DEBOUNCE_TIME = 1000;
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_KEY = CHAT_CONFIG.public_id;
+
+let globalAppplyStateToProps = () => {};
+
+const throttledFunc = debounce(() => {
+ globalAppplyStateToProps();
+}, DEBOUNCE_TIME, { trailing: true, leading: true });
+
+const intlMessages = defineMessages({
+ titlePublic: {
+ id: 'app.chat.titlePublic',
+ description: 'title for public chat',
+ },
+ unreadPlural: {
+ id: 'app.userList.chatListItem.unreadPlural',
+ description: 'singular aria label for new message',
+ },
+ unreadSingular: {
+ id: 'app.userList.chatListItem.unreadSingular',
+ description: 'plural aria label for new messages',
+ },
+});
+
+const propTypes = {
+ chat: PropTypes.shape({
+ userId: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ unreadCounter: PropTypes.number.isRequired,
+ }).isRequired,
+ idChatOpen: PropTypes.string.isRequired,
+ compact: PropTypes.bool.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ tabIndex: PropTypes.number,
+ isPublicChat: PropTypes.func.isRequired,
+ shortcuts: PropTypes.string,
+};
+
+const defaultProps = {
+ shortcuts: '',
+ tabIndex: -1,
+};
+
+const ChatListItem = (props) => {
+ const {
+ chat,
+ activeChatId,
+ idChatOpen,
+ compact,
+ intl,
+ tabIndex,
+ isPublicChat,
+ shortcuts: TOGGLE_CHAT_PUB_AK,
+ sidebarContentIsOpen,
+ sidebarContentPanel,
+ layoutContextDispatch,
+ } = props;
+
+ const chatPanelOpen = sidebarContentIsOpen && sidebarContentPanel === PANELS.CHAT;
+
+ const isCurrentChat = chat.chatId === activeChatId && chatPanelOpen;
+
+ const [stateUreadCount, setStateUreadCount] = useState(0);
+
+ if (chat.unreadCounter !== stateUreadCount && (stateUreadCount < chat.unreadCounter)) {
+ globalAppplyStateToProps = () => {
+ setStateUreadCount(chat.unreadCounter);
+ };
+ throttledFunc();
+ } else if (chat.unreadCounter !== stateUreadCount && (stateUreadCount > chat.unreadCounter)) {
+ setStateUreadCount(chat.unreadCounter);
+ }
+
+ useEffect(() => {
+ if (chat.userId !== PUBLIC_CHAT_KEY && chat.userId === idChatOpen) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: chat.chatId,
+ });
+ }
+ }, [idChatOpen, sidebarContentIsOpen, sidebarContentPanel, chat]);
+
+ const handleClickToggleChat = () => {
+ // Verify if chat panel is open
+
+ if (sidebarContentIsOpen && sidebarContentPanel === PANELS.CHAT) {
+ if (idChatOpen === chat.chatId) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: '',
+ });
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: chat.chatId,
+ });
+ }
+ } else {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: chat.chatId,
+ });
+ }
+ };
+
+ const localizedChatName = isPublicChat(chat)
+ ? intl.formatMessage(intlMessages.titlePublic)
+ : chat.name;
+
+ const arialabel = `${localizedChatName} ${
+ stateUreadCount > 1
+ ? intl.formatMessage(intlMessages.unreadPlural, { 0: stateUreadCount })
+ : intl.formatMessage(intlMessages.unreadSingular)}`;
+
+ return (
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
+ >
+
+
+ {chat.icon
+ ? (
+
+
+
+ ) : (
+
+ {chat.name.toLowerCase().slice(0, 2)}
+
+ )}
+
+
+ {!compact
+ ? (
+
+ {isPublicChat(chat)
+ ? intl.formatMessage(intlMessages.titlePublic) : chat.name}
+
+ ) : null}
+
+ {(stateUreadCount > 0)
+ ? (
+
+
+ {stateUreadCount}
+
+
+ )
+ : null}
+
+
+ );
+};
+
+ChatListItem.propTypes = propTypes;
+ChatListItem.defaultProps = defaultProps;
+
+export default withShortcutHelper(injectIntl(ChatListItem), 'togglePublicChat');
diff --git a/src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx b/src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx
new file mode 100644
index 00000000..680164be
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/chat-list-item/container.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import ChatListItem from './component';
+import { layoutSelect, layoutSelectInput, layoutDispatch } from '../../layout/context';
+import Service from '/imports/ui/components/user-list/service';
+
+const ChatListItemContainer = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const idChatOpen = layoutSelect((i) => i.idChatOpen);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { sidebarContentPanel } = sidebarContent;
+ const sidebarContentIsOpen = sidebarContent.isOpen;
+
+ const { isPublicChat } = Service;
+
+ return (
+
+ );
+};
+
+export default ChatListItemContainer;
diff --git a/src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js b/src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js
new file mode 100644
index 00000000..aebc0b34
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/chat-list-item/styles.js
@@ -0,0 +1,108 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import ContentStyled from '/imports/ui/components/user-list/user-list-content/styles';
+import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+import {
+ lgPaddingY,
+ smPaddingY,
+ borderSize,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorGrayDark,
+ colorOffWhite,
+ listItemBgHover,
+ colorGrayLight,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const ChatListItemLink = styled.div`
+ display: flex;
+ flex-grow: 1;
+ align-items: center;
+ text-decoration: none;
+ width: 100%;
+`;
+
+const ChatIcon = styled.div`
+ flex: 0 0 2.2rem;
+`;
+
+const ChatName = styled.div`
+ display: flex;
+ align-items: center;
+ flex-grow: 1;
+ justify-content: center;
+ width: 50%;
+ padding-right: ${smPaddingY};
+`;
+
+const ChatNameMain = styled.span`
+ margin: 0;
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ font-weight: 400;
+ font-size: ${fontSizeSmall};
+ color: ${colorGrayDark};
+ flex-grow: 1;
+ line-height: 2;
+ text-align: left;
+ padding: 0 0 0 ${lgPaddingY};
+ text-overflow: ellipsis;
+
+ [dir="rtl"] & {
+ text-align: right;
+ padding: 0 ${lgPaddingY} 0 0;
+ }
+
+ ${({ active }) => active && `
+ background-color: ${listItemBgHover};
+ `}
+`;
+
+const ChatListItem = styled(Styled.ListItem)`
+ cursor: pointer;
+ text-decoration: none;
+ flex-grow: 1;
+ line-height: 2;
+ color: ${colorGrayDark};
+ background-color: ${colorOffWhite};
+ padding-top: ${lgPaddingY};
+ padding-bottom: ${lgPaddingY};
+ padding-left: ${lgPaddingY};
+ padding-right: 0;
+ margin-left: ${borderSize};
+ margin-top: ${borderSize};
+ margin-bottom: ${borderSize};
+ margin-right: 0;
+
+ [dir="rtl"] & {
+ padding-left: 0;
+ padding-right: ${lgPaddingY};
+ margin-left: 0;
+ margin-right: ${borderSize};
+ }
+`;
+
+const ChatThumbnail = styled.div`
+ display: flex;
+ flex-flow: column;
+ color: ${colorGrayLight};
+ justify-content: center;
+ font-size: 175%;
+`;
+
+const UnreadMessages = styled(ContentStyled.UnreadMessages)``;
+const UnreadMessagesText = styled(ContentStyled.UnreadMessagesText)``;
+
+export default {
+ ChatListItemLink,
+ ChatIcon,
+ ChatName,
+ ChatNameMain,
+ ChatListItem,
+ ChatThumbnail,
+ UnreadMessages,
+ UnreadMessagesText,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/component.jsx b/src/2.7.12/imports/ui/components/user-list/component.jsx
new file mode 100644
index 00000000..caf0204d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/component.jsx
@@ -0,0 +1,43 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import Styled from './styles';
+import CustomLogo from './custom-logo/component';
+import UserContentContainer from './user-list-content/container';
+
+const propTypes = {
+ compact: PropTypes.bool,
+ CustomLogoUrl: PropTypes.string.isRequired,
+ showBranding: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ compact: false,
+};
+
+class UserList extends PureComponent {
+ render() {
+ const {
+ compact,
+ CustomLogoUrl,
+ showBranding,
+ } = this.props;
+
+ return (
+
+ {
+ showBranding
+ && !compact
+ && CustomLogoUrl
+ ? : null
+ }
+
+
+ );
+ }
+}
+
+UserList.propTypes = propTypes;
+UserList.defaultProps = defaultProps;
+
+export default injectWbResizeEvent(UserList);
diff --git a/src/2.7.12/imports/ui/components/user-list/container.jsx b/src/2.7.12/imports/ui/components/user-list/container.jsx
new file mode 100644
index 00000000..baacd19f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/container.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import Service from '/imports/ui/components/user-list/service';
+import UserList from './component';
+
+const UserListContainer = (props) => ;
+
+export default withTracker(({ compact }) => (
+ {
+ CustomLogoUrl: Service.getCustomLogoUrl(),
+ showBranding: getFromUserSettings('bbb_display_branding_area', Meteor.settings.public.app.branding.displayBrandingArea),
+ }
+))(UserListContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx b/src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx
new file mode 100644
index 00000000..44df7ab1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/custom-logo/component.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import Styled from './styles';
+
+const CustomLogo = props => (
+
+
+
+
+
+
+);
+
+export default CustomLogo;
diff --git a/src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js b/src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js
new file mode 100644
index 00000000..04036cfc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/custom-logo/styles.js
@@ -0,0 +1,28 @@
+import styled from 'styled-components';
+
+import { colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
+import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
+import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+
+const Separator = styled.div`
+ height: 1px;
+ background-color: ${colorGrayLighter};
+ margin-bottom: calc(${lineHeightComputed} * .5);
+`;
+
+const Branding = styled.div`
+ padding: ${smPaddingX};
+ width: 100%;
+ & > img {
+ max-height: 3rem;
+ max-width: 100%;
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+ }
+`;
+
+export default {
+ Separator,
+ Branding,
+}
diff --git a/src/2.7.12/imports/ui/components/user-list/service.js b/src/2.7.12/imports/ui/components/user-list/service.js
new file mode 100644
index 00000000..1f3bdd0e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/service.js
@@ -0,0 +1,842 @@
+import React from 'react';
+import Users from '/imports/api/users';
+import VoiceUsers from '/imports/api/voice-users';
+import GroupChat from '/imports/api/group-chat';
+import Breakouts from '/imports/api/breakouts';
+import Meetings from '/imports/api/meetings';
+import UserReaction from '/imports/api/user-reaction';
+import Auth from '/imports/ui/services/auth';
+import Storage from '/imports/ui/services/storage/session';
+import { EMOJI_STATUSES } from '/imports/utils/statuses';
+import { makeCall } from '/imports/ui/services/api';
+import KEY_CODES from '/imports/utils/keyCodes';
+import AudioService from '/imports/ui/components/audio/service';
+import VideoService from '/imports/ui/components/video-provider/service';
+import UserReactionService from '/imports/ui/components/user-reaction/service';
+import logger from '/imports/startup/client/logger';
+import WhiteboardService from '/imports/ui/components/whiteboard/service';
+import { Session } from 'meteor/session';
+import Settings from '/imports/ui/services/settings';
+import { notify } from '/imports/ui/services/notification';
+import { FormattedMessage } from 'react-intl';
+import { getDateString } from '/imports/utils/string-utils';
+import { indexOf } from '/imports/utils/array-utils';
+import { isEmpty, throttle } from 'radash';
+import ChatService from '/imports/ui/components/chat/service';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
+const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
+const USER_STATUS_ENABLED = Meteor.settings.public.userStatus.enabled;
+
+const DIAL_IN_CLIENT_TYPE = 'dial-in-user';
+
+// session for closed chat list
+const CLOSED_CHAT_LIST_KEY = 'closedChatList';
+// session for chats the current user started
+const STARTED_CHAT_LIST_KEY = 'startedChatList';
+
+const CUSTOM_LOGO_URL_KEY = 'CustomLogoUrl';
+
+export const setCustomLogoUrl = (path) => Storage.setItem(CUSTOM_LOGO_URL_KEY, path);
+
+export const setModeratorOnlyMessage = (msg) => Storage.setItem('ModeratorOnlyMessage', msg);
+
+const getCustomLogoUrl = () => Storage.getItem(CUSTOM_LOGO_URL_KEY);
+
+const sortByWhiteboardAccess = (a, b) => {
+ const _a = a.whiteboardAccess;
+ const _b = b.whiteboardAccess;
+ if (!_b && _a) return -1;
+ if (!_a && _b) return 1;
+ return 0;
+};
+
+const sortUsersByUserId = (a, b) => {
+ if (a.userId > b.userId) {
+ return -1;
+ } if (a.userId < b.userId) {
+ return 1;
+ }
+
+ return 0;
+};
+
+const sortUsersByName = (a, b) => {
+ const aName = a.sortName || '';
+ const bName = b.sortName || '';
+
+ // Extending for sorting strings with non-ASCII characters
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#sorting_non-ascii_characters
+ return aName.localeCompare(bName);
+};
+
+const sortByPropTime = (propName, propTimeName, nullValue, a, b) => {
+ const aObjTime = a[propName] && a[propName] !== nullValue && a[propTimeName]
+ ? a[propTimeName] : Number.MAX_SAFE_INTEGER;
+
+ const bObjTime = b[propName] && b[propName] !== nullValue && b[propTimeName]
+ ? b[propTimeName] : Number.MAX_SAFE_INTEGER;
+
+ if (aObjTime < bObjTime) {
+ return -1;
+ } if (aObjTime > bObjTime) {
+ return 1;
+ }
+ return 0;
+};
+
+const sortUsersByEmoji = (a, b) => sortByPropTime('emoji', 'emojiTime', 'none', a, b);
+const sortUsersByAway = (a, b) => sortByPropTime('away', 'awayTime', false, a, b);
+const sortUsersByRaiseHand = (a, b) => sortByPropTime('raiseHand', 'raiseHandTime', false, a, b);
+const sortUsersByReaction = (a, b) => sortByPropTime('reaction', 'reactionTime', 'none', a, b);
+
+const sortUsersByModerator = (a, b) => {
+ if (a.role === ROLE_MODERATOR && b.role === ROLE_MODERATOR) {
+ return 0;
+ } if (a.role === ROLE_MODERATOR) {
+ return -1;
+ } if (b.role === ROLE_MODERATOR) {
+ return 1;
+ }
+
+ return 0;
+};
+
+const sortUsersByPhoneUser = (a, b) => {
+ if (!a.clientType === DIAL_IN_CLIENT_TYPE && !b.clientType === DIAL_IN_CLIENT_TYPE) {
+ return 0;
+ } if (!a.clientType === DIAL_IN_CLIENT_TYPE) {
+ return -1;
+ } if (!b.clientType === DIAL_IN_CLIENT_TYPE) {
+ return 1;
+ }
+
+ return 0;
+};
+
+// current user's name is always on top
+const sortUsersByCurrent = (a, b) => {
+ if (a.userId === Auth.userID) {
+ return -1;
+ } if (b.userId === Auth.userID) {
+ return 1;
+ }
+
+ return 0;
+};
+
+const sortUsers = (a, b) => {
+ let sort = sortUsersByCurrent(a, b);
+ if (sort === 0) sort = sortUsersByModerator(a, b);
+ if (sort === 0) sort = sortUsersByRaiseHand(a, b);
+ if (sort === 0) sort = sortUsersByAway(a, b);
+ if (sort === 0) sort = sortUsersByReaction(a, b);
+ if (sort === 0) sort = sortUsersByEmoji(a, b);
+ if (sort === 0) sort = sortUsersByPhoneUser(a, b);
+ if (sort === 0) sort = sortByWhiteboardAccess(a, b);
+ if (sort === 0) sort = sortUsersByName(a, b);
+ if (sort === 0) sort = sortUsersByUserId(a, b);
+
+ return sort;
+};
+
+const isPublicChat = (chat) => (
+ chat.userId === PUBLIC_CHAT_ID
+);
+
+const userFindSorting = {
+ emojiTime: 1,
+ role: 1,
+ phoneUser: 1,
+ name: 1,
+ userId: 1,
+};
+
+const addWhiteboardAccess = (users) => {
+ const whiteboardId = WhiteboardService.getCurrentWhiteboardId();
+
+ if (whiteboardId) {
+ const multiUserWhiteboard = WhiteboardService.getMultiUser(whiteboardId);
+ return users.map((user) => {
+ const whiteboardAccess = multiUserWhiteboard.includes(user.userId);
+
+ return {
+ ...user,
+ whiteboardAccess,
+ };
+ });
+ }
+
+ return users.map((user) => {
+ const whiteboardAccess = false;
+ return {
+ ...user,
+ whiteboardAccess,
+ };
+ });
+};
+
+const addIsSharingWebcam = (users) => {
+ const usersId = VideoService.getUsersIdFromVideoStreams();
+
+ return users.map((user) => {
+ const isSharingWebcam = usersId.includes(user.userId);
+
+ return {
+ ...user,
+ isSharingWebcam,
+ };
+ });
+};
+
+const addUserReaction = (users) => {
+ const usersReactions = UserReaction.find({
+ meetingId: Auth.meetingID,
+ }).fetch();
+
+ return users.map((user) => {
+ let reaction = '';
+ const obj = usersReactions.find((us) => us.userId === user.userId);
+ if (obj !== undefined) {
+ ({ reaction } = obj);
+ }
+
+ return {
+ ...user,
+ reaction,
+ };
+ });
+};
+
+// TODO I think this method is no longer used, verify
+const getUsers = () => {
+ let users = Users
+ .find({
+ meetingId: Auth.meetingID,
+ }, userFindSorting)
+ .fetch();
+
+ const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { role: 1, locked: 1 } });
+ if (currentUser && currentUser.role === ROLE_VIEWER && currentUser.locked) {
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'lockSettingsProps.hideUserList': 1 } });
+ if (meeting && meeting.lockSettingsProps && meeting.lockSettingsProps.hideUserList) {
+ const moderatorOrCurrentUser = (u) => u.role === ROLE_MODERATOR || u.userId === Auth.userID;
+ users = users.filter(moderatorOrCurrentUser);
+ }
+ }
+
+ return addIsSharingWebcam(addUserReaction(addWhiteboardAccess(users))).sort(sortUsers);
+};
+
+const formatUsers = (contextUsers, videoUsers, whiteboardUsers, reactionUsers) => {
+ let users = contextUsers.filter((user) => user.loggedOut === false && user.left === false);
+
+ const currentUser = Users.findOne({ userId: Auth.userID }, { fields: { role: 1, locked: 1 } });
+ if (currentUser && currentUser.role === ROLE_VIEWER && currentUser.locked) {
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'lockSettingsProps.hideUserList': 1 } });
+ if (meeting && meeting.lockSettingsProps && meeting.lockSettingsProps.hideUserList) {
+ const moderatorOrCurrentUser = (u) => u.role === ROLE_MODERATOR || u.userId === Auth.userID;
+ users = users.filter(moderatorOrCurrentUser);
+ }
+ }
+
+ return users.map((user) => {
+ const isSharingWebcam = videoUsers?.includes(user.userId);
+ const whiteboardAccess = whiteboardUsers?.includes(user.userId);
+ const reaction = reactionUsers?.includes(user.userId)
+ ? UserReactionService.getUserReaction(user.userId)
+ : { reaction: 'none', reactionTime: 0 };
+
+ return {
+ ...user,
+ isSharingWebcam,
+ whiteboardAccess,
+ ...reaction,
+ };
+ }).sort(sortUsers);
+};
+
+const getUserCount = () => Users.find({ meetingId: Auth.meetingID }).count();
+
+const hasBreakoutRoom = () => Breakouts.find({ parentMeetingId: Auth.meetingID },
+ { fields: {} }).count() > 0;
+
+const isMe = (userId) => userId === Auth.userID;
+
+const getActiveChats = ({ groupChatsMessages, groupChats, users }) => {
+ if (isEmpty(groupChats) && isEmpty(users)) return [];
+
+ const chatIds = Object.keys(groupChats);
+ const lastTimeWindows = chatIds.reduce((acc, chatId) => {
+ const chat = groupChatsMessages[chatId];
+ const lastTimewindowKey = chat?.lastTimewindow;
+ const lastTimeWindow = lastTimewindowKey?.split('-')[1];
+ return {
+ ...acc,
+ chatId: lastTimeWindow,
+ };
+ }, {});
+
+ chatIds.sort((a, b) => {
+ if (a === PUBLIC_GROUP_CHAT_ID) {
+ return -1;
+ }
+
+ if (lastTimeWindows[a] === lastTimeWindows[b]) {
+ return 0;
+ }
+
+ return 1;
+ });
+
+ const chatInfo = chatIds.map((chatId) => {
+ const contextChat = groupChatsMessages[chatId];
+ const isPublicChatId = chatId === PUBLIC_GROUP_CHAT_ID;
+ let unreadMessagesCount = 0;
+ if (contextChat) {
+ const unreadTimewindows = contextChat.unreadTimeWindows;
+ // eslint-disable-next-line
+ for (const unreadTimeWindowId of unreadTimewindows) {
+ const timeWindow = (isPublicChatId
+ ? contextChat?.preJoinMessages[unreadTimeWindowId]
+ || contextChat?.posJoinMessages[unreadTimeWindowId]
+ : contextChat?.messageGroups[unreadTimeWindowId]);
+ unreadMessagesCount += timeWindow.content.length;
+ }
+ }
+
+ if (chatId !== PUBLIC_GROUP_CHAT_ID) {
+ const groupChatsParticipants = groupChats[chatId].participants;
+ const otherParticipant = groupChatsParticipants.filter((user) => user.id !== Auth.userID)[0];
+ const user = users[otherParticipant.id];
+ const startedChats = Session.get(STARTED_CHAT_LIST_KEY) || [];
+
+ return {
+ color: user?.color || '#7b1fa2',
+ isModerator: user?.role === ROLE_MODERATOR,
+ name: user?.name || otherParticipant.name,
+ avatar: user?.avatar,
+ chatId,
+ unreadCounter: unreadMessagesCount,
+ userId: user?.userId || otherParticipant.id,
+ shouldDisplayInChatList: groupChats[chatId].createdBy === Auth.userID
+ || startedChats.includes(chatId)
+ || !!contextChat,
+ };
+ }
+
+ return {
+ userId: PUBLIC_CHAT_ID,
+ name: 'Public Chat',
+ icon: 'group_chat',
+ chatId: PUBLIC_CHAT_ID,
+ unreadCounter: unreadMessagesCount,
+ shouldDisplayInChatList: true,
+ };
+ });
+
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY) || [];
+ const removeClosedChats = chatInfo.filter((chat) => !currentClosedChats.find(closedChat => closedChat.chatId === chat.chatId)
+ && chat.shouldDisplayInChatList);
+ const sortByChatIdAndUnread = removeClosedChats.sort((a, b) => {
+ if (a.chatId === PUBLIC_GROUP_CHAT_ID) {
+ return -1;
+ }
+ if (b.chatId === PUBLIC_CHAT_ID) {
+ return 0;
+ }
+ if (a.unreadCounter > b.unreadCounter) {
+ return -1;
+ } else if (b.unreadCounter > a.unreadCounter) {
+ return 1;
+ } else {
+ if (a.name.toLowerCase() < b.name.toLowerCase()) {
+ return -1;
+ }
+ if (a.name.toLowerCase() > b.name.toLowerCase()) {
+ return 1;
+ }
+ return 0;
+ }
+ });
+ return sortByChatIdAndUnread;
+};
+
+const isVoiceOnlyUser = (userId) => userId.toString().startsWith('v_');
+
+const isMeetingLocked = (id) => {
+ const meeting = Meetings.findOne({ meetingId: id },
+ { fields: { lockSettingsProps: 1, usersProp: 1 } });
+ let isLocked = false;
+
+ if (meeting.lockSettingsProps !== undefined) {
+ const { lockSettingsProps: lockSettings, usersProp } = meeting;
+
+ if (lockSettings.disableCam
+ || lockSettings.disableMic
+ || lockSettings.disablePrivateChat
+ || lockSettings.disablePublicChat
+ || lockSettings.disableNotes
+ || lockSettings.hideUserList
+ || lockSettings.hideViewersCursor
+ || lockSettings.hideViewersAnnotation
+ || usersProp.webcamsOnlyForModerator) {
+ isLocked = true;
+ }
+ }
+
+ return isLocked;
+};
+
+const getUsersProp = () => {
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ {
+ fields: {
+ 'usersProp.allowModsToUnmuteUsers': 1,
+ 'usersProp.allowModsToEjectCameras': 1,
+ 'usersProp.authenticatedGuest': 1,
+ 'usersProp.allowPromoteGuestToModerator': 1,
+ },
+ },
+ );
+
+ if (meeting.usersProp) return meeting.usersProp;
+
+ return {
+ allowModsToUnmuteUsers: false,
+ allowModsToEjectCameras: false,
+ authenticatedGuest: false,
+ allowPromoteGuestToModerator: false,
+ };
+};
+
+const curatedVoiceUser = (intId) => {
+ const voiceUser = VoiceUsers.findOne({ intId });
+ return {
+ isVoiceUser: voiceUser ? voiceUser.joined : false,
+ isMuted: voiceUser ? voiceUser.muted && !voiceUser.listenOnly : false,
+ isTalking: voiceUser ? voiceUser.talking && !voiceUser.muted : false,
+ isListenOnly: voiceUser ? voiceUser.listenOnly : false,
+ };
+};
+
+const getAvailableActions = (
+ amIModerator, isBreakoutRoom, subjectUser, subjectVoiceUser, usersProp, amIPresenter,
+) => {
+ const isDialInUser = isVoiceOnlyUser(subjectUser.userId) || subjectUser.phone_user;
+ const amISubjectUser = isMe(subjectUser.userId);
+ const isSubjectUserModerator = subjectUser.role === ROLE_MODERATOR;
+ const isSubjectUserGuest = subjectUser.guest;
+
+ const hasAuthority = amIModerator || amISubjectUser;
+ const allowedToChatPrivately = !amISubjectUser && !isDialInUser;
+ const allowedToMuteAudio = hasAuthority
+ && subjectVoiceUser.isVoiceUser
+ && !subjectVoiceUser.isMuted
+ && !subjectVoiceUser.isListenOnly;
+
+ const allowedToUnmuteAudio = hasAuthority
+ && subjectVoiceUser.isVoiceUser
+ && !subjectVoiceUser.isListenOnly
+ && subjectVoiceUser.isMuted
+ && (amISubjectUser || usersProp.allowModsToUnmuteUsers);
+
+ const allowedToResetStatus = hasAuthority
+ && subjectUser.emoji !== EMOJI_STATUSES.none
+ && !isDialInUser;
+
+ // if currentUser is a moderator, allow removing other users
+ const allowedToRemove = amIModerator
+ && !amISubjectUser
+ && !isBreakoutRoom;
+
+ const allowedToSetPresenter = amIModerator
+ && !subjectUser.presenter
+ && !isDialInUser;
+
+ const allowedToPromote = amIModerator
+ && !amISubjectUser
+ && !isSubjectUserModerator
+ && !isDialInUser
+ && !isBreakoutRoom
+ && !(isSubjectUserGuest
+ && usersProp.authenticatedGuest
+ && !usersProp.allowPromoteGuestToModerator);
+
+ const allowedToDemote = amIModerator
+ && !amISubjectUser
+ && isSubjectUserModerator
+ && !isDialInUser
+ && !isBreakoutRoom
+ && !(isSubjectUserGuest
+ && usersProp.authenticatedGuest
+ && !usersProp.allowPromoteGuestToModerator);
+
+ const allowedToChangeStatus = amISubjectUser && USER_STATUS_ENABLED;
+
+ const allowedToChangeUserLockStatus = amIModerator
+ && !isSubjectUserModerator
+ && isMeetingLocked(Auth.meetingID);
+
+ const allowedToChangeWhiteboardAccess = amIPresenter
+ && !amISubjectUser;
+
+ const allowedToEjectCameras = amIModerator
+ && !amISubjectUser
+ && usersProp.allowModsToEjectCameras;
+
+ const allowedToSetAway = amISubjectUser && !USER_STATUS_ENABLED;
+
+ return {
+ allowedToChatPrivately,
+ allowedToMuteAudio,
+ allowedToUnmuteAudio,
+ allowedToResetStatus,
+ allowedToRemove,
+ allowedToSetPresenter,
+ allowedToPromote,
+ allowedToDemote,
+ allowedToChangeStatus,
+ allowedToChangeUserLockStatus,
+ allowedToChangeWhiteboardAccess,
+ allowedToEjectCameras,
+ allowedToSetAway,
+ };
+};
+
+const normalizeEmojiName = (emoji) => (
+ emoji in EMOJI_STATUSES ? EMOJI_STATUSES[emoji] : emoji
+);
+
+const setEmojiStatus = throttle({ interval: 1000 }, (userId, emoji) => {
+ const statusAvailable = (Object.keys(EMOJI_STATUSES).includes(emoji));
+ return statusAvailable
+ ? makeCall('setEmojiStatus', Auth.userID, emoji)
+ : makeCall('setEmojiStatus', userId, 'none');
+});
+
+const setUserAway = throttle({ interval: 1000 }, (userId, away) => {
+ return makeCall('changeAway', away);
+}, 250, { leading: false, trailing: true });
+
+const setUserRaiseHand = throttle({ interval: 1000 }, (userId, raiseHand) => {
+ return makeCall('changeRaiseHand', raiseHand);
+}, 250, { leading: false, trailing: true });
+
+const clearAllEmojiStatus = () => {
+ makeCall('clearAllUsersEmoji');
+};
+
+const clearAllReactions = () => {
+ makeCall('clearAllUsersReaction');
+};
+
+const assignPresenter = (userId) => { makeCall('assignPresenter', userId); };
+
+const removeUser = (userId, banUser) => {
+ if (isVoiceOnlyUser(userId)) {
+ makeCall('ejectUserFromVoice', userId, banUser);
+ } else {
+ makeCall('removeUser', userId, banUser);
+ }
+};
+
+const toggleVoice = (userId) => {
+ if (userId === Auth.userID) {
+ AudioService.toggleMuteMicrophone();
+ } else {
+ makeCall('toggleVoice', userId);
+ logger.info({
+ logCode: 'usermenu_option_mute_toggle_audio',
+ extraInfo: { logType: 'moderator_action', userId },
+ }, 'moderator muted user microphone');
+ }
+};
+
+const ejectUserCameras = (userId) => {
+ makeCall('ejectUserCameras', userId);
+};
+
+const getEmoji = () => {
+ const currentUser = Users.findOne({ userId: Auth.userID },
+ { fields: { emoji: 1 } });
+
+ if (!currentUser) {
+ return false;
+ }
+
+ return currentUser.emoji;
+};
+
+const muteAllUsers = (userId) => { makeCall('muteAllUsers', userId); };
+
+const muteAllExceptPresenter = (userId) => { makeCall('muteAllExceptPresenter', userId); };
+
+const changeRole = (userId, role) => { makeCall('changeRole', userId, role); };
+
+const focusFirstDropDownItem = () => {
+ const dropdownContent = document.querySelector('div[data-test="dropdownContent"][style="visibility: visible;"]');
+ if (!dropdownContent) return;
+ const list = dropdownContent.getElementsByTagName('li');
+ list[0].focus();
+};
+
+const roving = (...args) => {
+ const [
+ event,
+ changeState,
+ elementsList,
+ element,
+ ] = args;
+
+ this.selectedElement = element;
+ const numberOfChilds = elementsList.childElementCount;
+ const menuOpen = Session.get('dropdownOpen') || false;
+
+ if (menuOpen) {
+ const menuChildren = document.activeElement.getElementsByTagName('li');
+
+ if ([KEY_CODES.ESCAPE, KEY_CODES.ARROW_LEFT].includes(event.keyCode)) {
+ Session.set('dropdownOpen', false);
+ document.activeElement.click();
+ }
+
+ if ([KEY_CODES.ARROW_UP].includes(event.keyCode)) {
+ menuChildren[menuChildren.length - 1].focus();
+ }
+
+ if ([KEY_CODES.ARROW_DOWN].includes(event.keyCode)) {
+ for (let i = 0; i < menuChildren.length; i += 1) {
+ if (menuChildren[i].hasAttribute('tabIndex')) {
+ menuChildren[i].focus();
+ break;
+ }
+ }
+ }
+
+ return;
+ }
+
+ if ([KEY_CODES.ESCAPE, KEY_CODES.TAB].includes(event.keyCode)) {
+ Session.set('dropdownOpen', false);
+ changeState(null);
+ }
+
+ if (event.keyCode === KEY_CODES.ARROW_DOWN) {
+ const firstElement = elementsList.firstChild;
+ let elRef = element && numberOfChilds > 1 ? element.nextSibling : firstElement;
+
+ elRef = elRef || firstElement;
+ changeState(elRef);
+ }
+
+ if (event.keyCode === KEY_CODES.ARROW_UP) {
+ const lastElement = elementsList.lastChild;
+ let elRef = element ? element.previousSibling : lastElement;
+ elRef = elRef || lastElement;
+ changeState(elRef);
+ }
+
+ if ([KEY_CODES.ARROW_RIGHT, KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event.keyCode)) {
+ const tether = document.activeElement.firstChild;
+ const dropdownTrigger = tether.firstChild;
+ dropdownTrigger?.click();
+ focusFirstDropDownItem();
+ }
+};
+
+const hasPrivateChatBetweenUsers = (senderId, receiverId) => GroupChat
+ .findOne({ users: { $all: [receiverId, senderId] } });
+
+const getGroupChatPrivate = (senderUserId, receiver) => {
+ const chat = hasPrivateChatBetweenUsers(senderUserId, receiver.userId);
+ if (!chat) {
+ makeCall('createGroupChat', receiver);
+ } else {
+ const startedChats = Session.get(STARTED_CHAT_LIST_KEY) || [];
+ if (indexOf(startedChats, chat.chatId) < 0) {
+ startedChats.push(chat.chatId);
+ Session.set(STARTED_CHAT_LIST_KEY, startedChats);
+ }
+
+ const currentClosedChats = Storage.getItem(CLOSED_CHAT_LIST_KEY);
+
+ if (ChatService.isChatClosed(chat.chatId)) {
+ const closedChats = currentClosedChats.filter(closedChat => closedChat.chatId !== chat.chatId);
+ Storage.setItem(CLOSED_CHAT_LIST_KEY,closedChats);
+ }
+ }
+};
+
+const toggleUserChatLock = (userId, isLocked) => {
+ makeCall('toggleUserChatLock', userId, isLocked);
+}
+
+const toggleUserLock = (userId, lockStatus) => {
+ makeCall('toggleUserLock', userId, lockStatus);
+};
+
+const requestUserInformation = (userId) => {
+ makeCall('requestUserInformation', userId);
+};
+
+const sortUsersByFirstName = (a, b) => {
+ const aUser = { sortName: a.firstName ? a.firstName : '' };
+ const bUser = { sortName: b.firstName ? b.firstName : '' };
+
+ return sortUsersByName(aUser, bUser);
+};
+
+const sortUsersByLastName = (a, b) => {
+ const aUser = { sortName: a.lastName ? a.lastName : '' };
+ const bUser = { sortName: b.lastName ? b.lastName : '' };
+
+ return sortUsersByName(aUser, bUser);
+};
+
+const isUserPresenter = (userId = Auth.userID) => {
+ const user = Users.findOne({ userId },
+ { fields: { presenter: 1 } });
+ return user ? user.presenter : false;
+};
+
+export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => {
+ const mimeType = 'text/plain';
+ const userNamesObj = getUsers()
+ .map((u) => {
+ const name = u.name.split(' ');
+ return ({
+ firstName: name[0],
+ middleNames: name.length > 2 ? name.slice(1, name.length - 1) : null,
+ lastName: name.length > 1 ? name[name.length - 1] : null,
+ });
+ });
+
+ const getUsernameString = (user) => {
+ const { firstName, middleNames, lastName } = user;
+ return `${firstName || ''} ${middleNames && middleNames.length > 0 ? middleNames.join(' ') : ''} ${lastName || ''}`;
+ };
+
+ const namesByFirstName = userNamesObj.sort(sortUsersByFirstName)
+ .map((u) => getUsernameString(u)).join('\r\n');
+
+ const namesByLastName = userNamesObj.sort(sortUsersByLastName)
+ .map((u) => getUsernameString(u)).join('\r\n');
+
+ const namesListsString = `${docTitle}\r\n\r\n${fnSortedLabel}\r\n${namesByFirstName}
+ \r\n\r\n${lnSortedLabel}\r\n${namesByLastName}`.replace(/ {2}/g, ' ');
+
+ const link = document.createElement('a');
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'meetingProp.name': 1 } });
+ link.setAttribute('download', `bbb-${meeting.meetingProp.name}[users-list]_${getDateString()}.txt`);
+ link.setAttribute(
+ 'href',
+ `data: ${mimeType};charset=utf-16,${encodeURIComponent(namesListsString)}`,
+ );
+ return link;
+};
+
+const UserJoinedMeetingAlert = (obj) => {
+ const {
+ userJoinAudioAlerts,
+ userJoinPushAlerts,
+ } = Settings.application;
+
+ if (!userJoinAudioAlerts && !userJoinPushAlerts) return;
+
+ if (userJoinAudioAlerts) {
+ AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId}`
+ + '/resources/sounds/userJoin.mp3');
+ }
+
+ if (userJoinPushAlerts) {
+ notify(
+ ,
+ obj.notificationType,
+ obj.icon,
+ );
+ }
+}
+
+const UserLeftMeetingAlert = (obj) => {
+ const {
+ userLeaveAudioAlerts,
+ userLeavePushAlerts,
+ } = Settings.application;
+
+ if (!userLeaveAudioAlerts && !userLeavePushAlerts) return;
+
+ if (userLeaveAudioAlerts) {
+ AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId}`
+ + '/resources/sounds/notify.mp3');
+ }
+
+ if (userLeavePushAlerts) {
+ notify(
+ ,
+ obj.notificationType,
+ obj.icon,
+ );
+ }
+}
+
+export default {
+ sortUsersByName,
+ sortUsers,
+ setEmojiStatus,
+ setUserAway,
+ setUserRaiseHand,
+ clearAllEmojiStatus,
+ clearAllReactions,
+ assignPresenter,
+ removeUser,
+ toggleVoice,
+ muteAllUsers,
+ muteAllExceptPresenter,
+ changeRole,
+ getUsers,
+ formatUsers,
+ getActiveChats,
+ getAvailableActions,
+ curatedVoiceUser,
+ normalizeEmojiName,
+ isMeetingLocked,
+ isPublicChat,
+ roving,
+ getCustomLogoUrl,
+ getGroupChatPrivate,
+ toggleUserChatLock,
+ hasBreakoutRoom,
+ getEmojiList: () => EMOJI_STATUSES,
+ getEmoji,
+ hasPrivateChatBetweenUsers,
+ toggleUserLock,
+ requestUserInformation,
+ focusFirstDropDownItem,
+ isUserPresenter,
+ getUsersProp,
+ getUserCount,
+ sortUsersByCurrent,
+ ejectUserCameras,
+ UserJoinedMeetingAlert,
+ UserLeftMeetingAlert,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/styles.js b/src/2.7.12/imports/ui/components/user-list/styles.js
new file mode 100644
index 00000000..9844368a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/styles.js
@@ -0,0 +1,80 @@
+import styled from 'styled-components';
+
+import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders';
+import {
+ userListBg,
+ userListText,
+ colorGray,
+ listItemBgHover,
+ itemFocusBorder,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { smPaddingX, borderSize } from '/imports/ui/stylesheets/styled-components/general';
+
+const UserList = styled(FlexColumn)`
+ justify-content: flex-start;
+ background-color: ${userListBg};
+ color: ${userListText};
+ height: 100%;
+`;
+
+const SmallTitle = styled.h2`
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ padding: 0 ${smPaddingX};
+ color: ${colorGray};
+ flex: 1;
+ margin: 0;
+`;
+
+const Messages = styled.div`
+ flex-grow: 0;
+ display: flex;
+ flex-flow: column;
+ flex-shrink: 0;
+ max-height: 30vh;
+`;
+
+const ListItem = styled.div`
+ display: flex;
+ flex-flow: row;
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ cursor: pointer;
+
+ [dir="rtl"] & {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ }
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ background-color: ${listItemBgHover};
+ }
+
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ background-color: ${listItemBgHover};
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder};
+ }
+`;
+
+export default {
+ UserList,
+ SmallTitle,
+ ListItem,
+ Messages,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx
new file mode 100644
index 00000000..f1f6f089
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/component.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Icon from '/imports/ui/components/common/icon/component';
+import Styled from './styles';
+import { ACTIONS, PANELS } from '../../../layout/enums';
+import MeetingRemainingTime from '../../../notifications-bar/meeting-remaining-time/container';
+
+const intlMessages = defineMessages({
+ breakoutTitle: {
+ id: 'app.createBreakoutRoom.title',
+ description: 'breakout title',
+ },
+ breakoutTimeRemaining: {
+ id: 'app.createBreakoutRoom.duration',
+ description: 'Message that tells how much time is remaining for the breakout room',
+ },
+});
+
+const BreakoutRoomItem = ({
+ hasBreakoutRoom,
+ breakoutRoom,
+ sidebarContentPanel,
+ layoutContextDispatch,
+ intl,
+}) => {
+ const toggleBreakoutPanel = () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: sidebarContentPanel !== PANELS.BREAKOUT,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: sidebarContentPanel === PANELS.BREAKOUT
+ ? PANELS.NONE
+ : PANELS.BREAKOUT,
+ });
+ };
+
+ if (hasBreakoutRoom) {
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.breakoutTitle)}
+
+
+
+
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ toggleBreakoutPanel();
+ }
+ }}
+ >
+
+
+
+ {intl.formatMessage(intlMessages.breakoutTitle)}
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return ;
+};
+
+export default injectIntl(BreakoutRoomItem);
+
+BreakoutRoomItem.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ hasBreakoutRoom: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx
new file mode 100644
index 00000000..f00b5038
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/container.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import BreakoutRoomItem from './component';
+import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
+import Breakouts from '/imports/api/breakouts';
+import Auth from '/imports/ui/services/auth';
+
+const BreakoutRoomContainer = ({ breakoutRoom }) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+
+ const hasBreakoutRoom = !!breakoutRoom;
+
+ return (
+
+ );
+};
+
+export default withTracker(() => {
+ const breakoutRoom = Breakouts.findOne(
+ { parentMeetingId: Auth.meetingID },
+ { fields: { timeRemaining: 1 } },
+ );
+
+ return {
+ breakoutRoom,
+ };
+})(BreakoutRoomContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js
new file mode 100644
index 00000000..ddf72185
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/breakout-room/styles.js
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+import { colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)``;
+
+const ListItem = styled(StyledContent.ListItem)``;
+
+const BreakoutTitle = styled.div`
+ font-size: ${fontSizeSmall};
+`;
+
+const BreakoutDuration = styled.p`
+ margin: 0;
+ font-size: 0.75rem;
+ font-weight: 200;
+ color: ${colorGray};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-style: italic;
+`;
+
+export default {
+ Messages,
+ Container,
+ SmallTitle,
+ ScrollableList,
+ List,
+ ListItem,
+ BreakoutTitle,
+ BreakoutDuration,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx
new file mode 100644
index 00000000..e6fbb707
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/component.jsx
@@ -0,0 +1,60 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import UserParticipantsContainer from './user-participants/container';
+import UserMessagesContainer from './user-messages/container';
+import UserNotesContainer from './user-notes/container';
+import TimerContainer from './timer/container';
+import UserCaptionsContainer from './user-captions/container';
+import WaitingUsersContainer from './waiting-users/container';
+import UserPollsContainer from './user-polls/container';
+import BreakoutRoomContainer from './breakout-room/container';
+import { isChatEnabled } from '/imports/ui/services/features';
+
+const propTypes = {
+ currentUser: PropTypes.shape({}).isRequired,
+ isTimerActive: PropTypes.bool.isRequired,
+};
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const ALWAYS_SHOW_WAITING_ROOM = Meteor.settings.public.app.alwaysShowWaitingRoomUI;
+
+class UserContent extends PureComponent {
+ render() {
+ const {
+ currentUser,
+ isTimerActive,
+ pendingUsers,
+ isWaitingRoomEnabled,
+ isGuestLobbyMessageEnabled,
+ compact,
+ } = this.props;
+
+ const showWaitingRoom = (ALWAYS_SHOW_WAITING_ROOM && isWaitingRoomEnabled)
+ || pendingUsers.length > 0;
+
+ return (
+
+ {isChatEnabled() ? : null}
+
+
+ { isTimerActive && (
+
+ ) }
+ {showWaitingRoom && currentUser.role === ROLE_MODERATOR
+ ? (
+
+ ) : null}
+
+
+
+
+ );
+ }
+}
+
+UserContent.propTypes = propTypes;
+
+export default UserContent;
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx
new file mode 100644
index 00000000..89441369
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/container.jsx
@@ -0,0 +1,40 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import UserContent from './component';
+import GuestUsers from '/imports/api/guest-users';
+import TimerService from '/imports/ui/components/timer/service';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import WaitingUsersService from '/imports/ui/components/waiting-users/service';
+
+const UserContentContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = {
+ userId: Auth.userID,
+ presenter: users[Auth.meetingID][Auth.userID].presenter,
+ locked: users[Auth.meetingID][Auth.userID].locked,
+ role: users[Auth.meetingID][Auth.userID].role,
+ };
+ const { isGuestLobbyMessageEnabled } = WaitingUsersService;
+
+ return (
+
+ );
+};
+
+export default withTracker(() => ({
+ isTimerActive: TimerService.isActive(),
+ pendingUsers: GuestUsers.find({
+ meetingId: Auth.meetingID,
+ approved: false,
+ denied: false,
+ }).fetch(),
+ isWaitingRoomEnabled: WaitingUsersService.isWaitingRoomEnabled(),
+}))(UserContentContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js
new file mode 100644
index 00000000..8deb290d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/styles.js
@@ -0,0 +1,175 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders';
+import {
+ smPaddingX,
+ lgPaddingY,
+ borderSize,
+ mdPaddingY,
+ mdPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorPrimary,
+ userListBg,
+ colorWhite,
+ colorOffWhite,
+ colorGrayDark,
+ colorGrayLight,
+ colorGrayLighter,
+ listItemBgHover,
+ itemFocusBorder,
+ unreadMessagesBg,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { fontSizeSmall } from '/imports/ui/stylesheets/styled-components/typography';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+
+const Content = styled(FlexColumn)`
+ flex-grow: 1;
+ overflow: hidden;
+`;
+
+const Container = styled.div`
+ display: flex;
+ align-items: center;
+ margin-bottom: ${lgPaddingY};
+ margin-top: ${smPaddingX};
+`;
+
+const ScrollableList = styled(ScrollboxVertical)`
+ background: linear-gradient(${userListBg} 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), ${userListBg} 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ outline: none;
+
+ &:hover {
+ /* Visible in Windows high-contrast themes */
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:focus,
+ &:active {
+ border-radius: none;
+ box-shadow: inset 0 0 1px ${colorPrimary};
+ outline-style: transparent;
+ }
+
+ overflow-x: hidden;
+ padding-top: 1px;
+ padding-right: 1px;
+`;
+
+const List = styled.div`
+ margin: 0 0 1px ${mdPaddingY};
+
+ [dir="rtl"] & {
+ margin: 0 ${mdPaddingY} 1px 0;
+ }
+`;
+
+const ListItem = styled(Styled.ListItem)`
+ align-items: center;
+ cursor: pointer;
+ display: flex;
+ flex-flow: row;
+ flex-grow: 0;
+ flex-shrink: 0;
+ padding-top: ${lgPaddingY};
+ padding-bottom: ${lgPaddingY};
+ padding-left: ${lgPaddingY};
+ text-decoration: none;
+ width: 100%;
+ color: ${colorGrayDark};
+ background-color: ${colorOffWhite};
+
+ [dir="rtl"] & {
+ padding-right: ${lgPaddingY};
+ padding-left: 0;
+ }
+
+ > i {
+ display: flex;
+ font-size: 175%;
+ color: ${colorGrayLight};
+ flex: 0 0 2.2rem;
+ margin-right: ${smPaddingX};
+ [dir="rtl"] & {
+ margin-right: 0;
+ margin-left: ${smPaddingX};
+ }
+ }
+
+ > span {
+ font-weight: 400;
+ font-size: ${fontSizeSmall};
+ color: ${colorGrayDark};
+ position: relative;
+ flex-grow: 1;
+ line-height: 2;
+ text-align: left;
+ padding-left: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+
+ [dir="rtl"] & {
+ text-align: right;
+ padding-right: ${mdPaddingX};
+ }
+ }
+
+ div {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:active {
+ background-color: ${listItemBgHover};
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder};
+ }
+`;
+
+const UnreadMessages = styled(FlexColumn)`
+ justify-content: center;
+ margin-left: auto;
+ [dir="rtl"] & {
+ margin-right: auto;
+ margin-left: 0;
+ }
+`;
+
+const UnreadMessagesText = styled(FlexColumn)`
+ margin: 0;
+ justify-content: center;
+ color: ${colorWhite};
+ line-height: calc(1rem + 1px);
+ padding: 0 0.5rem;
+ text-align: center;
+ border-radius: 0.5rem/50%;
+ font-size: 0.8rem;
+ background-color: ${unreadMessagesBg};
+`;
+
+const Separator = styled.hr`
+ margin: 1rem auto;
+ width: 2.2rem;
+ border: 0;
+ border-top: 1px solid ${colorGrayLighter};
+`;
+
+export default {
+ Content,
+ Container,
+ ScrollableList,
+ List,
+ ListItem,
+ UnreadMessages,
+ UnreadMessagesText,
+ Separator,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx
new file mode 100644
index 00000000..aaf4cb03
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/component.jsx
@@ -0,0 +1,75 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Icon from '/imports/ui/components/common/icon/component';
+import TimerService from '/imports/ui/components/timer/service';
+import Styled from './styles';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ stopwatch: PropTypes.bool.isRequired,
+ sidebarContentPanel: PropTypes.shape().isRequired,
+ layoutContextDispatch: PropTypes.shape().isRequired,
+ isModerator: PropTypes.bool.isRequired,
+};
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.userList.timerTitle',
+ description: 'Title for the time',
+ },
+ timer: {
+ id: 'app.timer.timer.title',
+ description: 'Title for the timer',
+ },
+ stopwatch: {
+ id: 'app.timer.stopwatch.title',
+ description: 'Title for the stopwatch',
+ },
+});
+
+class Timer extends PureComponent {
+ render() {
+ const {
+ intl,
+ isModerator,
+ stopwatch,
+ sidebarContentPanel,
+ layoutContextDispatch,
+ } = this.props;
+
+ if (!isModerator) return null;
+
+ const message = stopwatch ? intlMessages.stopwatch : intlMessages.timer;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+
+
+ TimerService.togglePanel(sidebarContentPanel, layoutContextDispatch)}
+ >
+
+
+ {intl.formatMessage(message)}
+
+
+
+
+
+ );
+ }
+}
+
+Timer.propTypes = propTypes;
+
+export default injectIntl(Timer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx
new file mode 100644
index 00000000..ed864e0f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/container.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import TimerService from '/imports/ui/components/timer/service';
+import Timer from './component';
+import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
+
+const TimerContainer = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+ return ;
+};
+
+export default withTracker(() => ({
+ isModerator: TimerService.isModerator(),
+ stopwatch: TimerService.getStopwatch(),
+}))(TimerContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js
new file mode 100644
index 00000000..d3341890
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/timer/styles.js
@@ -0,0 +1,27 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+
+const ListItem = styled(StyledContent.ListItem)`
+ i{ left: 4px; }
+`;
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)``;
+
+export default {
+ ListItem,
+ Messages,
+ Container,
+ SmallTitle,
+ ScrollableList,
+ List,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx
new file mode 100644
index 00000000..ca517720
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/component.jsx
@@ -0,0 +1,139 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { TransitionGroup, CSSTransition } from 'react-transition-group';
+import CaptionsListItem from '/imports/ui/components/user-list/captions-list-item/component';
+import { defineMessages, injectIntl } from 'react-intl';
+import KEY_CODES from '/imports/utils/keyCodes';
+import Styled from './styles';
+import { findDOMNode } from 'react-dom';
+
+const propTypes = {
+ ownedLocales: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sidebarContentPanel: PropTypes.string.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ roving: PropTypes.func.isRequired,
+};
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.userList.captionsTitle',
+ description: 'Title for the captions list',
+ },
+});
+
+class UserCaptions extends Component {
+ constructor() {
+ super();
+
+ this.state = {
+ selectedCaption: null,
+ };
+
+ this.activeCaptionRefs = [];
+
+ this.changeState = this.changeState.bind(this);
+ this.rove = this.rove.bind(this);
+ }
+
+ componentDidMount() {
+ if (this._captionsList) {
+ this._captionsList.addEventListener(
+ 'keydown',
+ this.rove,
+ true,
+ );
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { selectedCaption } = this.state;
+ if (selectedCaption && selectedCaption !== prevState.selectedCaption) {
+ const { firstChild } = selectedCaption;
+ if (firstChild) firstChild.focus();
+ }
+ }
+
+ changeState(ref) {
+ this.setState({ selectedCaption: ref });
+ }
+
+ rove(event) {
+ const { roving } = this.props;
+ const { selectedCaption } = this.state;
+ const captionItemsRef = findDOMNode(this._captionItems);
+ if ([KEY_CODES.SPACE, KEY_CODES.ENTER].includes(event?.which)) {
+ selectedCaption?.firstChild?.click();
+ } else {
+ roving(event, this.changeState, captionItemsRef, selectedCaption);
+ }
+ event.stopPropagation();
+ }
+
+ renderCaptions() {
+ const {
+ ownedLocales,
+ sidebarContentPanel,
+ layoutContextDispatch,
+ } = this.props;
+
+ let index = -1;
+
+ return ownedLocales.map((ownedLocale) => (
+
+ { this.activeCaptionRefs[index += 1] = node; }}>
+
+
+
+ ));
+ }
+
+ render() {
+ const {
+ intl,
+ ownedLocales,
+ } = this.props;
+
+ if (ownedLocales.length < 1) return null;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+ { this._captionsList = ref; }}
+ >
+
+ { this._captionItems = ref; }}>
+ {this.renderCaptions()}
+
+
+
+
+ );
+ }
+}
+
+UserCaptions.propTypes = propTypes;
+
+export default injectIntl(UserCaptions);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx
new file mode 100644
index 00000000..a9d79e49
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/container.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import UserCaptionsItem from './component';
+import Service from '/imports/ui/components/user-list/service';
+import CaptionsService from '/imports/ui/components/captions/service';
+import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
+
+const Container = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+ const { roving } = Service;
+ return ;
+};
+
+export default withTracker(() => ({
+ ownedLocales: CaptionsService.getOwnedLocales(),
+}))(Container);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js
new file mode 100644
index 00000000..02fcb050
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-captions/styles.js
@@ -0,0 +1,61 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)``;
+
+const ListTransition = styled.div`
+ display: flex;
+ flex-flow: column;
+ margin: 0;
+ padding: 0;
+ padding-top: ${borderSize};
+ outline: none;
+ overflow: hidden;
+ flex-shrink: 1;
+
+ &.transition-enter,
+ &.transition-appear {
+ opacity: 0.01;
+ }
+
+ &.transition-enter-active,
+ &.transition-appear-active {
+ opacity: 1;
+
+ &.animationsEnabled {
+ transition: all 600ms;
+ }
+ }
+
+ &.transition-leave {
+ opacity: 1;
+ }
+
+ &.transition-leave-active {
+ opacity: 0;
+
+ &.animationsEnabled {
+ transition: all 600ms;
+ }
+ }
+`;
+
+export default {
+ Messages,
+ Container,
+ SmallTitle,
+ ScrollableList,
+ List,
+ ListTransition,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx
new file mode 100644
index 00000000..c722e43f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/component.jsx
@@ -0,0 +1,141 @@
+import React, { PureComponent } from 'react';
+import { TransitionGroup, CSSTransition } from 'react-transition-group';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import Styled from './styles';
+import { findDOMNode } from 'react-dom';
+import ChatListItemContainer from '../../chat-list-item/container';
+import { injectIntl } from 'react-intl';
+
+const propTypes = {
+ activeChats: PropTypes.arrayOf(String).isRequired,
+ compact: PropTypes.bool,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ roving: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ compact: false,
+};
+
+const intlMessages = defineMessages({
+ messagesTitle: {
+ id: 'app.userList.messagesTitle',
+ description: 'Title for the messages list',
+ },
+});
+
+class UserMessages extends PureComponent {
+ constructor() {
+ super();
+
+ this.state = {
+ selectedChat: null,
+ };
+
+ this.activeChatRefs = [];
+
+ this.changeState = this.changeState.bind(this);
+ this.rove = this.rove.bind(this);
+ }
+
+ componentDidMount() {
+ const { compact } = this.props;
+ if (!compact) {
+ this._msgsList.addEventListener(
+ 'keydown',
+ this.rove,
+ true,
+ );
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { selectedChat } = this.state;
+ if (selectedChat && selectedChat !== prevState.selectedChat) {
+ const { firstChild } = selectedChat;
+ if (firstChild) firstChild.focus();
+ }
+ }
+
+ getActiveChats() {
+ const {
+ activeChats,
+ compact,
+ } = this.props;
+
+ let index = -1;
+
+ return activeChats.map(chat => (
+
+ { this.activeChatRefs[index += 1] = node; }}>
+
+
+
+ ));
+ }
+
+ changeState(ref) {
+ this.setState({ selectedChat: ref });
+ }
+
+ rove(event) {
+ const { roving } = this.props;
+ const { selectedChat } = this.state;
+ const msgItemsRef = findDOMNode(this._msgItems);
+ roving(event, this.changeState, msgItemsRef, selectedChat);
+ event.stopPropagation();
+ }
+
+ render() {
+ const {
+ intl,
+ compact,
+ } = this.props;
+
+ return (
+
+
+ {
+ !compact ? (
+
+ {intl.formatMessage(intlMessages.messagesTitle)}
+
+ ) : (
+
+ )
+ }
+
+ { this._msgsList = ref; }}
+ >
+
+ { this._msgItems = ref; }}>
+ {this.getActiveChats()}
+
+
+
+
+ );
+ }
+}
+
+UserMessages.propTypes = propTypes;
+UserMessages.defaultProps = defaultProps;
+
+export default injectIntl(UserMessages);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx
new file mode 100644
index 00000000..8af97a5e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/container.jsx
@@ -0,0 +1,35 @@
+import React, { useContext } from 'react';
+import UserMessages from './component';
+import { ChatContext } from '/imports/ui/components/components-data/chat-context/context';
+import { GroupChatContext } from '/imports/ui/components/components-data/group-chat-context/context';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+import Service from '/imports/ui/components/user-list/service';
+import Auth from '/imports/ui/services/auth';
+import { withTracker } from 'meteor/react-meteor-data';
+import Storage from '/imports/ui/services/storage/session';
+import { Session } from 'meteor/session';
+
+const CLOSED_CHAT_LIST_KEY = 'closedChatList';
+const STARTED_CHAT_LIST_KEY = 'startedChatList';
+
+const UserMessagesContainer = () => {
+ const usingChatContext = useContext(ChatContext);
+ const usingUsersContext = useContext(UsersContext);
+ const usingGroupChatContext = useContext(GroupChatContext);
+ const { chats: groupChatsMessages } = usingChatContext;
+ const { users } = usingUsersContext;
+ const { groupChat: groupChats } = usingGroupChatContext;
+ const activeChats = Service.getActiveChats({ groupChatsMessages, groupChats, users:users[Auth.meetingID] });
+ const { roving } = Service;
+
+ return ;
+};
+
+export default withTracker(() => {
+ // Here just to add reactivity to this component.
+ // We need to rerender this component whenever this
+ // Storage variable changes.
+ Storage.getItem(CLOSED_CHAT_LIST_KEY);
+ Session.get(STARTED_CHAT_LIST_KEY);
+ return {};
+})(UserMessagesContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js
new file mode 100644
index 00000000..42e89674
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-messages/styles.js
@@ -0,0 +1,69 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const Separator = styled(StyledContent.Separator)``;
+
+const MessagesTitle = styled(Styled.SmallTitle)`
+ flex: 1;
+ margin: 0;
+`;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)`
+ background-color: var(--color-off-white,#F3F6F9);
+`;
+
+const ListTransition = styled.div`
+ display: flex;
+ flex-flow: column;
+ margin: 0;
+ padding: 0;
+ padding-top: ${borderSize};
+ outline: none;
+ overflow: hidden;
+ flex-shrink: 1;
+
+ &.transition-enter,
+ &.transition-appear {
+ opacity: 0.01;
+ }
+
+ &.transition-enter-active,
+ &.transition-appear-active {
+ opacity: 1;
+
+ &.animationsEnabled {
+ transition: all 600ms;
+ }
+ }
+
+ &.transition-leave {
+ opacity: 1;
+ }
+
+ &.transition-leave-active {
+ opacity: 0;
+
+ &.animationsEnabled {
+ transition: all 600ms;
+ }
+ }
+`;
+
+export default {
+ Messages,
+ Container,
+ Separator,
+ MessagesTitle,
+ ScrollableList,
+ List,
+ ListTransition,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx
new file mode 100644
index 00000000..c6362a57
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/component.jsx
@@ -0,0 +1,199 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Icon from '/imports/ui/components/common/icon/component';
+import NotesService from '/imports/ui/components/notes/service';
+import Styled from './styles';
+import { PANELS } from '/imports/ui/components/layout/enums';
+import { notify } from '/imports/ui/services/notification';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isPinned: PropTypes.bool.isRequired,
+ sidebarContentPanel: PropTypes.string.isRequired,
+};
+
+const intlMessages = defineMessages({
+ title: {
+ id: 'app.userList.notesTitle',
+ description: 'Title for the notes list',
+ },
+ pinnedNotification: {
+ id: 'app.notes.pinnedNotification',
+ description: 'Notification text for pinned shared notes',
+ },
+ sharedNotes: {
+ id: 'app.notes.title',
+ description: 'Title for the shared notes',
+ },
+ sharedNotesPinned: {
+ id: 'app.notes.titlePinned',
+ description: 'Title for the shared notes pinned',
+ },
+ unreadContent: {
+ id: 'app.userList.notesListItem.unreadContent',
+ description: 'Aria label for notes unread content',
+ },
+ locked: {
+ id: 'app.notes.locked',
+ description: '',
+ },
+ byModerator: {
+ id: 'app.userList.byModerator',
+ description: '',
+ },
+ disabled: {
+ id: 'app.notes.disabled',
+ description: 'Aria description for disabled notes button',
+ },
+});
+
+class UserNotes extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ unread: false,
+ pinWasNotified: false,
+ };
+ this.setUnread = this.setUnread.bind(this);
+ this.showTitleAlert = this.showTitleAlert.bind(this);
+ }
+
+ componentDidMount() {
+ this.setUnread(NotesService.hasUnreadNotes());
+ }
+
+ componentDidUpdate(prevProps) {
+ const { sidebarContentPanel, isPinned } = this.props;
+ const { unread } = this.state;
+
+ this.showTitleAlert();
+
+ const notesOpen = sidebarContentPanel === PANELS.SHARED_NOTES && !isPinned;
+ const notesClosed = (prevProps.sidebarContentPanel === PANELS.SHARED_NOTES
+ && sidebarContentPanel !== PANELS.SHARED_NOTES)
+ || (prevProps.isPinned && !isPinned);
+
+ if (notesOpen && unread) {
+ NotesService.markNotesAsRead();
+ this.setUnread(false);
+ } else if (!unread && NotesService.hasUnreadNotes()) {
+ this.setUnread(true);
+ }
+
+ if (notesClosed) {
+ NotesService.markNotesAsRead();
+ this.setUnread(false);
+ }
+ if (prevProps.isPinned && !isPinned) {
+ this.setState({ pinWasNotified: false });
+ }
+ }
+
+ setUnread(unread) {
+ this.setState({ unread });
+ }
+
+ showTitleAlert() {
+ const {
+ intl,
+ isPinned,
+ } = this.props;
+ const { pinWasNotified } = this.state;
+ if (isPinned && !pinWasNotified) {
+ notify(intl.formatMessage(intlMessages.pinnedNotification), 'info', 'copy', { pauseOnFocusLoss: false });
+ this.setState({
+ pinWasNotified: true,
+ });
+ }
+ }
+
+ renderNotes() {
+ const {
+ intl,
+ disableNotes,
+ sidebarContentPanel,
+ layoutContextDispatch,
+ isPinned,
+ } = this.props;
+ const { unread } = this.state;
+
+ let notification = null;
+ if (unread && !isPinned) {
+ notification = (
+
+
+ ···
+
+
+ );
+ }
+
+ const showTitle = isPinned ? intl.formatMessage(intlMessages.sharedNotesPinned)
+ : intl.formatMessage(intlMessages.sharedNotes);
+ return (
+ NotesService.toggleNotesPanel(sidebarContentPanel, layoutContextDispatch)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ NotesService.toggleNotesPanel(sidebarContentPanel, layoutContextDispatch);
+ }
+ }}
+ as={isPinned ? 'button' : 'div'}
+ disabled={isPinned}
+ $disabled={isPinned}
+ >
+
+
+
+ { showTitle }
+
+ {disableNotes
+ ? (
+
+
+ {`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}
+
+ ) : null}
+ {isPinned
+ ? (
+ {`${intl.formatMessage(intlMessages.disabled)}`}
+ ) : null}
+
+ {notification}
+
+ );
+ }
+
+ render() {
+ const { intl } = this.props;
+
+ if (!NotesService.isEnabled()) return null;
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.title)}
+
+
+
+
+ {this.renderNotes()}
+
+
+
+ );
+ }
+}
+
+UserNotes.propTypes = propTypes;
+
+export default injectIntl(UserNotes);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx
new file mode 100644
index 00000000..670ad193
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/container.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import NotesService from '/imports/ui/components/notes/service';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import UserNotes from './component';
+import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
+
+const UserNotesContainer = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+ return ;
+};
+
+export default lockContextContainer(withTracker(({ userLocks }) => {
+ const shouldDisableNotes = userLocks.userNotes;
+ return {
+ unread: NotesService.hasUnreadNotes(),
+ disableNotes: shouldDisableNotes,
+ isPinned: NotesService.isSharedNotesPinned(),
+ };
+})(UserNotesContainer));
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js
new file mode 100644
index 00000000..7ac236ae
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-notes/styles.js
@@ -0,0 +1,56 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+import { colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeSmall,
+ fontSizeSmaller,
+ fontSizeXS,
+} from '/imports/ui/stylesheets/styled-components/typography';
+
+const UnreadMessages = styled(StyledContent.UnreadMessages)``;
+
+const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``;
+
+const ListItem = styled(StyledContent.ListItem)`
+ i{ left: 4px; }
+`;
+
+const NotesTitle = styled.div`
+ font-weight: 400;
+ font-size: ${fontSizeSmall};
+`;
+
+const NotesLock = styled.div`
+ font-weight: 200;
+ font-size: ${fontSizeSmaller};
+ color: ${colorGray};
+
+ > i {
+ font-size: ${fontSizeXS};
+ }
+`;
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)``;
+
+export default {
+ UnreadMessages,
+ UnreadMessagesText,
+ ListItem,
+ NotesTitle,
+ NotesLock,
+ Messages,
+ Container,
+ SmallTitle,
+ ScrollableList,
+ List,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx
new file mode 100644
index 00000000..4d529ed3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/component.jsx
@@ -0,0 +1,279 @@
+import React, { Component } from 'react';
+import { defineMessages } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import { findDOMNode } from 'react-dom';
+import {
+ AutoSizer,
+ CellMeasurer,
+ CellMeasurerCache,
+} from 'react-virtualized';
+import UserListItemContainer from './user-list-item/container';
+import UserOptionsContainer from './user-options/container';
+import Settings from '/imports/ui/services/settings';
+import { injectIntl } from 'react-intl';
+
+const propTypes = {
+ compact: PropTypes.bool,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ currentUser: PropTypes.shape({}),
+ users: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
+ setEmojiStatus: PropTypes.func.isRequired,
+ clearAllEmojiStatus: PropTypes.func.isRequired,
+ clearAllReactions: PropTypes.func.isRequired,
+ roving: PropTypes.func.isRequired,
+ requestUserInformation: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ compact: false,
+ currentUser: null,
+};
+
+const intlMessages = defineMessages({
+ usersTitle: {
+ id: 'app.userList.usersTitle',
+ description: 'Title for the Header',
+ },
+});
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const SKELETON_COUNT = 10;
+
+class UserParticipants extends Component {
+ constructor() {
+ super();
+
+ this.cache = new CellMeasurerCache({
+ fixedWidth: true,
+ keyMapper: () => 1,
+ });
+
+ this.state = {
+ selectedUser: null,
+ isOpen: false,
+ scrollArea: null,
+ };
+
+ this.userRefs = [];
+
+ this.getScrollContainerRef = this.getScrollContainerRef.bind(this);
+ this.rove = this.rove.bind(this);
+ this.changeState = this.changeState.bind(this);
+ this.rowRenderer = this.rowRenderer.bind(this);
+ this.handleClickSelectedUser = this.handleClickSelectedUser.bind(this);
+ this.selectEl = this.selectEl.bind(this);
+ }
+
+ componentDidMount() {
+ document.getElementById('user-list-virtualized-scroll')?.getElementsByTagName('div')[0]?.firstElementChild?.setAttribute('aria-label', 'Users list');
+
+ const { compact } = this.props;
+ if (!compact) {
+ this.refScrollContainer.addEventListener(
+ 'keydown',
+ this.rove,
+ );
+
+ this.refScrollContainer.addEventListener(
+ 'click',
+ this.handleClickSelectedUser,
+ );
+ }
+
+ window.addEventListener('beforeunload', () => Session.set('dropdownOpenUserId', null));
+ }
+
+ shouldComponentUpdate(nextProps) {
+ return nextProps.isReady;
+ }
+
+ selectEl(el) {
+ if (!el) return null;
+ if (typeof el.getAttribute === 'function' && el.getAttribute('tabindex')) return el?.focus();
+ this.selectEl(el?.firstChild);
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ const { selectedUser } = this.state;
+
+ if (selectedUser) {
+ const { firstChild } = selectedUser;
+ if (!firstChild.isEqualNode(document.activeElement)) {
+ this.selectEl(selectedUser);
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ this.refScrollContainer.removeEventListener('keydown', this.rove);
+ this.refScrollContainer.removeEventListener('click', this.handleClickSelectedUser);
+ }
+
+ getScrollContainerRef() {
+ return this.refScrollContainer;
+ }
+
+ rowRenderer({
+ index,
+ parent,
+ style,
+ key,
+ }) {
+ const {
+ compact,
+ setEmojiStatus,
+ setUserAway,
+ users,
+ requestUserInformation,
+ currentUser,
+ meetingIsBreakout,
+ lockSettingsProps,
+ isThisMeetingLocked,
+ } = this.props;
+ const { scrollArea } = this.state;
+ const user = users[index];
+ const isRTL = Settings.application.isRTL;
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ handleClickSelectedUser(event) {
+ let selectedUser = null;
+ if (event.path) {
+ selectedUser = event.path.find(p => p.id && p.id.includes('user-'));
+ }
+ this.setState({ selectedUser });
+ }
+
+ rove(event) {
+ const { roving } = this.props;
+ const { selectedUser, scrollArea } = this.state;
+ const usersItemsRef = findDOMNode(scrollArea.firstChild);
+ event.stopPropagation();
+ roving(event, this.changeState, usersItemsRef, selectedUser);
+ }
+
+ changeState(ref) {
+ this.setState({ selectedUser: ref });
+ }
+
+ render() {
+ const {
+ intl,
+ users,
+ compact,
+ clearAllEmojiStatus,
+ clearAllReactions,
+ currentUser,
+ meetingIsBreakout,
+ isMeetingMuteOnStart,
+ } = this.props;
+ const { isOpen, scrollArea } = this.state;
+
+ return (
+
+ {
+ !compact
+ ? (
+
+
+ {intl.formatMessage(intlMessages.usersTitle)}
+ {users.length > 0 ? ` (${users.length})` : null}
+
+ {currentUser?.role === ROLE_MODERATOR
+ ? (
+
+ ) : null
+ }
+
+
+ )
+ :
+ }
+ {
+ this.refScrollContainer = ref;
+ }}
+ >
+
+
+ {({ height, width }) => (
+ {
+ if (ref !== null) {
+ this.listRef = ref;
+ }
+
+ if (ref !== null && !scrollArea) {
+ this.setState({ scrollArea: findDOMNode(ref) });
+ }
+ }}
+ rowHeight={this.cache.rowHeight}
+ rowRenderer={this.rowRenderer}
+ rowCount={users.length || SKELETON_COUNT}
+ height={height - 1}
+ width={width - 1}
+ overscanRowCount={30}
+ deferredMeasurementCache={this.cache}
+ tabIndex={-1}
+ />
+ )}
+
+
+
+ );
+ }
+}
+
+UserParticipants.propTypes = propTypes;
+UserParticipants.defaultProps = defaultProps;
+
+export default injectIntl(UserParticipants);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx
new file mode 100644
index 00000000..c9f11cc6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/container.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import UserListService from '/imports/ui/components/user-list/service';
+import UserParticipants from './component';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import ChatService from '/imports/ui/components/chat/service';
+import Auth from '/imports/ui/services/auth';
+import useContextUsers from '/imports/ui/components/components-data/users-context/service';
+import VideoService from '/imports/ui/components/video-provider/service';
+import UserReactionService from '/imports/ui/components/user-reaction/service';
+import WhiteboardService from '/imports/ui/components/whiteboard/service';
+import Meetings from '/imports/api/meetings';
+
+const UserParticipantsContainer = (props) => {
+ const {
+ formatUsers,
+ setEmojiStatus,
+ setUserAway,
+ clearAllEmojiStatus,
+ clearAllReactions,
+ roving,
+ requestUserInformation,
+ } = UserListService;
+
+ const { videoUsers, whiteboardUsers, reactionUsers } = props;
+ const { users: contextUsers, isReady } = useContextUsers();
+
+ const currentUser = contextUsers && isReady ? contextUsers[Auth.meetingID][Auth.userID] : null;
+ const usersArray = contextUsers && isReady ? Object.values(contextUsers[Auth.meetingID]) : null;
+ const users = contextUsers && isReady ? formatUsers(usersArray, videoUsers, whiteboardUsers, reactionUsers) : [];
+
+ return (
+
+ );
+};
+
+export default withTracker(() => {
+ ChatService.removePackagedClassAttribute(
+ ['ReactVirtualized__Grid', 'ReactVirtualized__Grid__innerScrollContainer'],
+ 'role',
+ );
+
+ const whiteboardId = WhiteboardService.getCurrentWhiteboardId();
+ const whiteboardUsers = whiteboardId ? WhiteboardService.getMultiUser(whiteboardId) : null;
+ const currentMeeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { lockSettingsProps: 1 } });
+
+ const isMeetingMuteOnStart = () => {
+ const { voiceProp } = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'voiceProp.muteOnStart': 1 } });
+ const { muteOnStart } = voiceProp;
+ return muteOnStart;
+ };
+
+ return ({
+ isMeetingMuteOnStart: isMeetingMuteOnStart(),
+ meetingIsBreakout: meetingIsBreakout(),
+ videoUsers: VideoService.getUsersIdFromVideoStreams(),
+ whiteboardUsers,
+ reactionUsers: UserReactionService.getUsersIdFromUserReaction(),
+ isThisMeetingLocked: UserListService.isMeetingLocked(Auth.meetingID),
+ lockSettingsProps: currentMeeting && currentMeeting.lockSettingsProps,
+ });
+})(UserParticipantsContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js
new file mode 100644
index 00000000..40a358a8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/styles.js
@@ -0,0 +1,82 @@
+import styled from 'styled-components';
+
+import { FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders';
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+import {
+ ScrollboxVertical,
+ VirtualizedScrollboxVertical,
+} from '/imports/ui/stylesheets/styled-components/scrollable';
+import { borderSize, mdPaddingY } from '/imports/ui/stylesheets/styled-components/general';
+import { colorPrimary, userListBg } from '/imports/ui/stylesheets/styled-components/palette';
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const Separator = styled(StyledContent.Separator)``;
+
+const UserListColumn = styled(FlexColumn)`
+ min-height: 0;
+ flex-grow: 1;
+`;
+
+const VirtualizedScrollableList = styled(ScrollboxVertical)`
+ background: linear-gradient(${userListBg} 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), ${userListBg} 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ > div {
+ outline: none;
+ }
+
+ &:hover {
+ /* Visible in Windows high-contrast themes */
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+
+ &:focus {
+ outline: none;
+ }
+
+ &:focus,
+ &:active {
+ box-shadow: inset 0 0 1px ${colorPrimary};
+ border-radius: none;
+ outline-style: transparent;
+ }
+
+ flex-grow: 1;
+ flex-shrink: 1;
+
+ margin: 0 0 1px ${mdPaddingY};
+
+ [dir="rtl"] & {
+ margin: 0 ${mdPaddingY} 1px 0;
+ }
+ margin-left: 0;
+ padding-top: 1px;
+`;
+
+const VirtualizedList = styled(VirtualizedScrollboxVertical)`
+ background: linear-gradient(#f3f6f9 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), #f3f6f9 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ outline: none;
+`;
+
+export default {
+ Container,
+ SmallTitle,
+ Separator,
+ UserListColumn,
+ VirtualizedScrollableList,
+ VirtualizedList,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx
new file mode 100644
index 00000000..91fe487d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx
@@ -0,0 +1,964 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import TooltipContainer from '/imports/ui/components/common/tooltip/container';
+import { Session } from 'meteor/session';
+import { findDOMNode } from 'react-dom';
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+import Icon from '/imports/ui/components/common/icon/component';
+import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
+import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component';
+import VideoService from '/imports/ui/components/video-provider/service';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Styled from './styles';
+import { PANELS, ACTIONS } from '../../../../layout/enums';
+import WhiteboardService from '/imports/ui/components/whiteboard/service';
+import { isChatEnabled } from '/imports/ui/services/features';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import 'react-loading-skeleton/dist/skeleton.css';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const messages = defineMessages({
+ presenter: {
+ id: 'app.userList.presenter',
+ description: 'Text for identifying presenter user',
+ },
+ you: {
+ id: 'app.userList.you',
+ description: 'Text for identifying your user',
+ },
+ locked: {
+ id: 'app.userList.locked',
+ description: 'Text for identifying locked user',
+ },
+ menuTitleContext: {
+ id: 'app.userList.menuTitleContext',
+ description: 'adds context to userListItem menu title',
+ },
+ userAriaLabel: {
+ id: 'app.userList.userAriaLabel',
+ description: 'aria label for each user in the userlist',
+ },
+ statusTriggerLabel: {
+ id: 'app.actionsBar.emojiMenu.statusTriggerLabel',
+ description: 'label for option to show emoji menu',
+ },
+ backTriggerLabel: {
+ id: 'app.audio.backLabel',
+ description: 'label for option to hide emoji menu',
+ },
+ lockPublicChat: {
+ id: 'app.userList.menu.lockPublicChat.label',
+ description: 'label for option to lock user\'s public chat',
+ },
+ unlockPublicChat: {
+ id: 'app.userList.menu.unlockPublicChat.label',
+ description: 'label for option to lock user\'s public chat',
+ },
+ StartPrivateChat: {
+ id: 'app.userList.menu.chat.label',
+ description: 'label for option to start a new private chat',
+ },
+ PinUserWebcam: {
+ id: 'app.userList.menu.webcamPin.label',
+ description: 'label for pin user webcam',
+ },
+ UnpinUserWebcam: {
+ id: 'app.userList.menu.webcamUnpin.label',
+ description: 'label for pin user webcam',
+ },
+ ClearStatusLabel: {
+ id: 'app.userList.menu.clearStatus.label',
+ description: 'Clear the emoji status of this user',
+ },
+ takePresenterLabel: {
+ id: 'app.actionsBar.actionsDropdown.takePresenter',
+ description: 'Set this user to be the presenter in this meeting',
+ },
+ makePresenterLabel: {
+ id: 'app.userList.menu.makePresenter.label',
+ description: 'label to make another user presenter',
+ },
+ giveWhiteboardAccess: {
+ id: 'app.userList.menu.giveWhiteboardAccess.label',
+ description: 'label to give user whiteboard access',
+ },
+ removeWhiteboardAccess: {
+ id: 'app.userList.menu.removeWhiteboardAccess.label',
+ description: 'label to remove user whiteboard access',
+ },
+ ejectUserCamerasLabel: {
+ id: 'app.userList.menu.ejectUserCameras.label',
+ description: 'label to eject user cameras',
+ },
+ RemoveUserLabel: {
+ id: 'app.userList.menu.removeUser.label',
+ description: 'Forcefully remove this user from the meeting',
+ },
+ MuteUserAudioLabel: {
+ id: 'app.userList.menu.muteUserAudio.label',
+ description: 'Forcefully mute this user',
+ },
+ UnmuteUserAudioLabel: {
+ id: 'app.userList.menu.unmuteUserAudio.label',
+ description: 'Forcefully unmute this user',
+ },
+ PromoteUserLabel: {
+ id: 'app.userList.menu.promoteUser.label',
+ description: 'Forcefully promote this viewer to a moderator',
+ },
+ DemoteUserLabel: {
+ id: 'app.userList.menu.demoteUser.label',
+ description: 'Forcefully demote this moderator to a viewer',
+ },
+ UnlockUserLabel: {
+ id: 'app.userList.menu.unlockUser.label',
+ description: 'Unlock individual user',
+ },
+ LockUserLabel: {
+ id: 'app.userList.menu.lockUser.label',
+ description: 'Lock a unlocked user',
+ },
+ DirectoryLookupLabel: {
+ id: 'app.userList.menu.directoryLookup.label',
+ description: 'Directory lookup',
+ },
+ yesLabel: {
+ id: 'app.confirmationModal.yesLabel',
+ description: 'confirm button label',
+ },
+ noLabel: {
+ id: 'app.endMeeting.noLabel',
+ description: 'cancel confirm button label',
+ },
+ removeConfirmTitle: {
+ id: 'app.userList.menu.removeConfirmation.label',
+ description: 'title for remove user confirmation modal',
+ },
+ removeConfirmDesc: {
+ id: 'app.userlist.menu.removeConfirmation.desc',
+ description: 'description for remove user confirmation',
+ },
+ moderator: {
+ id: 'app.userList.moderator',
+ description: 'Text for identifying moderator user',
+ },
+ mobile: {
+ id: 'app.userList.mobile',
+ description: 'Text for identifying mobile user',
+ },
+ guest: {
+ id: 'app.userList.guest',
+ description: 'Text for identifying guest user',
+ },
+ sharingWebcam: {
+ id: 'app.userList.sharingWebcam',
+ description: 'Text for identifying who is sharing webcam',
+ },
+ breakoutRoom: {
+ id: 'app.createBreakoutRoom.room',
+ description: 'breakout room',
+ },
+ awayLabel: {
+ id: 'app.userList.menu.away',
+ description: 'Text for identifying away user',
+ },
+ notAwayLabel: {
+ id: 'app.userList.menu.notAway',
+ description: 'Text for identifying not away user',
+ },
+});
+
+const propTypes = {
+ compact: PropTypes.bool.isRequired,
+ user: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ pin: PropTypes.bool.isRequired,
+ }),
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ getAvailableActions: PropTypes.func.isRequired,
+ isThisMeetingLocked: PropTypes.bool.isRequired,
+ normalizeEmojiName: PropTypes.func.isRequired,
+ getScrollContainerRef: PropTypes.func.isRequired,
+ toggleUserLock: PropTypes.func.isRequired,
+ toggleUserChatLock: PropTypes.func.isRequired,
+ isMeteorConnected: PropTypes.bool.isRequired,
+ isMe: PropTypes.func.isRequired,
+};
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const LABEL = Meteor.settings.public.user.label;
+
+class UserListItem extends PureComponent {
+ /**
+ * Return true if the content fit on the screen, false otherwise.
+ *
+ * @param {number} contentOffSetTop
+ * @param {number} contentOffsetHeight
+ * @return True if the content fit on the screen, false otherwise.
+ */
+ static checkIfDropdownIsVisible(contentOffSetTop, contentOffsetHeight) {
+ return (contentOffSetTop + contentOffsetHeight) < window.innerHeight;
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isActionsOpen: false,
+ dropdownVisible: false,
+ showNestedOptions: false,
+ selected: false,
+ isConfirmationModalOpen: false,
+ };
+
+ this.handleScroll = this.handleScroll.bind(this);
+ this.onActionsShow = this.onActionsShow.bind(this);
+ this.onActionsHide = this.onActionsHide.bind(this);
+ this.getDropdownMenuParent = this.getDropdownMenuParent.bind(this);
+ this.renderUserAvatar = this.renderUserAvatar.bind(this);
+ this.resetMenuState = this.resetMenuState.bind(this);
+ this.setConfirmationModalIsOpen = this.setConfirmationModalIsOpen.bind(this);
+
+ this.title = uniqueId('dropdown-title-');
+ this.seperator = uniqueId('action-separator-');
+ }
+
+ componentDidUpdate() {
+ const { user, selectedUserId } = this.props;
+ const { selected } = this.state;
+ if (selectedUserId === user?.userId && !selected) {
+ this.setState({ selected: true });
+ } else if (selectedUserId !== user?.userId && selected) {
+ this.setState({ selected: false });
+ }
+ }
+
+ handleScroll() {
+ this.setState({
+ isActionsOpen: false,
+ showNestedOptions: false,
+ });
+ }
+
+ handleClose() {
+ this.setState({ selected: null });
+ }
+
+ onActionsShow() {
+ Session.set('dropdownOpen', true);
+ const { getScrollContainerRef } = this.props;
+ const dropdown = this.getDropdownMenuParent();
+ const scrollContainer = getScrollContainerRef();
+
+ if (dropdown && scrollContainer) {
+ // eslint-disable-next-line react/no-find-dom-node
+ const list = findDOMNode(this.list);
+ const children = [].slice.call(list.children);
+ children.find((child) => child.getAttribute('role') === 'menuitem').focus();
+
+ this.setState({
+ isActionsOpen: true,
+ dropdownVisible: false,
+ });
+
+ scrollContainer.addEventListener('scroll', this.handleScroll, false);
+ }
+ }
+
+ onActionsHide(callback) {
+ const { getScrollContainerRef } = this.props;
+
+ this.setState({
+ isActionsOpen: false,
+ dropdownVisible: false,
+ showNestedOptions: false,
+ });
+
+ const scrollContainer = getScrollContainerRef();
+ scrollContainer.removeEventListener('scroll', this.handleScroll, false);
+
+ if (callback) {
+ return callback;
+ }
+
+ return Session.set('dropdownOpen', false);
+ }
+
+ getUsersActions() {
+ const {
+ intl,
+ currentUser,
+ user,
+ voiceUser,
+ getAvailableActions,
+ getGroupChatPrivate,
+ toggleUserChatLock,
+ getEmojiList,
+ setEmojiStatus,
+ setUserAway,
+ assignPresenter,
+ removeUser,
+ toggleVoice,
+ changeRole,
+ ejectUserCameras,
+ lockSettingsProps,
+ hasPrivateChatBetweenUsers,
+ toggleUserLock,
+ requestUserInformation,
+ isMeteorConnected,
+ userLocks,
+ isMe,
+ meetingIsBreakout,
+ usersProp,
+ layoutContextDispatch,
+ } = this.props;
+ const { showNestedOptions } = this.state;
+
+ if (!user) return [];
+
+ const { clientType, isSharingWebcam, pin: userIsPinned } = user;
+ const isDialInUser = clientType === 'dial-in-user';
+
+ const amIPresenter = currentUser.presenter;
+ const amIModerator = currentUser.role === ROLE_MODERATOR;
+ const actionPermissions = getAvailableActions(
+ amIModerator, meetingIsBreakout, user, voiceUser, usersProp, amIPresenter,
+ );
+
+ const {
+ allowedToChatPrivately,
+ allowedToMuteAudio,
+ allowedToUnmuteAudio,
+ allowedToResetStatus,
+ allowedToRemove,
+ allowedToSetPresenter,
+ allowedToPromote,
+ allowedToDemote,
+ allowedToChangeStatus,
+ allowedToChangeUserLockStatus,
+ allowedToChangeWhiteboardAccess,
+ allowedToEjectCameras,
+ allowedToSetAway,
+ } = actionPermissions;
+
+ const { disablePrivateChat } = lockSettingsProps;
+
+ const enablePrivateChat = currentUser.role === ROLE_MODERATOR
+ ? allowedToChatPrivately
+ : allowedToChatPrivately
+ && (!(currentUser.locked && disablePrivateChat)
+ || hasPrivateChatBetweenUsers(currentUser.userId, user.userId)
+ || user.role === ROLE_MODERATOR) && isMeteorConnected;
+
+ const { allowUserLookup } = Meteor.settings.public.app;
+ const userLocked = user.locked && user.role !== ROLE_MODERATOR;
+ const userChatLocked = user.chatLocked;
+
+ const availableActions = [
+ {
+ allowed: allowedToChangeStatus && !showNestedOptions && isMeteorConnected,
+ key: 'setstatus',
+ label: intl.formatMessage(messages.statusTriggerLabel),
+ onClick: () => this.setState({ showNestedOptions: true }),
+ icon: 'user',
+ iconRight: 'right_arrow',
+ dataTest: 'setStatus',
+ },
+ {
+ allowed: showNestedOptions && isMeteorConnected && allowedToChangeStatus,
+ key: 'back',
+ label: intl.formatMessage(messages.backTriggerLabel),
+ onClick: () => this.setState({ showNestedOptions: false }),
+ icon: 'left_arrow',
+ divider: true,
+ },
+ {
+ allowed: isSharingWebcam
+ && isMeteorConnected
+ && VideoService.isVideoPinEnabledForCurrentUser()
+ && !showNestedOptions,
+ key: 'pinVideo',
+ label: userIsPinned
+ ? intl.formatMessage(messages.UnpinUserWebcam)
+ : intl.formatMessage(messages.PinUserWebcam),
+ onClick: () => {
+ VideoService.toggleVideoPin(user.userId, userIsPinned);
+ },
+ icon: userIsPinned ? 'pin-video_off' : 'pin-video_on',
+ },
+ {
+ allowed: isChatEnabled()
+ && enablePrivateChat
+ && !isDialInUser
+ && !meetingIsBreakout
+ && isMeteorConnected
+ && !showNestedOptions,
+ key: 'activeChat',
+ label: intl.formatMessage(messages.StartPrivateChat),
+ onClick: () => {
+ this.handleClose();
+ getGroupChatPrivate(currentUser.userId, user);
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_ID_CHAT_OPEN,
+ value: user.userId,
+ });
+ },
+ icon: 'chat',
+ dataTest: 'startPrivateChat',
+ },
+ {
+ allowed: isChatEnabled()
+ && user.role !== ROLE_MODERATOR
+ && currentUser.role === ROLE_MODERATOR
+ && !isDialInUser
+ && isMeteorConnected,
+ key: 'lockChat',
+ label: userChatLocked ? intl.formatMessage(messages.unlockPublicChat) : intl.formatMessage(messages.lockPublicChat),
+ onClick: () => {
+ this.handleClose();
+ toggleUserChatLock(user.userId, !userChatLocked);
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: true,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.CHAT,
+ });
+ },
+ icon: userChatLocked ? 'unlock' : 'lock',
+ dataTest: 'togglePublicChat',
+ },
+ {
+ allowed: allowedToResetStatus
+ && user.emoji !== 'none'
+ && isMeteorConnected
+ && !showNestedOptions,
+ key: 'clearStatus',
+ label: intl.formatMessage(messages.ClearStatusLabel),
+ onClick: () => {
+ this.onActionsHide(setEmojiStatus(user.userId, 'none'));
+ this.handleClose();
+ },
+ icon: 'clear_status',
+ },
+ {
+ allowed: allowedToMuteAudio
+ && isMeteorConnected
+ && !meetingIsBreakout
+ && !showNestedOptions,
+ key: 'mute',
+ label: intl.formatMessage(messages.MuteUserAudioLabel),
+ onClick: () => {
+ this.onActionsHide(toggleVoice(user.userId));
+ this.handleClose();
+ },
+ icon: 'mute',
+ },
+ {
+ allowed: allowedToUnmuteAudio
+ && !userLocks.userMic
+ && isMeteorConnected
+ && !meetingIsBreakout
+ && !showNestedOptions,
+ key: 'unmute',
+ label: intl.formatMessage(messages.UnmuteUserAudioLabel),
+ onClick: () => {
+ this.onActionsHide(toggleVoice(user.userId));
+ this.handleClose();
+ },
+ icon: 'unmute',
+ dataTest: 'unmuteUser'
+ },
+ {
+ allowed: allowedToChangeWhiteboardAccess
+ && !user.presenter
+ && isMeteorConnected
+ && !isDialInUser
+ && !showNestedOptions,
+ key: 'changeWhiteboardAccess',
+ label: user.whiteboardAccess
+ ? intl.formatMessage(messages.removeWhiteboardAccess)
+ : intl.formatMessage(messages.giveWhiteboardAccess),
+ onClick: () => {
+ WhiteboardService.changeWhiteboardAccess(user.userId, !user.whiteboardAccess);
+ this.handleClose();
+ },
+ icon: 'pen_tool',
+ dataTest: 'changeWhiteboardAccess',
+ },
+ {
+ allowed: allowedToSetPresenter && isMeteorConnected && !isDialInUser && !showNestedOptions,
+ key: 'setPresenter',
+ label: isMe(user.userId)
+ ? intl.formatMessage(messages.takePresenterLabel)
+ : intl.formatMessage(messages.makePresenterLabel),
+ onClick: () => {
+ this.onActionsHide(assignPresenter(user.userId));
+ this.handleClose();
+ },
+ icon: 'presentation',
+ dataTest: isMe(user.userId) ? 'takePresenter' : 'makePresenter',
+ },
+ {
+ allowed: allowedToPromote && isMeteorConnected && !showNestedOptions,
+ key: 'promote',
+ label: intl.formatMessage(messages.PromoteUserLabel),
+ onClick: () => {
+ this.onActionsHide(changeRole(user.userId, 'MODERATOR'));
+ this.handleClose();
+ },
+ icon: 'promote',
+ dataTest: 'promoteToModerator',
+ },
+ {
+ allowed: allowedToDemote && isMeteorConnected && !showNestedOptions,
+ key: 'demote',
+ label: intl.formatMessage(messages.DemoteUserLabel),
+ onClick: () => {
+ this.onActionsHide(changeRole(user.userId, 'VIEWER'));
+ this.handleClose();
+ },
+ icon: 'user',
+ dataTest: 'demoteToViewer',
+ },
+ {
+ allowed: allowedToChangeUserLockStatus && isMeteorConnected && !showNestedOptions,
+ key: 'unlockUser',
+ label: userLocked ? intl.formatMessage(messages.UnlockUserLabel, { 0: user.name })
+ : intl.formatMessage(messages.LockUserLabel, { 0: user.name }),
+ onClick: () => {
+ this.onActionsHide(toggleUserLock(user.userId, !userLocked));
+ this.handleClose();
+ },
+ icon: userLocked ? 'unlock' : 'lock',
+ dataTest: 'unlockUserButton',
+ },
+ {
+ allowed: allowUserLookup && isMeteorConnected && !showNestedOptions,
+ key: 'directoryLookup',
+ label: intl.formatMessage(messages.DirectoryLookupLabel),
+ onClick: () => {
+ this.onActionsHide(requestUserInformation(user.extId));
+ this.handleClose();
+ },
+ icon: 'user',
+ },
+ {
+ allowed: allowedToRemove && isMeteorConnected && !showNestedOptions,
+ key: 'remove',
+ label: intl.formatMessage(messages.RemoveUserLabel, { 0: user.name }),
+ onClick: () => {
+ this.onActionsHide(
+ this.setConfirmationModalIsOpen(true),
+ );
+
+ this.handleClose();
+ },
+ icon: 'circle_close',
+ dataTest: 'removeUser'
+ },
+ {
+ allowed: allowedToEjectCameras
+ && user.isSharingWebcam
+ && isMeteorConnected
+ && !meetingIsBreakout
+ && !showNestedOptions,
+ key: 'ejectUserCameras',
+ label: intl.formatMessage(messages.ejectUserCamerasLabel),
+ onClick: () => {
+ this.onActionsHide(ejectUserCameras(user.userId));
+ this.handleClose();
+ },
+ icon: 'video_off',
+ dataTest: 'ejectCamera'
+ },
+ {
+ allowed: allowedToSetAway
+ && isMeteorConnected,
+ key: 'setAway',
+ label: intl.formatMessage(user.away ? messages.notAwayLabel : messages.awayLabel),
+ onClick: () => {
+ this.onActionsHide(setUserAway(user.userId, !user.away));
+ this.handleClose();
+ },
+ icon: 'time',
+ },
+ ];
+
+ const statuses = Object.keys(getEmojiList);
+
+ statuses.forEach((s) => {
+ availableActions.push({
+ allowed: showNestedOptions && isMeteorConnected,
+ key: s,
+ label: intl.formatMessage({ id: `app.actionsBar.emojiMenu.${s}Label` }),
+ onClick: () => {
+ setEmojiStatus(user.userId, s);
+ this.resetMenuState();
+ this.handleClose();
+ },
+ icon: getEmojiList[s],
+ dataTest: s,
+ });
+ });
+
+ return availableActions.filter((action) => action.allowed);
+ }
+
+ getDropdownMenuParent() {
+ // eslint-disable-next-line react/no-find-dom-node
+ return findDOMNode(this.dropdown);
+ }
+
+ resetMenuState() {
+ return this.setState({
+ isActionsOpen: false,
+ dropdownVisible: false,
+ showNestedOptions: false,
+ selected: false,
+ });
+ }
+
+ /**
+ * Check if the dropdown is visible, if so, check if should be draw on top or bottom direction.
+ */
+ checkDropdownDirection() {
+ if (this.isDropdownActivedByUser()) {
+ const nextState = {
+ dropdownVisible: true,
+ };
+
+ this.setState(nextState);
+ }
+ }
+
+ /**
+ * Check if the dropdown is visible and is opened by the user
+ *
+ * @return True if is visible and opened by the user
+ */
+ isDropdownActivedByUser() {
+ const { isActionsOpen, dropdownVisible } = this.state;
+
+ return isActionsOpen && !dropdownVisible;
+ }
+
+ renderUserAvatar() {
+ const {
+ normalizeEmojiName,
+ user,
+ userInBreakout,
+ breakoutSequence,
+ meetingIsBreakout,
+ voiceUser,
+ isReactionsEnabled,
+ } = this.props;
+
+ const emojiProps = {
+ size: '1.3rem',
+ };
+
+ const userAvatarFiltered = (user.raiseHand === true || user.away === true || user.reaction !== 'none')
+ ? ''
+ : user.avatar;
+
+ const emojiIcons = [
+ {
+ id: 'hand',
+ native: '✋',
+ },
+ {
+ id: 'clock7',
+ native: '⏰',
+ },
+ ];
+
+ const getIconUser = () => {
+ if (user.raiseHand === true) {
+ return isReactionsEnabled
+ ?
+ : ;
+ } if (user.away === true) {
+ return isReactionsEnabled
+ ?
+ : ;
+ } if (user.emoji !== 'none' && user.emoji !== 'notAway') {
+ return ;
+ } if (user.reaction !== 'none') {
+ return user.reaction;
+ } if (user.name) {
+ return user.name.toLowerCase().slice(0, 2);
+ } return '??';
+ };
+
+ const { clientType } = user;
+ const isVoiceOnly = clientType === 'dial-in-user';
+
+ const iconUser = getIconUser();
+ const iconVoiceOnlyUser = ( );
+ const userIcon = isVoiceOnly ? iconVoiceOnlyUser : iconUser;
+
+ return (
+
+ {
+ userInBreakout
+ && !meetingIsBreakout
+ ? breakoutSequence : userIcon
+ }
+
+ );
+ }
+
+ setConfirmationModalIsOpen(value) {
+ this.setState({
+ isConfirmationModalOpen: value,
+ });
+ }
+
+ render() {
+ const {
+ compact,
+ user,
+ intl,
+ isThisMeetingLocked,
+ userInBreakout,
+ userLastBreakout,
+ isMe,
+ isRTL,
+ selectedUserId,
+ removeUser,
+ } = this.props;
+
+ const {
+ isActionsOpen,
+ selected,
+ isConfirmationModalOpen
+ } = this.state;
+
+ if (!user) return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+
+ const actions = this.getUsersActions();
+
+ const you = isMe(user.userId) ? intl.formatMessage(messages.you) : '';
+
+ const presenter = (user.presenter)
+ ? intl.formatMessage(messages.presenter)
+ : '';
+
+ const userAriaLabel = intl.formatMessage(
+ messages.userAriaLabel,
+ {
+ 0: user.name,
+ 1: presenter,
+ 2: you,
+ 3: user.emoji,
+ },
+ );
+
+ const userNameSub = [];
+
+ if (user.isSharingWebcam && LABEL.sharingWebcam) {
+ userNameSub.push(
+
+ { user.pin === true
+ ?
+ : }
+
+ {intl.formatMessage(messages.sharingWebcam)}
+ ,
+ );
+ }
+
+ if (((isThisMeetingLocked && user.locked) || user.chatLocked) && user.role !== ROLE_MODERATOR) {
+ userNameSub.push(
+
+
+
+ {intl.formatMessage(messages.locked)}
+ ,
+ );
+ }
+
+ if (user.role === ROLE_MODERATOR) {
+ if (LABEL.moderator) userNameSub.push(intl.formatMessage(messages.moderator));
+ }
+
+ if (user.mobile) {
+ if (LABEL.mobile) userNameSub.push(intl.formatMessage(messages.mobile));
+ }
+
+ if (user.guest) {
+ if (LABEL.guest) userNameSub.push(intl.formatMessage(messages.guest));
+ }
+
+ if (userInBreakout && userLastBreakout) {
+ userNameSub.push(
+
+
+
+ {userLastBreakout.isDefaultName
+ ? intl.formatMessage(messages.breakoutRoom, { 0: userLastBreakout.sequence })
+ : userLastBreakout.shortName}
+ ,
+ );
+ }
+
+ const innerContents = (
+
+
+ {this.renderUserAvatar()}
+
+ {!compact
+ ? (
+
+
+
+
+ {user.name}
+
+
+
+ {(isMe(user.userId)) ? `(${intl.formatMessage(messages.you)})` : ''}
+
+ {
+ userNameSub.length
+ ? (
+
+ {userNameSub.reduce((prev, curr) => [prev, ' | ', curr])}
+
+ )
+ : null
+ }
+
+ )
+ : null}
+
+ );
+
+ const contents = !actions.length
+ ? (
+
+ {innerContents}
+
+ )
+ : (
+
+ {innerContents}
+
+ );
+
+ if (!actions.length) return contents;
+
+ return (
+ <>
+ this.setState({ selected: true }, () => Session.set('dropdownOpenUserId', user.userId))}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ this.setState({ selected: true }, () => Session.set('dropdownOpenUserId', user.userId));
+ }
+ }}
+ role="button"
+ >
+ {contents}
+
+ )
+ }
+ actions={actions}
+ selectedEmoji={user.emoji}
+ onCloseCallback={() => this.setState({ selected: false }, () => Session.set('dropdownOpenUserId', null))}
+ open={selectedUserId === user.userId}
+ />
+ {isConfirmationModalOpen ? this.setConfirmationModalIsOpen(false),
+ priority: "low",
+ setIsOpen: this.setConfirmationModalIsOpen,
+ isOpen: isConfirmationModalOpen
+ }}
+ /> : null}
+ >
+ );
+ }
+}
+
+UserListItem.propTypes = propTypes;
+
+export default lockContextContainer(injectIntl(UserListItem));
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx
new file mode 100644
index 00000000..63850c30
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import BreakoutService from '/imports/ui/components/breakout-room/service';
+import Auth from '/imports/ui/services/auth';
+import UserListItem from './component';
+import UserListService from '/imports/ui/components/user-list/service';
+import UserReactionService from '/imports/ui/components/user-reaction/service';
+import { layoutDispatch } from '../../../../layout/context';
+
+const UserListItemContainer = (props) => {
+ const layoutContextDispatch = layoutDispatch();
+
+ const {
+ toggleVoice,
+ removeUser,
+ toggleUserLock,
+ toggleUserChatLock,
+ changeRole,
+ ejectUserCameras,
+ assignPresenter,
+ getAvailableActions,
+ normalizeEmojiName,
+ getGroupChatPrivate,
+ hasPrivateChatBetweenUsers,
+ } = UserListService;
+
+ return ;
+};
+const isMe = (intId) => intId === Auth.userID;
+
+export default withTracker(({ user }) => {
+ const findUserInBreakout = user ? BreakoutService.getBreakoutUserIsIn(user.userId) : false;
+ const findUserLastBreakout = user ? BreakoutService.getBreakoutUserWasIn(user.userId, null) : null;
+ const breakoutSequence = (findUserInBreakout || {}).sequence;
+
+ return {
+ isMe,
+ userInBreakout: !!findUserInBreakout,
+ userLastBreakout: findUserLastBreakout,
+ breakoutSequence,
+ isMeteorConnected: Meteor.status().connected,
+ voiceUser: user ? UserListService.curatedVoiceUser(user.userId) : null,
+ getEmojiList: UserListService.getEmojiList(),
+ getEmoji: UserListService.getEmoji(),
+ usersProp: UserListService.getUsersProp(),
+ selectedUserId: Session.get('dropdownOpenUserId'),
+ isReactionsEnabled: UserReactionService.isEnabled(),
+ };
+})(UserListItemContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js
new file mode 100644
index 00000000..aa5fe79e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/styles.js
@@ -0,0 +1,189 @@
+import styled from 'styled-components';
+import userAvatar from '/imports/ui/components/user-avatar/component';
+
+import {
+ lgPaddingY,
+ smPaddingY,
+ borderSize,
+ smPaddingX,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ listItemBgHover,
+ itemFocusBorder,
+ colorGray,
+ colorGrayDark,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const UserItemContents = styled.div`
+ position: static;
+ padding: .45rem;
+ width: 100%;
+ margin-left: .5rem;
+
+ ${({ selected }) => selected && `
+ background-color: ${listItemBgHover};
+ border-top-left-radius: ${smPaddingY};
+ border-bottom-left-radius: ${smPaddingY};
+
+ &:focus {
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder};
+ }
+ `}
+
+ ${({ isActionsOpen }) => !isActionsOpen && `
+ display: flex;
+ flex-flow: row;
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ cursor: pointer;
+
+ [dir="rtl"] & {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+ }
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:hover {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ background-color: ${listItemBgHover};
+ }
+
+ &:active,
+ &:focus {
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ background-color: ${listItemBgHover};
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder};
+ }
+ flex-flow: column;
+ flex-shrink: 0;
+ `}
+
+ ${({ isActionsOpen }) => isActionsOpen && `
+ outline: transparent;
+ outline-width: ${borderSize};
+ outline-style: solid;
+ background-color: ${listItemBgHover};
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder};
+ border-top-left-radius: ${smPaddingY};
+ border-bottom-left-radius: ${smPaddingY};
+
+ &:focus {
+ outline-style: solid;
+ outline-color: transparent !important;
+ }
+ `}
+`;
+
+const SkeletonUserItemContents = styled.div`
+ position: static;
+ padding: .45rem;
+ margin-left: .5rem;
+ margin-right: .5rem;
+ width: auto;
+`;
+
+const UserItemInnerContents = styled.div`
+ flex-grow: 0;
+ display: flex;
+ flex-flow: row;
+ padding: 3px;
+
+ [dir="rtl"] & {
+ padding: ${lgPaddingY} ${lgPaddingY} ${lgPaddingY} 0;
+ }
+`;
+
+const UserAvatar = styled.div`
+ flex: 0 0 2.25rem;
+`;
+
+const UserAvatarComponent = styled(userAvatar)`
+${({ hasReaction, emoji }) => hasReaction && !emoji && `
+ transition: all .5s ease;
+ font-size: 1.2rem;
+`}
+`;
+
+const NoActionsListItem = styled.div`
+ margin-left: 0.5rem;
+ padding: .45rem;
+ width: 100%;
+`;
+
+const UserName = styled.div`
+ display: flex;
+ flex-flow: column;
+ min-width: 0;
+ flex-grow: 1;
+ margin: 0 0 0 ${smPaddingX};
+ justify-content: center;
+ font-size: 90%;
+
+ [dir="rtl"] & {
+ margin: 0 ${smPaddingX} 0 0;
+ }
+`;
+
+const UserNameMain = styled.span`
+ margin: 0;
+ font-size: 90%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-weight: 400;
+ color: ${colorGrayDark};
+ display: flex;
+ flex-direction: row;
+
+ > span {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+
+ &.animationsEnabled {
+ transition: all .3s;
+ }`;
+
+const UserNameSub = styled.span`
+ margin: 0;
+ font-size: 0.75rem;
+ font-weight: 200;
+ color: ${colorGray};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ i {
+ line-height: 0;
+ font-size: 75%;
+ }
+`;
+
+const SkeletonWrapper = styled.span`
+ width: 100%;
+`;
+
+export default {
+ UserItemContents,
+ SkeletonUserItemContents,
+ UserItemInnerContents,
+ UserAvatar,
+ NoActionsListItem,
+ UserName,
+ UserNameMain,
+ UserNameSub,
+ SkeletonWrapper,
+ UserAvatarComponent,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx
new file mode 100644
index 00000000..2ba5eb10
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx
@@ -0,0 +1,446 @@
+import React, { PureComponent } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import LockViewersContainer from '/imports/ui/components/lock-viewers/container';
+import GuestPolicyContainer from '/imports/ui/components/waiting-users/guest-policy/container';
+import CreateBreakoutRoomContainer from '/imports/ui/components/actions-bar/create-breakout-room/container';
+import CaptionsService from '/imports/ui/components/captions/service';
+import WriterMenuContainer from '/imports/ui/components/captions/writer-menu/container';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import Styled from './styles';
+import { getUserNamesLink } from '/imports/ui/components/user-list/service';
+import Settings from '/imports/ui/services/settings';
+import { isBreakoutRoomsEnabled, isLearningDashboardEnabled } from '/imports/ui/services/features';
+import { uniqueId } from '/imports/utils/string-utils';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isMeetingMuted: PropTypes.bool.isRequired,
+ toggleMuteAllUsers: PropTypes.func.isRequired,
+ toggleMuteAllUsersExceptPresenter: PropTypes.func.isRequired,
+ toggleStatus: PropTypes.func.isRequired,
+ toggleReactions: PropTypes.func.isRequired,
+ guestPolicy: PropTypes.string.isRequired,
+ meetingIsBreakout: PropTypes.bool.isRequired,
+ hasBreakoutRoom: PropTypes.bool.isRequired,
+ isBreakoutRecordable: PropTypes.bool.isRequired,
+ dynamicGuestPolicy: PropTypes.bool.isRequired,
+};
+
+const intlMessages = defineMessages({
+ optionsLabel: {
+ id: 'app.userList.userOptions.manageUsersLabel',
+ description: 'Manage user label',
+ },
+ clearAllLabel: {
+ id: 'app.userList.userOptions.clearAllLabel',
+ description: 'Clear all label',
+ },
+ clearAllDesc: {
+ id: 'app.userList.userOptions.clearAllDesc',
+ description: 'Clear all description',
+ },
+ clearAllReactionsLabel: {
+ id: 'app.userList.userOptions.clearAllReactionsLabel',
+ description: 'Clear all reactions label',
+ },
+ clearAllReactionsDesc: {
+ id: 'app.userList.userOptions.clearAllReactionsDesc',
+ description: 'Clear all reactions description',
+ },
+ muteAllLabel: {
+ id: 'app.userList.userOptions.muteAllLabel',
+ description: 'Mute all label',
+ },
+ muteAllDesc: {
+ id: 'app.userList.userOptions.muteAllDesc',
+ description: 'Mute all description',
+ },
+ unmuteAllLabel: {
+ id: 'app.userList.userOptions.unmuteAllLabel',
+ description: 'Unmute all label',
+ },
+ unmuteAllDesc: {
+ id: 'app.userList.userOptions.unmuteAllDesc',
+ description: 'Unmute all desc',
+ },
+ lockViewersLabel: {
+ id: 'app.userList.userOptions.lockViewersLabel',
+ description: 'Lock viewers label',
+ },
+ lockViewersDesc: {
+ id: 'app.userList.userOptions.lockViewersDesc',
+ description: 'Lock viewers description',
+ },
+ guestPolicyLabel: {
+ id: 'app.userList.userOptions.guestPolicyLabel',
+ description: 'Guest policy label',
+ },
+ guestPolicyDesc: {
+ id: 'app.userList.userOptions.guestPolicyDesc',
+ description: 'Guest policy description',
+ },
+ muteAllExceptPresenterLabel: {
+ id: 'app.userList.userOptions.muteAllExceptPresenterLabel',
+ description: 'Mute all except presenter label',
+ },
+ muteAllExceptPresenterDesc: {
+ id: 'app.userList.userOptions.muteAllExceptPresenterDesc',
+ description: 'Mute all except presenter description',
+ },
+ createBreakoutRoom: {
+ id: 'app.actionsBar.actionsDropdown.createBreakoutRoom',
+ description: 'Create breakout room option',
+ },
+ createBreakoutRoomDesc: {
+ id: 'app.actionsBar.actionsDropdown.createBreakoutRoomDesc',
+ description: 'Description of create breakout room option',
+ },
+ learningDashboardLabel: {
+ id: 'app.learning-dashboard.label',
+ description: 'Activity Report label',
+ },
+ learningDashboardDesc: {
+ id: 'app.learning-dashboard.description',
+ description: 'Activity Report description',
+ },
+ saveUserNames: {
+ id: 'app.actionsBar.actionsDropdown.saveUserNames',
+ description: 'Save user name feature description',
+ },
+ captionsLabel: {
+ id: 'app.actionsBar.actionsDropdown.captionsLabel',
+ description: 'Captions menu toggle label',
+ },
+ captionsDesc: {
+ id: 'app.actionsBar.actionsDropdown.captionsDesc',
+ description: 'Captions menu toggle description',
+ },
+ savedNamesListTitle: {
+ id: 'app.userList.userOptions.savedNames.title',
+ description: '',
+ },
+ sortedFirstNameHeading: {
+ id: 'app.userList.userOptions.sortedFirstName.heading',
+ description: '',
+ },
+ sortedLastNameHeading: {
+ id: 'app.userList.userOptions.sortedLastName.heading',
+ description: '',
+ },
+ newTab: {
+ id: 'app.modal.newTab',
+ description: 'label used in aria description',
+ },
+ invitationLabel: {
+ id: 'app.userList.userOptions.breakoutRoomInvitationLabel',
+ description: 'Invitation item',
+ },
+});
+
+const USER_STATUS_ENABLED = Meteor.settings.public.userStatus.enabled;
+const USER_REACTION_ENABLED = Meteor.settings.public.userReaction.enabled;
+
+class UserOptions extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.clearStatusId = uniqueId('list-item-');
+ this.clearReactionId = uniqueId('list-item-');
+ this.muteId = uniqueId('list-item-');
+ this.muteAllId = uniqueId('list-item-');
+ this.lockId = uniqueId('list-item-');
+ this.guestPolicyId = uniqueId('list-item-');
+ this.createBreakoutId = uniqueId('list-item-');
+ this.learningDashboardId = uniqueId('list-item-');
+ this.saveUsersNameId = uniqueId('list-item-');
+ this.captionsId = uniqueId('list-item-');
+ this.state = {
+ isCreateBreakoutRoomModalOpen: false,
+ isGuestPolicyModalOpen: false,
+ isInvitation: false,
+ isWriterMenuModalOpen: false,
+ isLockViewersModalOpen: false,
+ }
+
+ this.handleCreateBreakoutRoomClick = this.handleCreateBreakoutRoomClick.bind(this);
+ this.onCreateBreakouts = this.onCreateBreakouts.bind(this);
+ this.onInvitationUsers = this.onInvitationUsers.bind(this);
+ this.renderMenuItems = this.renderMenuItems.bind(this);
+ this.onSaveUserNames = this.onSaveUserNames.bind(this);
+ this.setCreateBreakoutRoomModalIsOpen = this.setCreateBreakoutRoomModalIsOpen.bind(this);
+ this.setGuestPolicyModalIsOpen = this.setGuestPolicyModalIsOpen.bind(this);
+ this.setWriterMenuModalIsOpen = this.setWriterMenuModalIsOpen.bind(this);
+ this.setLockViewersModalIsOpen = this.setLockViewersModalIsOpen.bind(this);
+ }
+
+ onSaveUserNames() {
+ const { intl, meetingName } = this.props;
+ const lang = Settings.application.locale;
+ const date = new Date();
+
+ const dateString = lang ? date.toLocaleDateString(lang) : date.toLocaleDateString();
+ const timeString = lang ? date.toLocaleTimeString(lang) : date.toLocaleTimeString();
+
+ getUserNamesLink(
+ intl.formatMessage(intlMessages.savedNamesListTitle,
+ {
+ 0: meetingName,
+ 1: `${dateString}:${timeString}`,
+ }),
+ intl.formatMessage(intlMessages.sortedFirstNameHeading),
+ intl.formatMessage(intlMessages.sortedLastNameHeading),
+ ).dispatchEvent(new MouseEvent('click',
+ { bubbles: true, cancelable: true, view: window }));
+ }
+
+ onCreateBreakouts() {
+ return this.handleCreateBreakoutRoomClick(false);
+ }
+
+ onInvitationUsers() {
+ return this.handleCreateBreakoutRoomClick(true);
+ }
+
+ handleCreateBreakoutRoomClick(isInvitation) {
+ this.setState({isInvitation})
+ return this.setCreateBreakoutRoomModalIsOpen(true);
+ }
+
+ renderMenuItems() {
+ const {
+ intl,
+ isMeetingMuted,
+ toggleStatus,
+ toggleReactions,
+ toggleMuteAllUsers,
+ toggleMuteAllUsersExceptPresenter,
+ meetingIsBreakout,
+ hasBreakoutRoom,
+ openLearningDashboardUrl,
+ amIModerator,
+ isMeteorConnected,
+ dynamicGuestPolicy,
+ } = this.props;
+
+ const canCreateBreakout = amIModerator
+ && !meetingIsBreakout
+ && !hasBreakoutRoom
+ && isBreakoutRoomsEnabled();
+
+ const canInviteUsers = amIModerator
+ && !meetingIsBreakout
+ && hasBreakoutRoom
+
+ const { locale } = intl;
+
+ this.menuItems = [];
+
+ if (isMeteorConnected) {
+ if (!meetingIsBreakout) {
+ this.menuItems.push({
+ key: this.muteAllId,
+ label: intl.formatMessage(intlMessages[isMeetingMuted ? 'unmuteAllLabel' : 'muteAllLabel']),
+ description: intl.formatMessage(intlMessages[isMeetingMuted ? 'unmuteAllDesc' : 'muteAllDesc']),
+ onClick: toggleMuteAllUsers,
+ icon: isMeetingMuted ? 'unmute' : 'mute',
+ dataTest: 'muteAll',
+ });
+
+ if (!isMeetingMuted) {
+ this.menuItems.push({
+ key: this.muteId,
+ label: intl.formatMessage(intlMessages.muteAllExceptPresenterLabel),
+ description: intl.formatMessage(intlMessages.muteAllExceptPresenterDesc),
+ onClick: toggleMuteAllUsersExceptPresenter,
+ icon: 'mute',
+ dataTest: 'muteAllExceptPresenter',
+ });
+ }
+
+ this.menuItems.push({
+ key: this.lockId,
+ label: intl.formatMessage(intlMessages.lockViewersLabel),
+ description: intl.formatMessage(intlMessages.lockViewersDesc),
+ onClick: () => this.setLockViewersModalIsOpen(true),
+ icon: 'lock',
+ dataTest: 'lockViewersButton',
+ });
+
+ if (dynamicGuestPolicy) {
+ this.menuItems.push({
+ key: this.guestPolicyId,
+ icon: 'user',
+ label: intl.formatMessage(intlMessages.guestPolicyLabel),
+ description: intl.formatMessage(intlMessages.guestPolicyDesc),
+ onClick: () => this.setGuestPolicyModalIsOpen(true),
+ dataTest: 'guestPolicyLabel',
+ });
+ }
+ }
+
+ if (amIModerator) {
+ this.menuItems.push({
+ key: this.saveUsersNameId,
+ label: intl.formatMessage(intlMessages.saveUserNames),
+ // description: ,
+ onClick: this.onSaveUserNames,
+ icon: 'download',
+ dataTest: 'downloadUserNamesList',
+ divider: !USER_STATUS_ENABLED,
+ });
+ }
+
+ if (USER_STATUS_ENABLED) {
+ this.menuItems.push({
+ key: this.clearStatusId,
+ label: intl.formatMessage(intlMessages.clearAllLabel),
+ description: intl.formatMessage(intlMessages.clearAllDesc),
+ onClick: toggleStatus,
+ icon: 'clear_status',
+ divider: true,
+ });
+ }
+
+ if (USER_REACTION_ENABLED) {
+ this.menuItems.push({
+ key: this.clearReactionId,
+ label: intl.formatMessage(intlMessages.clearAllReactionsLabel),
+ description: intl.formatMessage(intlMessages.clearAllReactionsDesc),
+ onClick: toggleReactions,
+ icon: 'clear_status',
+ divider: true,
+ });
+ }
+
+ if (canCreateBreakout) {
+ this.menuItems.push({
+ key: this.createBreakoutId,
+ icon: 'rooms',
+ label: intl.formatMessage(intlMessages.createBreakoutRoom),
+ description: intl.formatMessage(intlMessages.createBreakoutRoomDesc),
+ onClick: this.onCreateBreakouts,
+ dataTest: 'createBreakoutRooms',
+ });
+ }
+
+ if (canInviteUsers && isMeteorConnected) {
+ this.menuItems.push({
+ key: this.createBreakoutId,
+ icon: 'rooms',
+ label: intl.formatMessage(intlMessages.invitationLabel),
+ onClick: this.onInvitationUsers,
+ dataTest: 'inviteBreakoutRooms',
+ })
+}
+ if (amIModerator && CaptionsService.isCaptionsEnabled()) {
+ this.menuItems.push({
+ icon: 'closed_caption',
+ label: intl.formatMessage(intlMessages.captionsLabel),
+ description: intl.formatMessage(intlMessages.captionsDesc),
+ key: this.captionsId,
+ onClick: () => { this.setWriterMenuModalIsOpen(true); },
+ dataTest: 'writeClosedCaptions',
+ });
+ }
+ if (amIModerator) {
+ if (isLearningDashboardEnabled()) {
+ this.menuItems.push({
+ icon: 'multi_whiteboard',
+ iconRight: 'popout_window',
+ label: intl.formatMessage(intlMessages.learningDashboardLabel),
+ description: `${intl.formatMessage(intlMessages.learningDashboardDesc)} ${intl.formatMessage(intlMessages.newTab)}`,
+ key: this.learningDashboardId,
+ onClick: () => { openLearningDashboardUrl(locale); },
+ dividerTop: true,
+ dataTest: 'learningDashboard'
+ });
+ }
+ }
+ }
+
+ return this.menuItems;
+ }
+
+ renderModal(isOpen, setIsOpen, priority, Component, otherOptions) {
+ return isOpen ? setIsOpen(false),
+ priority,
+ setIsOpen,
+ isOpen
+ }}
+ /> : null
+ }
+
+ setCreateBreakoutRoomModalIsOpen(value) {
+ this.setState({
+ isCreateBreakoutRoomModalOpen: value,
+ })
+ }
+
+ setGuestPolicyModalIsOpen(value) {
+ this.setState({
+ isGuestPolicyModalOpen: value,
+ })
+ }
+
+ setWriterMenuModalIsOpen(value) {
+ this.setState({isWriterMenuModalOpen: value});
+ }
+
+ setLockViewersModalIsOpen(value) {
+ this.setState({isLockViewersModalOpen: value});
+ }
+
+ render() {
+ const { intl, isRTL, isBreakoutRecordable } = this.props;
+ const { isCreateBreakoutRoomModalOpen, isInvitation,
+ isGuestPolicyModalOpen, isWriterMenuModalOpen,
+ isLockViewersModalOpen } = this.state;
+
+ return (
+ <>
+ null}
+ />
+ )}
+ actions={this.renderMenuItems()}
+ opts={{
+ id: "user-options-dropdown-menu",
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: "true",
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ />
+ {this.renderModal(isCreateBreakoutRoomModalOpen, this.setCreateBreakoutRoomModalIsOpen, "medium",
+ CreateBreakoutRoomContainer, {isBreakoutRecordable, isInvitation, isUpdate: isInvitation})}
+ {this.renderModal(isGuestPolicyModalOpen, this.setGuestPolicyModalIsOpen, "low",
+ GuestPolicyContainer)}
+ {this.renderModal(isWriterMenuModalOpen, this.setWriterMenuModalIsOpen, "low",
+ WriterMenuContainer)}
+ {this.renderModal(isLockViewersModalOpen, this.setLockViewersModalIsOpen, "low",
+ LockViewersContainer)}
+ >
+ );
+ }
+}
+
+UserOptions.propTypes = propTypes;
+export default injectIntl(UserOptions);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx
new file mode 100644
index 00000000..ec7823e2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx
@@ -0,0 +1,108 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+import ActionsBarService from '/imports/ui/components/actions-bar/service';
+import LearningDashboardService from '/imports/ui/components/learning-dashboard/service';
+import UserListService from '/imports/ui/components/user-list/service';
+import WaitingUsersService from '/imports/ui/components/waiting-users/service';
+import logger from '/imports/startup/client/logger';
+import { defineMessages, injectIntl } from 'react-intl';
+import { notify } from '/imports/ui/services/notification';
+import UserOptions from './component';
+import { layoutSelect } from '/imports/ui/components/layout/context';
+
+const intlMessages = defineMessages({
+ clearStatusMessage: {
+ id: 'app.userList.content.participants.options.clearedStatus',
+ description: 'Used in toast notification when emojis have been cleared',
+ },
+ clearReactionsMessage: {
+ id: 'app.userList.content.participants.options.clearedReactions',
+ description: 'Used in toast notification when reactions have been cleared',
+ },
+});
+
+const { dynamicGuestPolicy } = Meteor.settings.public.app;
+
+const meetingMuteDisabledLog = () => logger.info({
+ logCode: 'useroptions_unmute_all',
+ extraInfo: { logType: 'moderator_action' },
+}, 'moderator disabled meeting mute');
+
+const UserOptionsContainer = (props) => {
+ const isRTL = layoutSelect((i) => i.isRTL);
+ return (
+
+ );
+};
+
+export default injectIntl(withTracker((props) => {
+ const {
+ clearAllEmojiStatus,
+ clearAllReactions,
+ intl,
+ isMeetingMuteOnStart,
+ } = props;
+
+ const toggleReactions = () => {
+ clearAllReactions();
+
+ notify(
+ intl.formatMessage(intlMessages.clearReactionsMessage), 'info', 'clear_status',
+ );
+ };
+
+ const toggleStatus = () => {
+ clearAllEmojiStatus();
+
+ notify(
+ intl.formatMessage(intlMessages.clearStatusMessage), 'info', 'clear_status',
+ );
+ };
+
+ const getMeetingName = () => {
+ const { meetingProp } = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'meetingProp.name': 1 } });
+ const { name } = meetingProp;
+ return name;
+ };
+ return {
+ toggleMuteAllUsers: () => {
+ UserListService.muteAllUsers(Auth.userID);
+ if (isMeetingMuteOnStart) {
+ return meetingMuteDisabledLog();
+ }
+ return logger.info({
+ logCode: 'useroptions_mute_all',
+ extraInfo: { logType: 'moderator_action' },
+ }, 'moderator enabled meeting mute, all users muted');
+ },
+ toggleMuteAllUsersExceptPresenter: () => {
+ UserListService.muteAllExceptPresenter(Auth.userID);
+ if (isMeetingMuteOnStart) {
+ return meetingMuteDisabledLog();
+ }
+ return logger.info({
+ logCode: 'useroptions_mute_all_except_presenter',
+ extraInfo: { logType: 'moderator_action' },
+ }, 'moderator enabled meeting mute, all users muted except presenter');
+ },
+ toggleStatus,
+ toggleReactions,
+ isMeetingMuted: isMeetingMuteOnStart,
+ amIModerator: ActionsBarService.amIModerator(),
+ hasBreakoutRoom: UserListService.hasBreakoutRoom(),
+ isBreakoutRecordable: ActionsBarService.isBreakoutRecordable(),
+ guestPolicy: WaitingUsersService.getGuestPolicy(),
+ isMeteorConnected: Meteor.status().connected,
+ meetingName: getMeetingName(),
+ openLearningDashboardUrl: LearningDashboardService.openLearningDashboardUrl,
+ dynamicGuestPolicy,
+ };
+})(UserOptionsContainer));
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js
new file mode 100644
index 00000000..228cefda
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.js
@@ -0,0 +1,23 @@
+import styled from 'styled-components';
+
+import Button from '/imports/ui/components/common/button/component';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const OptionsButton = styled(Button)`
+ border-radius: 50%;
+ display: block;
+ padding: 0;
+ margin: 0 0.25rem;
+
+ span {
+ padding: inherit;
+ }
+
+ i {
+ font-size: ${fontSizeBase} !important;
+ }
+`;
+
+export default {
+ OptionsButton,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx
new file mode 100644
index 00000000..3b65c43a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/component.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Icon from '/imports/ui/components/common/icon/component';
+import Styled from './styles';
+import { ACTIONS, PANELS } from '../../../layout/enums';
+
+const intlMessages = defineMessages({
+ pollLabel: {
+ id: 'app.poll.pollPaneTitle',
+ description: 'label for user-list poll button',
+ },
+});
+
+const UserPolls = ({
+ intl,
+ isPresenter,
+ pollIsOpen,
+ forcePollOpen,
+ sidebarContentPanel,
+ layoutContextDispatch,
+}) => {
+ if (!isPresenter) return null;
+ if (!pollIsOpen && !forcePollOpen) return null;
+
+ const handleClickTogglePoll = () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: sidebarContentPanel !== PANELS.POLL,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: sidebarContentPanel === PANELS.POLL
+ ? PANELS.NONE
+ : PANELS.POLL,
+ });
+ };
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.pollLabel)}
+
+
+
+
+ {
+ if (e.key === 'Enter') {
+ handleClickTogglePoll();
+ }
+ }}
+ >
+
+ {intl.formatMessage(intlMessages.pollLabel)}
+
+
+
+
+ );
+};
+
+export default injectIntl(UserPolls);
+
+UserPolls.propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ isPresenter: PropTypes.bool.isRequired,
+ pollIsOpen: PropTypes.bool.isRequired,
+ forcePollOpen: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx
new file mode 100644
index 00000000..aed0e8fa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/container.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import UserPolls from './component';
+import { layoutSelectInput, layoutDispatch } from '../../../layout/context';
+
+const UserPollsContainer = (props) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const { sidebarContentPanel } = sidebarContent;
+ const layoutContextDispatch = layoutDispatch();
+
+ return (
+
+ );
+};
+
+export default withTracker(({ isPresenter }) => ({
+ pollIsOpen: Session.equals('isPollOpen', true),
+ forcePollOpen: Session.equals('forcePollOpen', true),
+}))(UserPollsContainer);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js
new file mode 100644
index 00000000..32685895
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/user-polls/styles.js
@@ -0,0 +1,25 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)``;
+
+const ListItem = styled(StyledContent.ListItem)``;
+
+export default {
+ Messages,
+ Container,
+ SmallTitle,
+ ScrollableList,
+ List,
+ ListItem,
+};
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx
new file mode 100644
index 00000000..788ad63e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/component.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import Icon from '/imports/ui/components/common/icon/component';
+import Styled from './styles';
+import { ACTIONS, PANELS } from '../../../layout/enums';
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+};
+
+const intlMessages = defineMessages({
+ waitingUsersTitle: {
+ id: 'app.userList.guest.waitingUsersTitle',
+ description: 'Title for the notes list',
+ },
+ title: {
+ id: 'app.userList.guest.waitingUsers',
+ description: 'Title for the waiting users',
+ },
+});
+
+const WaitingUsers = ({
+ intl,
+ pendingUsers,
+ sidebarContentPanel,
+ layoutContextDispatch,
+}) => {
+ const toggleWaitingPanel = () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: sidebarContentPanel !== PANELS.WAITING_USERS,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: sidebarContentPanel === PANELS.WAITING_USERS
+ ? PANELS.NONE
+ : PANELS.WAITING_USERS,
+ });
+ };
+
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.waitingUsersTitle)}
+
+
+
+
+ {
+ if (e.key === 'Enter') {
+ toggleWaitingPanel();
+ }
+ }}
+ >
+
+ {intl.formatMessage(intlMessages.title)}
+ {pendingUsers.length > 0 && (
+
+
+ {pendingUsers.length}
+
+
+ )}
+
+
+
+
+ );
+};
+
+WaitingUsers.propTypes = propTypes;
+
+export default injectIntl(WaitingUsers);
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx
new file mode 100644
index 00000000..9c94a9a8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/container.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import WaitingUsers from './component';
+import { layoutSelectInput, layoutDispatch } from '/imports/ui/components/layout/context';
+
+const WaitingUsersContainer = ({ pendingUsers }) => {
+ const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
+ const layoutContextDispatch = layoutDispatch();
+ const { sidebarContentPanel } = sidebarContent;
+
+ return (
+
+ );
+};
+
+export default WaitingUsersContainer;
diff --git a/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js
new file mode 100644
index 00000000..7bf12a35
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-list/user-list-content/waiting-users/styles.js
@@ -0,0 +1,31 @@
+import styled from 'styled-components';
+
+import Styled from '/imports/ui/components/user-list/styles';
+import StyledContent from '/imports/ui/components/user-list/user-list-content/styles';
+
+const Messages = styled(Styled.Messages)``;
+
+const Container = styled(StyledContent.Container)``;
+
+const SmallTitle = styled(Styled.SmallTitle)``;
+
+const ScrollableList = styled(StyledContent.ScrollableList)``;
+
+const List = styled(StyledContent.List)``;
+
+const ListItem = styled(StyledContent.ListItem)``;
+
+const UnreadMessages = styled(StyledContent.UnreadMessages)``;
+
+const UnreadMessagesText = styled(StyledContent.UnreadMessagesText)``;
+
+export default {
+ Messages,
+ Container,
+ SmallTitle,
+ ScrollableList,
+ List,
+ ListItem,
+ UnreadMessages,
+ UnreadMessagesText,
+};
diff --git a/src/2.7.12/imports/ui/components/user-reaction/service.js b/src/2.7.12/imports/ui/components/user-reaction/service.js
new file mode 100644
index 00000000..f690a5c7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/user-reaction/service.js
@@ -0,0 +1,52 @@
+import UserReaction from '/imports/api/user-reaction';
+import Auth from '/imports/ui/services/auth';
+import { makeCall } from '/imports/ui/services/api';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import { isReactionsEnabled } from '/imports/ui/services/features/index';
+
+const ENABLED = Meteor.settings.public.userReaction.enabled;
+
+const isEnabled = () => isReactionsEnabled() && getFromUserSettings('enable-user-reaction', ENABLED);
+
+const setUserReaction = (reaction) => {
+ if (isEnabled()) {
+ makeCall('setUserReaction', reaction);
+ }
+};
+
+const getUsersIdFromUserReaction = () => UserReaction.find(
+ { meetingId: Auth.meetingID },
+ { fields: { userId: 1 } },
+).fetch().map((user) => user.userId);
+
+const getUserReaction = (userId) => {
+ const reaction = UserReaction.findOne(
+ {
+ meetingId: Auth.meetingID,
+ userId,
+ },
+ {
+ fields:
+ {
+ reaction: 1,
+ creationDate: 1,
+ },
+ },
+ );
+
+ if (!reaction) {
+ return {
+ reaction: 'none',
+ reactionTime: 0,
+ };
+ }
+
+ return { reaction: reaction.reaction, reactionTime: reaction.creationDate.getTime() };
+};
+
+export default {
+ getUserReaction,
+ setUserReaction,
+ getUsersIdFromUserReaction,
+ isEnabled,
+};
diff --git a/src/2.7.12/imports/ui/components/utils/hooks/index.js b/src/2.7.12/imports/ui/components/utils/hooks/index.js
new file mode 100644
index 00000000..4e3b0b29
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/utils/hooks/index.js
@@ -0,0 +1,19 @@
+import { useEffect, useRef } from 'react';
+
+/**
+ * Custom hook to get previous value. It can be used,
+ * for example, to get previous props or state.
+ * @param {*} value
+ * @returns The previous value.
+ */
+export const usePreviousValue = (value) => {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+};
+
+export default {
+ usePreviousValue,
+};
diff --git a/src/2.7.12/imports/ui/components/video-preview/component.jsx b/src/2.7.12/imports/ui/components/video-preview/component.jsx
new file mode 100644
index 00000000..bab7f8e2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/component.jsx
@@ -0,0 +1,1282 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import {
+ defineMessages, injectIntl, FormattedMessage,
+} from 'react-intl';
+import Button from '/imports/ui/components/common/button/component';
+import VirtualBgSelector from '/imports/ui/components/video-preview/virtual-background/component';
+import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service';
+import logger from '/imports/startup/client/logger';
+import browserInfo from '/imports/utils/browserInfo';
+import PreviewService from './service';
+import VideoService from '../video-provider/service';
+import Styled from './styles';
+import deviceInfo from '/imports/utils/deviceInfo';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+import { notify } from '/imports/ui/services/notification';
+import {
+ EFFECT_TYPES,
+ SHOW_THUMBNAILS,
+ setSessionVirtualBackgroundInfo,
+ getSessionVirtualBackgroundInfo,
+ isVirtualBackgroundSupported,
+ clearSessionVirtualBackgroundInfo,
+ getSessionVirtualBackgroundInfoWithDefault,
+} from '/imports/ui/services/virtual-background/service';
+import Settings from '/imports/ui/services/settings';
+import { isVirtualBackgroundsEnabled } from '/imports/ui/services/features';
+import Checkbox from '/imports/ui/components/common/checkbox/component';
+import { CustomVirtualBackgroundsContext } from '/imports/ui/components/video-preview/virtual-background/context';
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users';
+
+const VIEW_STATES = {
+ finding: 'finding',
+ found: 'found',
+ error: 'error',
+};
+
+const ENABLE_CAMERA_BRIGHTNESS = Meteor.settings.public.app.enableCameraBrightness;
+const CAMERA_BRIGHTNESS_AVAILABLE = ENABLE_CAMERA_BRIGHTNESS && isVirtualBackgroundSupported();
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ closeModal: PropTypes.func.isRequired,
+ startSharing: PropTypes.func.isRequired,
+ stopSharing: PropTypes.func.isRequired,
+ resolve: PropTypes.func,
+ camCapReached: PropTypes.bool,
+ hasVideoStream: PropTypes.bool.isRequired,
+ webcamDeviceId: PropTypes.string,
+ sharedDevices: PropTypes.arrayOf(PropTypes.string),
+ cameraAsContent: PropTypes.bool,
+};
+
+const defaultProps = {
+ resolve: null,
+ camCapReached: true,
+ webcamDeviceId: null,
+ sharedDevices: [],
+ cameraAsContent: false,
+};
+
+const intlMessages = defineMessages({
+ webcamEffectsTitle: {
+ id: 'app.videoPreview.webcamEffectsTitle',
+ description: 'Title for the video effects modal',
+ },
+ webcamSettingsTitle: {
+ id: 'app.videoPreview.webcamSettingsTitle',
+ description: 'Title for the video preview modal',
+ },
+ closeLabel: {
+ id: 'app.videoPreview.closeLabel',
+ description: 'Close button label',
+ },
+ webcamPreviewLabel: {
+ id: 'app.videoPreview.webcamPreviewLabel',
+ description: 'Webcam preview label',
+ },
+ cameraLabel: {
+ id: 'app.videoPreview.cameraLabel',
+ description: 'Camera dropdown label',
+ },
+ qualityLabel: {
+ id: 'app.videoPreview.profileLabel',
+ description: 'Quality dropdown label',
+ },
+ low: {
+ id: 'app.videoPreview.quality.low',
+ description: 'Low quality option label',
+ },
+ medium: {
+ id: 'app.videoPreview.quality.medium',
+ description: 'Medium quality option label',
+ },
+ high: {
+ id: 'app.videoPreview.quality.high',
+ description: 'High quality option label',
+ },
+ hd: {
+ id: 'app.videoPreview.quality.hd',
+ description: 'High definition option label',
+ },
+ startSharingLabel: {
+ id: 'app.videoPreview.startSharingLabel',
+ description: 'Start sharing button label',
+ },
+ stopSharingLabel: {
+ id: 'app.videoPreview.stopSharingLabel',
+ description: 'Stop sharing button label',
+ },
+ stopSharingAllLabel: {
+ id: 'app.videoPreview.stopSharingAllLabel',
+ description: 'Stop sharing all button label',
+ },
+ sharedCameraLabel: {
+ id: 'app.videoPreview.sharedCameraLabel',
+ description: 'Already Shared camera label',
+ },
+ findingWebcamsLabel: {
+ id: 'app.videoPreview.findingWebcamsLabel',
+ description: 'Finding webcams label',
+ },
+ webcamOptionLabel: {
+ id: 'app.videoPreview.webcamOptionLabel',
+ description: 'Default webcam option label',
+ },
+ webcamNotFoundLabel: {
+ id: 'app.videoPreview.webcamNotFoundLabel',
+ description: 'Webcam not found label',
+ },
+ profileNotFoundLabel: {
+ id: 'app.videoPreview.profileNotFoundLabel',
+ description: 'Profile not found label',
+ },
+ permissionError: {
+ id: 'app.video.permissionError',
+ description: 'Error message for webcam permission',
+ },
+ AbortError: {
+ id: 'app.video.abortError',
+ description: 'Some problem occurred which prevented the device from being used',
+ },
+ OverconstrainedError: {
+ id: 'app.video.overconstrainedError',
+ description: 'No candidate devices which met the criteria requested',
+ },
+ SecurityError: {
+ id: 'app.video.securityError',
+ description: 'Media support is disabled on the Document',
+ },
+ TypeError: {
+ id: 'app.video.typeError',
+ description: 'List of constraints specified is empty, or has all constraints set to false',
+ },
+ NotFoundError: {
+ id: 'app.video.notFoundError',
+ description: 'error message when can not get webcam video',
+ },
+ NotAllowedError: {
+ id: 'app.video.notAllowed',
+ description: 'error message when webcam had permission denied',
+ },
+ NotSupportedError: {
+ id: 'app.video.notSupportedError',
+ description: 'error message when origin do not have ssl valid',
+ },
+ NotReadableError: {
+ id: 'app.video.notReadableError',
+ description: 'error message When the webcam is being used by other software',
+ },
+ TimeoutError: {
+ id: 'app.video.timeoutError',
+ description: 'error message when promise did not return',
+ },
+ iOSError: {
+ id: 'app.audioModal.iOSBrowser',
+ description: 'Audio/Video Not supported warning',
+ },
+ iOSErrorDescription: {
+ id: 'app.audioModal.iOSErrorDescription',
+ description: 'Audio/Video not supported description',
+ },
+ iOSErrorRecommendation: {
+ id: 'app.audioModal.iOSErrorRecommendation',
+ description: 'Audio/Video recommended action',
+ },
+ genericError: {
+ id: 'app.video.genericError',
+ description: 'error message for when the webcam sharing fails with unknown error',
+ },
+ camCapReached: {
+ id: 'app.video.camCapReached',
+ description: 'message for when the camera cap has been reached',
+ },
+ virtualBgGenericError: {
+ id: 'app.video.virtualBackground.genericError',
+ description: 'Failed to apply camera effect',
+ },
+ inactiveError: {
+ id: 'app.video.inactiveError',
+ description: 'Camera stopped unexpectedly',
+ },
+ brightness: {
+ id: 'app.videoPreview.brightness',
+ description: 'Brightness label',
+ },
+ wholeImageBrightnessLabel: {
+ id: 'app.videoPreview.wholeImageBrightnessLabel',
+ description: 'Whole image brightness label',
+ },
+ wholeImageBrightnessDesc: {
+ id: 'app.videoPreview.wholeImageBrightnessDesc',
+ description: 'Whole image brightness aria description',
+ },
+ cameraAsContentSettingsTitle: {
+ id: 'app.videoPreview.cameraAsContentSettingsTitle',
+ description: 'Title for the video preview modal when sharing camera as content',
+ },
+ sliderDesc: {
+ id: 'app.videoPreview.sliderDesc',
+ description: 'Brightness slider aria description',
+ },
+});
+
+class VideoPreview extends Component {
+ constructor(props) {
+ super(props);
+
+ const {
+ webcamDeviceId,
+ } = props;
+
+ this.handleProceed = this.handleProceed.bind(this);
+ this.handleStartSharing = this.handleStartSharing.bind(this);
+ this.handleStopSharing = this.handleStopSharing.bind(this);
+ this.handleStopSharingAll = this.handleStopSharingAll.bind(this);
+ this.handleSelectWebcam = this.handleSelectWebcam.bind(this);
+ this.handleSelectProfile = this.handleSelectProfile.bind(this);
+ this.handleVirtualBgSelected = this.handleVirtualBgSelected.bind(this);
+ this.handleLocalStreamInactive = this.handleLocalStreamInactive.bind(this);
+ this.handleBrightnessAreaChange = this.handleBrightnessAreaChange.bind(this);
+ this.updateVirtualBackgroundInfo = this.updateVirtualBackgroundInfo.bind(this);
+
+ this._isMounted = false;
+
+ this.state = {
+ webcamDeviceId,
+ availableWebcams: null,
+ selectedProfile: null,
+ isStartSharingDisabled: true,
+ viewState: VIEW_STATES.finding,
+ deviceError: null,
+ previewError: null,
+ brightness: 100,
+ wholeImageBrightness: false,
+ };
+ }
+
+ set currentVideoStream (bbbVideoStream) {
+ // Stream is being unset - remove gUM revocation handler to avoid false negatives
+ if (this._currentVideoStream) {
+ this._currentVideoStream.removeListener('inactive', this.handleLocalStreamInactive);
+ }
+ // Set up inactivation handler for the new stream (to, eg, detect gUM revocation)
+ if (bbbVideoStream) {
+ bbbVideoStream.once('inactive', this.handleLocalStreamInactive);
+ }
+ this._currentVideoStream = bbbVideoStream;
+ }
+
+ get currentVideoStream () {
+ return this._currentVideoStream;
+ }
+
+ componentDidMount() {
+ const {
+ webcamDeviceId,
+ forceOpen,
+ } = this.props;
+ const { dispatch, backgrounds } = this.context;
+
+ this._isMounted = true;
+
+ // Set the custom or default virtual background
+ const webcamBackground = Users.findOne({
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ }, {
+ fields: {
+ webcamBackground: 1,
+ },
+ });
+
+ const webcamBackgroundURL = webcamBackground?.webcamBackground;
+ if (webcamBackgroundURL !== '' && !backgrounds.webcamBackgroundURL) {
+ VirtualBgService.getFileFromUrl(webcamBackgroundURL).then((fetchedWebcamBackground) => {
+ if (fetchedWebcamBackground) {
+ const data = URL.createObjectURL(fetchedWebcamBackground);
+ const uniqueId = 'webcamBackgroundURL';
+ const filename = webcamBackgroundURL;
+ dispatch({
+ type: 'update',
+ background: {
+ filename,
+ uniqueId,
+ data,
+ lastActivityDate: Date.now(),
+ custom: true,
+ sessionOnly: true,
+ },
+ });
+ } else {
+ logger.error('Failed to fetch custom webcam background image. Using fallback image.');
+ }
+ });
+ }
+
+ if (deviceInfo.hasMediaDevices) {
+ navigator.mediaDevices.enumerateDevices().then((devices) => {
+ VideoService.updateNumberOfDevices(devices);
+ // Video preview skip is activated, short circuit via a simpler procedure
+ if (PreviewService.getSkipVideoPreview() && !forceOpen) return this.skipVideoPreview();
+ // Late enumerateDevices resolution, stop.
+ if (!this._isMounted) return;
+
+ let {
+ webcams,
+ areLabelled,
+ areIdentified
+ } = PreviewService.digestVideoDevices(devices, webcamDeviceId);
+
+ logger.debug({
+ logCode: 'video_preview_enumerate_devices',
+ extraInfo: {
+ devices,
+ webcams,
+ },
+ }, `Enumerate devices came back. There are ${devices.length} devices and ${webcams.length} are video inputs`);
+
+ if (webcams.length > 0) {
+ this.getInitialCameraStream(webcams[0].deviceId)
+ .then(async () => {
+ // Late gUM resolve, stop.
+ if (!this._isMounted) return;
+
+ if (!areLabelled || !areIdentified) {
+ // If they aren't labelled or have nullish deviceIds, run
+ // enumeration again and get their full versions
+ // Why: fingerprinting countermeasures obfuscate those when
+ // no permission was granted via gUM
+ try {
+ const newDevices = await navigator.mediaDevices.enumerateDevices();
+ webcams = PreviewService.digestVideoDevices(newDevices, webcamDeviceId).webcams;
+ } catch (error) {
+ // Not a critical error beucase it should only affect UI; log it
+ // and go ahead
+ logger.error({
+ logCode: 'video_preview_enumerate_relabel_failure',
+ extraInfo: {
+ errorName: error.name, errorMessage: error.message,
+ },
+ }, 'enumerateDevices for relabelling failed');
+ }
+ }
+
+ this.setState({
+ availableWebcams: webcams,
+ viewState: VIEW_STATES.found,
+ });
+ this.displayPreview();
+ });
+ } else {
+ // There were no webcams coming from enumerateDevices. Throw an error.
+ const noWebcamsError = new Error('NotFoundError');
+ this.handleDeviceError('enumerate', noWebcamsError, ': no webcams found');
+ }
+ }).catch((error) => {
+ // enumerateDevices failed
+ this.handleDeviceError('enumerate', error, 'enumerating devices');
+ });
+ } else {
+ // Top-level navigator.mediaDevices is not supported.
+ // The session went through the version checking, but somehow ended here.
+ // Nothing we can do.
+ const error = new Error('NotSupportedError');
+ this.handleDeviceError('mount', error, ': navigator.mediaDevices unavailable');
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.brightnessMarker) {
+ const markerStyle = window.getComputedStyle(this.brightnessMarker);
+ const left = parseFloat(markerStyle.left);
+ const right = parseFloat(markerStyle.right);
+
+ if (left < 0) {
+ this.brightnessMarker.style.left = '0px';
+ this.brightnessMarker.style.right = 'auto';
+ } else if (right < 0) {
+ this.brightnessMarker.style.right = '0px';
+ this.brightnessMarker.style.left = 'auto';
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ const { webcamDeviceId } = this.state;
+ this.terminateCameraStream(this.currentVideoStream, webcamDeviceId);
+ this.cleanupStreamAndVideo();
+ this._isMounted = false;
+ }
+
+ handleSelectWebcam(event) {
+ const webcamValue = event.target.value;
+
+ this.getInitialCameraStream(webcamValue).then(() => {
+ this.displayPreview();
+ });
+ }
+
+ handleLocalStreamInactive({ id }) {
+ // id === MediaStream.id
+ if (this.currentVideoStream
+ && typeof id === 'string'
+ && this.currentVideoStream?.mediaStream?.id === id) {
+ this.setState({
+ isStartSharingDisabled: true,
+ });
+ this.handlePreviewError(
+ 'stream_inactive',
+ new Error('inactiveError'),
+ '- preview camera stream inactive',
+ );
+ }
+ }
+
+ // Resolves into true if the background switch is successful, false otherwise
+ handleVirtualBgSelected(type, name, customParams) {
+ if (type !== EFFECT_TYPES.NONE_TYPE || CAMERA_BRIGHTNESS_AVAILABLE) {
+ return this.startVirtualBackground(
+ this.currentVideoStream,
+ type,
+ name,
+ customParams,
+ ).then((switched) => {
+ if (switched) this.updateVirtualBackgroundInfo();
+ return switched;
+ });
+ }
+ this.stopVirtualBackground(this.currentVideoStream);
+ this.updateVirtualBackgroundInfo();
+ return Promise.resolve(true);
+ }
+
+ stopVirtualBackground(bbbVideoStream) {
+ if (bbbVideoStream) {
+ bbbVideoStream.stopVirtualBackground();
+ this.displayPreview();
+ }
+ }
+
+ startVirtualBackground(bbbVideoStream, type, name, customParams) {
+ this.setState({ isStartSharingDisabled: true });
+
+ if (bbbVideoStream == null) return Promise.resolve(false);
+
+ return bbbVideoStream.startVirtualBackground(type, name, customParams).then(() => {
+ this.displayPreview();
+ return true;
+ }).catch((error) => {
+ this.handleVirtualBgError(error, type, name);
+ return false;
+ }).finally(() => {
+ this.setState({ isStartSharingDisabled: false });
+ });
+ }
+
+ handleSelectProfile(event) {
+ const profileValue = event.target.value;
+ const { webcamDeviceId } = this.state;
+
+ const selectedProfile = PreviewService.getCameraProfile(profileValue);
+ this.getCameraStream(webcamDeviceId, selectedProfile).then(() => {
+ this.displayPreview();
+ });
+ }
+
+ handleStartSharing() {
+ const {
+ resolve,
+ startSharing,
+ cameraAsContent,
+ startSharingCameraAsContent,
+ } = this.props;
+ const {
+ webcamDeviceId,
+ selectedProfile,
+ brightness,
+ } = this.state;
+
+ // Only streams that will be shared should be stored in the service.
+ // If the store call returns false, we're duplicating stuff. So clean this one
+ // up because it's an impostor.
+ if (!PreviewService.storeStream(webcamDeviceId, this.currentVideoStream)) {
+ this.currentVideoStream.stop();
+ }
+
+ if (
+ this.currentVideoStream.virtualBgService
+ && brightness === 100
+ && this.currentVideoStream.virtualBgType === EFFECT_TYPES.NONE_TYPE
+ ) {
+ this.stopVirtualBackground(this.currentVideoStream);
+ }
+
+ this.cleanupStreamAndVideo();
+
+ PreviewService.changeProfile(selectedProfile);
+ PreviewService.changeWebcam(webcamDeviceId);
+ if (cameraAsContent) {
+ startSharingCameraAsContent(webcamDeviceId);
+ } else {
+ startSharing(webcamDeviceId);
+ }
+ if (resolve) resolve();
+ }
+
+ handleStopSharing() {
+ const { resolve, stopSharing, stopSharingCameraAsContent } = this.props;
+ const { webcamDeviceId } = this.state;
+
+ if (this.isCameraAsContentDevice(webcamDeviceId)) {
+ stopSharingCameraAsContent();
+ } else {
+ PreviewService.deleteStream(webcamDeviceId);
+ stopSharing(webcamDeviceId);
+ this.cleanupStreamAndVideo();
+ }
+ if (resolve) resolve();
+ }
+
+ handleStopSharingAll() {
+ const { resolve, stopSharing } = this.props;
+ stopSharing();
+ if (resolve) resolve();
+ }
+
+ handleProceed() {
+ const { resolve, closeModal, sharedDevices, isVisualEffects } = this.props;
+ const { webcamDeviceId, brightness } = this.state;
+ const shared = sharedDevices.includes(webcamDeviceId);
+
+ if (
+ (shared || isVisualEffects)
+ && this.currentVideoStream.virtualBgService
+ && brightness === 100
+ && this.currentVideoStream.virtualBgType === EFFECT_TYPES.NONE_TYPE
+ ) {
+ this.stopVirtualBackground(this.currentVideoStream);
+ }
+
+ this.terminateCameraStream(this.currentVideoStream, webcamDeviceId);
+ closeModal();
+ if (resolve) resolve();
+ }
+
+ handlePreviewError(logCode, error, description) {
+ logger.warn({
+ logCode: `video_preview_${logCode}_error`,
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, `Error ${description}`);
+ this.setState({
+ previewError: this.handleGUMError(error),
+ });
+ }
+
+ handleDeviceError(logCode, error, description) {
+ logger.warn({
+ logCode: `video_preview_${logCode}_error`,
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, `Error ${description}`);
+ this.setState({
+ viewState: VIEW_STATES.error,
+ deviceError: this.handleGUMError(error),
+ });
+ }
+
+ handleGUMError(error) {
+ const { intl } = this.props;
+
+ logger.error({
+ logCode: 'video_preview_gum_failure',
+ extraInfo: {
+ errorName: error.name, errorMessage: error.message,
+ },
+ }, 'getUserMedia failed in video-preview');
+
+ const intlError = intlMessages[error.name] || intlMessages[error.message];
+ if (intlError) {
+ return intl.formatMessage(intlError);
+ }
+
+ return intl.formatMessage(intlMessages.genericError,
+ { 0: `${error.name}: ${error.message}` });
+ }
+
+ terminateCameraStream(stream, deviceId) {
+ if (stream) {
+ // Stream is being destroyed - remove gUM revocation handler to avoid false negatives
+ stream.removeListener('inactive', this.handleLocalStreamInactive);
+ PreviewService.terminateCameraStream(stream, deviceId);
+ }
+ }
+
+ cleanupStreamAndVideo() {
+ this.currentVideoStream = null;
+ if (this.video) this.video.srcObject = null;
+ }
+
+ handleVirtualBgError(error, type, name) {
+ const { intl } = this.props;
+ logger.error({
+ logCode: `video_preview_virtualbg_error`,
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ virtualBgType: type,
+ virtualBgName: name,
+ },
+ }, `Failed to toggle virtual background: ${error.message}`);
+
+ notify(intl.formatMessage(intlMessages.virtualBgGenericError), 'error', 'video');
+ }
+
+ updateDeviceId (deviceId) {
+ let actualDeviceId = deviceId;
+
+ if (!actualDeviceId && this.currentVideoStream) {
+ actualDeviceId = MediaStreamUtils.extractDeviceIdFromStream(
+ this.currentVideoStream.originalStream,
+ 'video',
+ );
+ }
+
+ this.setState({ webcamDeviceId: actualDeviceId, });
+ }
+
+ getInitialCameraStream(deviceId) {
+ const { cameraAsContent } = this.props;
+ const defaultProfile = !cameraAsContent
+ ? PreviewService.getDefaultProfile()
+ : PreviewService.getCameraAsContentProfile();
+
+ return this.getCameraStream(deviceId, defaultProfile);
+ }
+
+ async startEffects(deviceId) {
+ // Brightness and backgrounds are independent of each other,
+ // handle each one separately.
+ try {
+ await this.startCameraBrightness();
+ } catch (error) {
+ logger.warn({
+ logCode: 'brightness_effect_error',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, 'Failed to start brightness effect');
+ }
+
+ let type;
+ let name;
+ let customParams;
+
+ const { backgrounds } = this.context;
+ const { webcamBackgroundURL } = backgrounds;
+ const storedBackgroundInfo = getSessionVirtualBackgroundInfo(deviceId);
+
+ if (storedBackgroundInfo) {
+ type = storedBackgroundInfo.type;
+ name = storedBackgroundInfo.name;
+ customParams = storedBackgroundInfo.customParams;
+ } else if (webcamBackgroundURL) {
+ const { data, filename } = webcamBackgroundURL;
+ type = EFFECT_TYPES.IMAGE_TYPE;
+ name = filename;
+ customParams = { file: data };
+ }
+
+ if (!type) return Promise.resolve(true);
+
+ try {
+ return this.handleVirtualBgSelected(type, name, customParams);
+ } catch (error) {
+ this.handleVirtualBgError(error, type, name);
+ clearSessionVirtualBackgroundInfo(deviceId);
+ throw error;
+ }
+ }
+
+ getCameraStream(deviceId, profile) {
+ const { webcamDeviceId } = this.state;
+ const { cameraAsContent } = this.props;
+
+ this.setState({
+ selectedProfile: profile.id,
+ isStartSharingDisabled: true,
+ previewError: undefined,
+ });
+
+ this.terminateCameraStream(this.currentVideoStream, webcamDeviceId);
+ this.cleanupStreamAndVideo();
+
+ // The return of doGUM is an instance of BBBVideoStream (a thin wrapper over a MediaStream)
+ return PreviewService.doGUM(deviceId, profile).then((bbbVideoStream) => {
+ // Late GUM resolve, clean up tracks, stop.
+ if (!this._isMounted) {
+ this.terminateCameraStream(bbbVideoStream, deviceId);
+ this.cleanupStreamAndVideo();
+ return Promise.resolve(false);
+ }
+
+ this.currentVideoStream = bbbVideoStream;
+ this.updateDeviceId(deviceId);
+
+ if (cameraAsContent) {
+ this.setState({
+ isStartSharingDisabled: false,
+ });
+
+ return Promise.resolve(true);
+ }
+
+ return this.startEffects(deviceId)
+ .catch((error) => {
+ if (this.shouldSkipVideoPreview()) {
+ throw error;
+ }
+ })
+ .finally(() => {
+ if (this._isMounted) {
+ this.setState({
+ isStartSharingDisabled: false,
+ });
+ } else {
+ this.terminateCameraStream(bbbVideoStream, deviceId);
+ this.cleanupStreamAndVideo();
+ }
+ });
+ }).catch((error) => {
+ // When video preview is set to skip, we need some way to bubble errors
+ // up to users; so re-throw the error
+ if (!PreviewService.getSkipVideoPreview()) {
+ this.handlePreviewError('do_gum_preview', error, 'displaying final selection');
+ } else {
+ throw error;
+ }
+ });
+ }
+
+ displayPreview() {
+ if (this.currentVideoStream && this.video) {
+ this.video.srcObject = this.currentVideoStream.mediaStream;
+ }
+ }
+
+ skipVideoPreview() {
+ this.getInitialCameraStream().then(() => {
+ this.handleStartSharing();
+ }).catch(error => {
+ this.cleanupStreamAndVideo();
+ notify(this.handleGUMError(error), 'error', 'video');
+ });
+ }
+
+ supportWarning() {
+ const { intl } = this.props;
+
+ return (
+
+ !
+ {intl.formatMessage(intlMessages.iOSError)}
+ {intl.formatMessage(intlMessages.iOSErrorDescription)}
+
+ {intl.formatMessage(intlMessages.iOSErrorRecommendation)}
+
+
+ );
+ }
+
+ getFallbackLabel(webcam, index) {
+ const { intl } = this.props;
+ return `${intl.formatMessage(intlMessages.cameraLabel)} ${index}`
+ }
+
+ isAlreadyShared (webcamId) {
+ const { sharedDevices, cameraAsContentDeviceId } = this.props;
+
+ return sharedDevices.includes(webcamId) || webcamId === cameraAsContentDeviceId;
+ }
+
+ isCameraAsContentDevice (deviceId) {
+ const { cameraAsContentDeviceId } = this.props;
+
+ return deviceId === cameraAsContentDeviceId;
+ }
+
+ renderDeviceSelectors() {
+ const {
+ intl,
+ sharedDevices,
+ isVisualEffects,
+ cameraAsContent,
+ } = this.props;
+
+ const {
+ webcamDeviceId,
+ availableWebcams,
+ selectedProfile,
+ } = this.state;
+
+ const shared = sharedDevices.includes(webcamDeviceId);
+ const shouldShowVirtualBackgrounds = isVirtualBackgroundsEnabled() && !cameraAsContent;
+
+ if (isVisualEffects) {
+ return (
+ <>
+ {isVirtualBackgroundsEnabled() && this.renderVirtualBgSelector()}
+ >
+ );
+ }
+
+ return (
+ <>
+
+ { cameraAsContent
+ ? (
+ <>
+
+ {intl.formatMessage(intlMessages.cameraLabel)}
+
+ { availableWebcams && availableWebcams.length > 0
+ ? (
+
+ {availableWebcams.map((webcam, index) => (
+
+ {webcam.label || this.getFallbackLabel(webcam, index)}
+
+ ))}
+
+ )
+ : (
+
+ {intl.formatMessage(intlMessages.webcamNotFoundLabel)}
+
+ )
+ }
+ >
+ )
+ :
+ <>
+
+ {intl.formatMessage(intlMessages.cameraLabel)}
+
+ { availableWebcams && availableWebcams.length > 0
+ ? (
+
+ {availableWebcams.map((webcam, index) => (
+
+ {webcam.label || this.getFallbackLabel(webcam, index)}
+
+ ))}
+
+ )
+ : (
+
+ {intl.formatMessage(intlMessages.webcamNotFoundLabel)}
+
+ )
+ }
+ { shared
+ ? (
+
+ {intl.formatMessage(intlMessages.sharedCameraLabel)}
+
+ )
+ : (
+ <>
+
+ {intl.formatMessage(intlMessages.qualityLabel)}
+
+ {PreviewService.PREVIEW_CAMERA_PROFILES.length > 0
+ ? (
+
+ {PreviewService.PREVIEW_CAMERA_PROFILES.map((profile) => {
+ const label = intlMessages[`${profile.id}`]
+ ? intl.formatMessage(intlMessages[`${profile.id}`])
+ : profile.name;
+
+ return (
+
+ {`${label}`}
+
+ );
+ })}
+
+ )
+ : (
+
+ {intl.formatMessage(intlMessages.profileNotFoundLabel)}
+
+ )
+ }
+ >
+ )
+ }
+ {shouldShowVirtualBackgrounds && this.renderVirtualBgSelector()}
+ >
+ }
+ >
+ );
+ }
+
+ handleBrightnessAreaChange() {
+ const { wholeImageBrightness } = this.state;
+ this.currentVideoStream.toggleCameraBrightnessArea(!wholeImageBrightness);
+ this.setState({ wholeImageBrightness: !wholeImageBrightness });
+ }
+
+ renderBrightnessInput() {
+ const {
+ cameraAsContent,
+ } = this.props;
+ const {
+ webcamDeviceId,
+ } = this.state;
+ if (!ENABLE_CAMERA_BRIGHTNESS) return null;
+
+ const { intl } = this.props;
+ const { brightness, wholeImageBrightness, isStartSharingDisabled } = this.state;
+ const shared = this.isAlreadyShared(webcamDeviceId);
+
+ const origin = brightness <= 100 ? 'left' : 'right';
+ const offset = origin === 'left'
+ ? (brightness * 100) / 200
+ : ((200 - brightness) * 100) / 200;
+
+ if(cameraAsContent){ return null }
+
+ return (
+ <>
+
+ {intl.formatMessage(intlMessages.brightness)}
+
+
+
+ this.brightnessMarker = ref}
+ style={{ [origin]: `calc(${offset}% - 1rem)` }}
+ >
+ {brightness - 100}
+
+
+
+ {
+ const brightness = e.target.valueAsNumber;
+ this.currentVideoStream.changeCameraBrightness(brightness);
+ this.setState({ brightness });
+ }}
+ disabled={!isVirtualBackgroundSupported() || isStartSharingDisabled}
+ />
+
+ {intl.formatMessage(intlMessages.sliderDesc)}
+
+
+ {'-100'}
+ {'0'}
+ {'100'}
+
+
+
+
+ >
+ );
+ }
+
+ renderVirtualBgSelector() {
+ const { isVisualEffects } = this.props;
+ const { isStartSharingDisabled, webcamDeviceId } = this.state;
+ const initialVirtualBgState = this.currentVideoStream ? {
+ type: this.currentVideoStream.virtualBgType,
+ name: this.currentVideoStream.virtualBgName
+ } : getSessionVirtualBackgroundInfoWithDefault(webcamDeviceId);
+
+ return (
+
+ );
+ }
+
+ renderContent() {
+ const {
+ intl,
+ } = this.props;
+
+ const {
+ viewState,
+ deviceError,
+ previewError,
+ } = this.state;
+
+ const { animations } = Settings.application;
+
+ switch (viewState) {
+ case VIEW_STATES.finding:
+ return (
+
+
+
+ {intl.formatMessage(intlMessages.findingWebcamsLabel)}
+
+
+
+
+ );
+ case VIEW_STATES.error:
+ return (
+
+ {deviceError}
+
+ );
+ case VIEW_STATES.found:
+ default:
+ return (
+
+
+ {
+ previewError
+ ? (
+ {previewError}
+ )
+ : (
+ { this.video = ref; }}
+ autoPlay
+ playsInline
+ muted
+ />
+ )
+ }
+
+
+ {this.renderDeviceSelectors()}
+ {this.renderBrightnessInput()}
+
+
+ );
+ }
+ }
+
+ getModalTitle() {
+ const { intl, cameraAsContent } = this.props;
+ if (cameraAsContent) return intl.formatMessage(intlMessages.cameraAsContentSettingsTitle);
+ return intl.formatMessage(intlMessages.webcamSettingsTitle);
+ }
+
+ startCameraBrightness() {
+ if (CAMERA_BRIGHTNESS_AVAILABLE) {
+ const setBrightnessInfo = () => {
+ const stream = this.currentVideoStream || {};
+ const service = stream.virtualBgService || {};
+ const { brightness = 100, wholeImageBrightness = false } = service;
+ this.setState({ brightness, wholeImageBrightness });
+ };
+
+ if (!this.currentVideoStream.virtualBgService) {
+ return this.startVirtualBackground(
+ this.currentVideoStream,
+ EFFECT_TYPES.NONE_TYPE,
+ ).then((switched) => {
+ if (switched) {
+ setBrightnessInfo();
+ }
+ });
+ }
+
+ setBrightnessInfo();
+ }
+
+ return Promise.resolve(true);
+ }
+
+ updateVirtualBackgroundInfo() {
+ const { webcamDeviceId } = this.state;
+
+ // Update this session's virtual camera effect information if it's enabled
+ setSessionVirtualBackgroundInfo(
+ this.currentVideoStream.virtualBgType,
+ this.currentVideoStream.virtualBgName,
+ this.currentVideoStream.customParams,
+ webcamDeviceId,
+ );
+ }
+
+ renderModalContent() {
+ const {
+ intl,
+ hasVideoStream,
+ forceOpen,
+ camCapReached,
+ isVisualEffects,
+ } = this.props;
+
+ const {
+ isStartSharingDisabled,
+ webcamDeviceId,
+ deviceError,
+ previewError,
+ } = this.state;
+ const shouldDisableButtons = PreviewService.getSkipVideoPreview()
+ && !forceOpen
+ && !(deviceError || previewError);
+
+ const shared = this.isAlreadyShared(webcamDeviceId);
+
+ const { isIe } = browserInfo;
+
+ return (
+ <>
+ {isIe ? (
+
+ Chrome,
+ 1: Firefox ,
+ }}
+ />
+
+ ) : null}
+
+ {this.renderContent()}
+
+ {!isVisualEffects ? (
+
+ {hasVideoStream && VideoService.isMultipleCamerasEnabled()
+ ? (
+
+
+
+ )
+ : null
+ }
+
+ {!shared && camCapReached ? (
+ {intl.formatMessage(intlMessages.camCapReached)}
+ ) : ( )}
+
+
+ ) : null }
+ >
+ );
+ }
+
+ render() {
+ const {
+ intl,
+ isCamLocked,
+ forceOpen,
+ isVisualEffects,
+ isOpen,
+ priority,
+ } = this.props;
+
+ if (isCamLocked === true) {
+ this.handleProceed();
+ return null;
+ }
+
+ if (PreviewService.getSkipVideoPreview() && !forceOpen) {
+ return null;
+ }
+
+ const {
+ deviceError,
+ previewError,
+ } = this.state;
+
+ const allowCloseModal = !!(deviceError || previewError)
+ || !PreviewService.getSkipVideoPreview()
+ || forceOpen;
+
+ const title = isVisualEffects
+ ? intl.formatMessage(intlMessages.webcamEffectsTitle)
+ : intl.formatMessage(intlMessages.webcamSettingsTitle);
+
+ return (
+
+ {deviceInfo.hasMediaDevices
+ ? this.renderModalContent()
+ : this.supportWarning()
+ }
+
+ );
+ }
+}
+
+VideoPreview.propTypes = propTypes;
+VideoPreview.defaultProps = defaultProps;
+VideoPreview.contextType = CustomVirtualBackgroundsContext;
+
+export default injectIntl(VideoPreview);
diff --git a/src/2.7.12/imports/ui/components/video-preview/container.jsx b/src/2.7.12/imports/ui/components/video-preview/container.jsx
new file mode 100644
index 00000000..74cd63b5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/container.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Service from './service';
+import VideoPreview from './component';
+import VideoService from '../video-provider/service';
+import ScreenShareService from '/imports/ui/components/screenshare/service';
+import logger from '/imports/startup/client/logger';
+import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/errors';
+
+const VideoPreviewContainer = (props) => ;
+
+export default withTracker(({ setIsOpen, callbackToClose }) => ({
+ startSharing: (deviceId) => {
+ callbackToClose();
+ setIsOpen(false);
+ VideoService.joinVideo(deviceId);
+ },
+ startSharingCameraAsContent: (deviceId) => {
+ callbackToClose();
+ setIsOpen(false);
+ const handleFailure = (error) => {
+ const {
+ errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
+ errorMessage = error.message,
+ } = error;
+
+ logger.error({
+ logCode: 'camera_as_content_failed',
+ extraInfo: { errorCode, errorMessage },
+ }, `Sharing camera as content failed: ${errorMessage} (code=${errorCode})`);
+
+ ScreenShareService.screenshareHasEnded();
+ };
+ ScreenShareService.shareScreen(
+ true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream }
+ );
+ ScreenShareService.setCameraAsContentDeviceId(deviceId);
+ },
+ stopSharing: (deviceId) => {
+ callbackToClose();
+ setIsOpen(false);
+ if (deviceId) {
+ const streamId = VideoService.getMyStreamId(deviceId);
+ if (streamId) VideoService.stopVideo(streamId);
+ } else {
+ VideoService.exitVideo();
+ }
+ },
+ stopSharingCameraAsContent: () => {
+ callbackToClose();
+ setIsOpen(false);
+ ScreenShareService.screenshareHasEnded();
+ },
+ sharedDevices: VideoService.getSharedDevices(),
+ cameraAsContentDeviceId: ScreenShareService.getCameraAsContentDeviceId(),
+ isCamLocked: VideoService.isUserLocked(),
+ camCapReached: VideoService.hasCapReached(),
+ closeModal: () => {
+ callbackToClose();
+ setIsOpen(false);
+ },
+ webcamDeviceId: Service.webcamDeviceId(),
+ hasVideoStream: VideoService.hasVideoStream(),
+}))(VideoPreviewContainer);
diff --git a/src/2.7.12/imports/ui/components/video-preview/service.js b/src/2.7.12/imports/ui/components/video-preview/service.js
new file mode 100644
index 00000000..e0701b6c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/service.js
@@ -0,0 +1,252 @@
+import Storage from '/imports/ui/services/storage/session';
+import BBBStorage from '/imports/ui/services/storage';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+import VideoService from '/imports/ui/components/video-provider/service';
+import BBBVideoStream from '/imports/ui/services/webrtc-base/bbb-video-stream';
+import browserInfo from '/imports/utils/browserInfo';
+
+const GUM_TIMEOUT = Meteor.settings.public.kurento.gUMTimeout;
+// GUM retry + delay params (Chrome only for now)
+const GUM_MAX_RETRIES = 5;
+const GUM_RETRY_DELAY = 200;
+// Unfiltered, includes hidden profiles
+const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles || [];
+// Filtered, without hidden profiles
+const PREVIEW_CAMERA_PROFILES = CAMERA_PROFILES.filter(p => !p.hidden);
+const CAMERA_AS_CONTENT_PROFILE_ID = 'fhd';
+
+const getDefaultProfile = () => {
+ return CAMERA_PROFILES.find(profile => profile.id === BBBStorage.getItem('WebcamProfileId'))
+ || CAMERA_PROFILES.find(profile => profile.id === VideoService.getUserParameterProfile())
+ || CAMERA_PROFILES.find(profile => profile.default)
+ || CAMERA_PROFILES[0];
+}
+
+const getCameraAsContentProfile = () => {
+ return CAMERA_PROFILES.find(profile => profile.id == CAMERA_AS_CONTENT_PROFILE_ID)
+ || CAMERA_PROFILES.find(profile => profile.default)
+}
+
+const getCameraProfile = (id) => {
+ return CAMERA_PROFILES.find(profile => profile.id === id);
+}
+
+// VIDEO_STREAM_STORAGE: Map. Registers WEBCAM streams.
+// Easier to keep track of them. Easier to centralize their referencing.
+// Easier to shuffle them around.
+const VIDEO_STREAM_STORAGE = new Map();
+
+const storeStream = (deviceId, stream) => {
+ if (!stream) return false;
+
+ // Check if there's a dangling stream. If there's one and it's active, cool,
+ // return false as it's already stored. Otherwised, clean the derelict stream
+ // and store the new one
+ if (hasStream(deviceId)) {
+ const existingStream = getStream(deviceId);
+ if (existingStream.active) return false;
+ deleteStream(deviceId);
+ }
+
+ VIDEO_STREAM_STORAGE.set(deviceId, stream);
+
+ // Stream insurance: clean it up if it ends (see the events being listened to below)
+ stream.once('inactive', () => {
+ deleteStream(deviceId);
+ });
+
+ return true;
+}
+
+const getStream = (deviceId) => {
+ return VIDEO_STREAM_STORAGE.get(deviceId);
+}
+
+const hasStream = (deviceId) => {
+ return VIDEO_STREAM_STORAGE.has(deviceId);
+}
+
+const deleteStream = (deviceId) => {
+ const stream = getStream(deviceId);
+ if (stream == null) return false;
+ MediaStreamUtils.stopMediaStreamTracks(stream);
+ return VIDEO_STREAM_STORAGE.delete(deviceId);
+}
+
+const clearStreams = () => VIDEO_STREAM_STORAGE.clear();
+
+const promiseTimeout = (ms, promise) => {
+ const timeout = new Promise((resolve, reject) => {
+ const id = setTimeout(() => {
+ clearTimeout(id);
+
+ const error = {
+ name: 'TimeoutError',
+ message: 'Promise did not return',
+ };
+
+ reject(error);
+ }, ms);
+ });
+
+ return Promise.race([
+ promise,
+ timeout,
+ ]);
+};
+
+const getSkipVideoPreview = () => {
+ const KURENTO_CONFIG = Meteor.settings.public.kurento;
+
+ const skipVideoPreviewOnFirstJoin = getFromUserSettings(
+ 'bbb_skip_video_preview_on_first_join',
+ KURENTO_CONFIG.skipVideoPreviewOnFirstJoin,
+ );
+ const skipVideoPreview = getFromUserSettings(
+ 'bbb_skip_video_preview',
+ KURENTO_CONFIG.skipVideoPreview,
+ );
+
+ return (
+ (Storage.getItem('isFirstJoin') !== false && skipVideoPreviewOnFirstJoin)
+ || skipVideoPreview
+ );
+};
+
+// Takes a raw list of media devices of any media type coming enumerateDevices
+// and a deviceId to be prioritized
+// Outputs an object containing:
+// webcams: videoinput media devices, priorityDevice being the first member of the array (if it exists)
+// areLabelled: whether all videoinput devices are labelled
+// areIdentified: whether all videoinput devices have deviceIds
+const digestVideoDevices = (devices, priorityDevice) => {
+ const webcams = [];
+ let areLabelled = true;
+ let areIdentified = true;
+
+ devices.forEach((device) => {
+ if (device.kind === 'videoinput') {
+ if (!webcams.some(d => d.deviceId === device.deviceId)) {
+ // We found a priority device. Push it to the beginning of the array so we
+ // can use it as the "initial device"
+ if (priorityDevice && priorityDevice === device.deviceId) {
+ webcams.unshift(device);
+ } else {
+ webcams.push(device);
+ }
+
+ if (!device.label) { areLabelled = false }
+ if (!device.deviceId) { areIdentified = false }
+ }
+ }
+ });
+
+ // Returns the list of devices and whether they are labelled and identified with deviceId
+ return {
+ webcams,
+ areLabelled,
+ areIdentified,
+ };
+};
+
+const _retry = (foo, opts) => new Promise((resolve, reject) => {
+ const {
+ retries = 1,
+ delay = 0,
+ error: bubbledError,
+ errorRetryList = [],
+ } = opts;
+
+ if (!retries) return reject(bubbledError);
+
+ return foo().then(resolve).catch((_error) => {
+ if (errorRetryList.length > 0
+ && !errorRetryList.some((eName) => _error.name === eName)) {
+ reject(_error);
+ return;
+ }
+
+ const newOpts = {
+ ...opts,
+ retries: retries - 1,
+ error: _error,
+ };
+
+ setTimeout(() => {
+ _retry(foo, newOpts).then(resolve).catch(reject);
+ }, delay);
+ });
+});
+
+// Returns a promise that resolves an instance of BBBVideoStream or rejects an *Error
+const doGUM = (deviceId, profile) => {
+ // Check if this is an already loaded stream
+ if (deviceId && hasStream(deviceId)) {
+ return Promise.resolve(getStream(deviceId));
+ }
+
+ const constraints = {
+ audio: false,
+ video: { ...profile.constraints },
+ };
+
+ if (deviceId) {
+ constraints.video.deviceId = { exact: deviceId };
+ }
+
+ const postProcessedgUM = (cts) => {
+ const ppGUM = () => navigator.mediaDevices.getUserMedia(cts)
+ .then((stream) => new BBBVideoStream(stream));
+
+ // Chrome/Edge sometimes bork gUM calls when switching camera
+ // profiles. This looks like a browser bug. Track release not
+ // being done synchronously -> quick subsequent gUM calls for the same
+ // device (profile switching) -> device becoming unavaible while previous
+ // tracks aren't finished - prlanzarin
+ if (browserInfo.isChrome || browserInfo.isEdge) {
+ const opts = {
+ retries: GUM_MAX_RETRIES,
+ errorRetryList: ['NotReadableError'],
+ delay: GUM_RETRY_DELAY,
+ };
+ return _retry(ppGUM, opts);
+ }
+
+ return ppGUM();
+ };
+
+ return promiseTimeout(GUM_TIMEOUT, postProcessedgUM(constraints));
+};
+
+const terminateCameraStream = (bbbVideoStream, deviceId) => {
+ // Cleanup current stream if it wasn't shared/stored
+ if (bbbVideoStream && !hasStream(deviceId)) {
+ bbbVideoStream.stop()
+ }
+}
+
+export default {
+ PREVIEW_CAMERA_PROFILES,
+ CAMERA_PROFILES,
+ promiseTimeout,
+ changeWebcam: (deviceId) => {
+ BBBStorage.setItem('WebcamDeviceId', deviceId);
+ },
+ webcamDeviceId: () => BBBStorage.getItem('WebcamDeviceId'),
+ changeProfile: (profileId) => {
+ BBBStorage.setItem('WebcamProfileId', profileId);
+ },
+ getSkipVideoPreview,
+ storeStream,
+ getStream,
+ hasStream,
+ deleteStream,
+ digestVideoDevices,
+ getDefaultProfile,
+ getCameraAsContentProfile,
+ getCameraProfile,
+ doGUM,
+ terminateCameraStream,
+ clearStreams,
+};
diff --git a/src/2.7.12/imports/ui/components/video-preview/styles.js b/src/2.7.12/imports/ui/components/video-preview/styles.js
new file mode 100644
index 00000000..22e94bd0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/styles.js
@@ -0,0 +1,267 @@
+import styled, { css, keyframes } from 'styled-components';
+import {
+ borderSize,
+ borderSizeLarge,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorGrayLabel,
+ colorWhite,
+ colorGrayLighter,
+ colorPrimary,
+ colorText,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeLarge,
+ lineHeightComputed,
+ headingsFontWeight,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import { smallOnly, landscape } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import ModalStyles from '/imports/ui/components/common/modal/simple/styles';
+
+const Warning = styled.div`
+ text-align: center;
+ font-weight: ${headingsFontWeight};
+ font-size: 5rem;
+ white-space: normal;
+`;
+
+const Main = styled.h4`
+ margin: ${lineHeightComputed};
+ text-align: center;
+ font-size: ${fontSizeLarge};
+`;
+
+const Text = styled.div`
+ margin: ${lineHeightComputed};
+ text-align: center;
+`;
+
+const Col = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ justify-content: center;
+ margin: 0 0.5rem 0 0.5rem;
+
+ width: 50%;
+ @media ${smallOnly} {
+ width: 90%;
+ height: unset;
+ }
+`;
+
+const VideoCol = styled(Col)`
+ align-items: center;
+
+ @media ${landscape} {
+ width: 33.3%;
+ }
+`;
+
+const Label = styled.label`
+ margin-top: 8px;
+ font-size: 0.85rem;
+ font-weight: bold;
+ color: ${colorGrayLabel};
+`;
+
+const Select = styled.select`
+ background-color: ${colorWhite};
+ border: ${borderSize} solid ${colorWhite};
+ border-radius: ${borderSize};
+ border-bottom: 0.1rem solid ${colorGrayLighter};
+ color: ${colorGrayLabel};
+ width: 100%;
+ height: 1.75rem;
+ padding: 1px;
+
+ &:focus {
+ outline: none;
+ border-radius: ${borderSize};
+ box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary};
+ }
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+`;
+
+const Content = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ overflow: auto;
+ color: ${colorText};
+ font-weight: normal;
+ padding: ${lineHeightComputed} 0;
+
+ @media ${smallOnly} {
+ flex-direction: column;
+ margin: 0;
+ }
+`;
+
+const BrowserWarning = styled.p`
+ padding: 0.5rem;
+ border-width: ${borderSizeLarge};
+ border-style: solid;
+ border-radius: 0.25rem;
+ margin: ${lineHeightComputed};
+ text-align: center;
+`;
+
+const Footer = styled.div`
+ display: flex;
+`;
+
+const Actions = styled.div`
+ margin-left: auto;
+ margin-right: auto;
+
+ [dir="rtl"] & {
+ margin-right: auto;
+ background:red;
+ margin-left: ${borderSizeLarge};
+ }
+
+ & > :first-child {
+ margin-right: ${borderSizeLarge};
+ margin-left: inherit;
+
+ [dir="rtl"] & {
+ margin-right: inherit;
+ margin-left: ${borderSizeLarge};
+ }
+ }
+`;
+
+const ExtraActions = styled.div`
+ margin-right: auto;
+ margin-left: ${borderSizeLarge};
+
+ [dir="rtl"] & {
+ margin-left: auto;
+ margin-right: ${borderSizeLarge};
+ }
+
+ & > :first-child {
+ margin-left: ${borderSizeLarge};
+ margin-right: inherit;
+
+ [dir="rtl"] & {
+ margin-left: inherit;
+ margin-right: ${borderSizeLarge};
+ }
+ }
+`;
+
+const VideoPreviewModal = styled(ModalSimple)`
+ padding: 1rem;
+ min-height: 25rem;
+ max-height: 100vh;
+
+ @media ${smallOnly} {
+ height: unset;
+ min-height: 22.5rem;
+ max-height: unset;
+ }
+
+ ${({ isPhone }) => isPhone && `
+ min-height: 100%;
+ min-width: 100%;
+ border-radius: 0;
+ `}
+
+ ${ModalStyles.Content} {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ }
+`;
+
+const ellipsis = keyframes`
+ to {
+ width: 1.5em;
+ }
+`;
+
+const FetchingAnimation = styled.span`
+ margin: auto;
+ display: inline-block;
+ width: 1.5em;
+
+ &:after {
+ overflow: hidden;
+ display: inline-block;
+ vertical-align: bottom;
+ content: "\\2026"; /* ascii code for the ellipsis character */
+ width: 0;
+ margin-left: 0.25em;
+
+ ${({ animations }) => animations && css`
+ animation: ${ellipsis} steps(4, end) 900ms infinite;
+ `}
+ }
+`;
+
+const VideoPreview = styled.video`
+ height: 100%;
+ width: 100%;
+
+ @media ${smallOnly} {
+ height: 10rem;
+ }
+
+ ${({ mirroredVideo }) => mirroredVideo && `
+ transform: scale(-1, 1);
+ `}
+`;
+
+const Marker = styled.div`
+ width: 2rem;
+ text-align: center;
+`;
+
+const MarkerDynamic = styled(Marker)`
+ position: absolute;
+ top: 0;
+`;
+
+const MarkerWrapper = styled.div`
+ display: flex;
+ justify-content: space-between;
+ user-select: none;
+`;
+
+const MarkerDynamicWrapper = styled.div`
+ position: relative;
+ height: 1rem;
+ user-select: none;
+`;
+
+export default {
+ Warning,
+ Main,
+ Text,
+ Col,
+ VideoCol,
+ Label,
+ Select,
+ Content,
+ BrowserWarning,
+ Footer,
+ Actions,
+ ExtraActions,
+ VideoPreviewModal,
+ FetchingAnimation,
+ VideoPreview,
+ Marker,
+ MarkerDynamic,
+ MarkerWrapper,
+ MarkerDynamicWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/video-preview/virtual-background/component.jsx b/src/2.7.12/imports/ui/components/video-preview/virtual-background/component.jsx
new file mode 100644
index 00000000..1244a84c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/virtual-background/component.jsx
@@ -0,0 +1,523 @@
+import React, { useState, useRef, useContext, useEffect } from 'react';
+import { findDOMNode } from 'react-dom';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import {
+ EFFECT_TYPES,
+ BLUR_FILENAME,
+ IMAGE_NAMES,
+ getVirtualBackgroundThumbnail,
+ isVirtualBackgroundSupported,
+} from '/imports/ui/services/virtual-background/service';
+import { CustomVirtualBackgroundsContext } from './context';
+import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service';
+import logger from '/imports/startup/client/logger';
+import withFileReader from '/imports/ui/components/common/file-reader/component';
+import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
+import 'react-loading-skeleton/dist/skeleton.css';
+import Settings from '/imports/ui/services/settings';
+import { isCustomVirtualBackgroundsEnabled } from '/imports/ui/services/features';
+
+const { MIME_TYPES_ALLOWED, MAX_FILE_SIZE } = VirtualBgService;
+const ENABLE_CAMERA_BRIGHTNESS = Meteor.settings.public.app.enableCameraBrightness;
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ handleVirtualBgSelected: PropTypes.func.isRequired,
+ locked: PropTypes.bool.isRequired,
+ showThumbnails: PropTypes.bool,
+ initialVirtualBgState: PropTypes.shape({
+ type: PropTypes.string.isRequired,
+ name: PropTypes.string,
+ }),
+};
+
+const intlMessages = defineMessages({
+ virtualBackgroundSettingsLabel: {
+ id: 'app.videoPreview.webcamVirtualBackgroundLabel',
+ description: 'Label for the virtual background',
+ },
+ virtualBackgroundSettingsDisabledLabel: {
+ id: 'app.videoPreview.webcamVirtualBackgroundDisabledLabel',
+ description: 'Label for the unsupported virtual background',
+ },
+ noneLabel: {
+ id: 'app.video.virtualBackground.none',
+ description: 'Label for no virtual background selected',
+ },
+ customLabel: {
+ id: 'app.video.virtualBackground.custom',
+ description: 'Label for custom virtual background selected',
+ },
+ removeLabel: {
+ id: 'app.video.virtualBackground.remove',
+ description: 'Label for remove custom virtual background',
+ },
+ blurLabel: {
+ id: 'app.video.virtualBackground.blur',
+ description: 'Label for the blurred camera option',
+ },
+ camBgAriaDesc: {
+ id: 'app.video.virtualBackground.camBgAriaDesc',
+ description: 'Label for virtual background button aria',
+ },
+ customDesc: {
+ id: 'app.video.virtualBackground.button.customDesc',
+ description: 'Aria description for upload virtual background button',
+ },
+ background: {
+ id: 'app.video.virtualBackground.background',
+ description: 'Label for the background word',
+ },
+ backgroundWithIndex: {
+ id: 'app.video.virtualBackground.backgroundWithIndex',
+ description: 'Label for the background word indexed',
+ },
+ ...IMAGE_NAMES.reduce((prev, imageName) => {
+ const id = imageName.split('.').shift();
+ return {
+ ...prev,
+ [id]: {
+ id: `app.video.virtualBackground.${id}`,
+ description: `Label for the ${id} camera option`,
+ defaultMessage: '{background} {index}',
+ },
+ };
+ }, {})
+});
+
+const SKELETON_COUNT = 5;
+const VIRTUAL_BACKGROUNDS_CONFIG = Meteor.settings.public.virtualBackgrounds;
+const ENABLE_UPLOAD = VIRTUAL_BACKGROUNDS_CONFIG.enableVirtualBackgroundUpload;
+const shouldEnableBackgroundUpload = () => ENABLE_UPLOAD && isCustomVirtualBackgroundsEnabled();
+
+const VirtualBgSelector = ({
+ intl,
+ handleVirtualBgSelected,
+ locked,
+ showThumbnails,
+ initialVirtualBgState,
+ isVisualEffects,
+ readFile,
+}) => {
+ const [currentVirtualBg, setCurrentVirtualBg] = useState({
+ ...initialVirtualBgState,
+ });
+
+ const inputElementsRef = useRef([]);
+ const customBgSelectorRef = useRef(null);
+
+ const {
+ dispatch,
+ loaded,
+ defaultSetUp,
+ backgrounds,
+ loadFromDB,
+ } = useContext(CustomVirtualBackgroundsContext);
+
+ const { MIME_TYPES_ALLOWED } = VirtualBgService;
+
+ useEffect(() => {
+ if (shouldEnableBackgroundUpload()) {
+ if (!defaultSetUp) {
+ const defaultBackgrounds = ['Blur', ...IMAGE_NAMES].map((imageName) => ({
+ uniqueId: imageName,
+ custom: false,
+ lastActivityDate: Date.now(),
+ }));
+ dispatch({
+ type: 'setDefault',
+ backgrounds: defaultBackgrounds,
+ });
+ }
+ if (!loaded) loadFromDB();
+ }
+ }, []);
+
+ const _virtualBgSelected = (type, name, index, customParams) =>
+ handleVirtualBgSelected(type, name, customParams)
+ .then(switched => {
+ // Reset to the base NONE_TYPE effect if it failed because the expected
+ // behaviour from upstream's method is to actually stop/reset the effect
+ // service if it fails
+ if (!switched) {
+ return setCurrentVirtualBg({ type: EFFECT_TYPES.NONE_TYPE });
+ }
+
+ setCurrentVirtualBg({ type, name });
+
+ if (!index || index < 0) return;
+
+ if (!shouldEnableBackgroundUpload()) {
+ findDOMNode(inputElementsRef.current[index]).focus();
+ } else {
+ if (customParams) {
+ dispatch({
+ type: 'update',
+ background: {
+ filename: name,
+ uniqueId: customParams.uniqueId,
+ data: customParams.file,
+ sessionOnly: customParams.sessionOnly,
+ custom: true,
+ lastActivityDate: Date.now(),
+ },
+ });
+ } else {
+ dispatch({
+ type: 'update',
+ background: {
+ uniqueId: name,
+ custom: false,
+ lastActivityDate: Date.now(),
+ },
+ });
+ }
+ findDOMNode(inputElementsRef.current[0]).focus();
+ }
+ });
+
+ const renderDropdownSelector = () => {
+ const disabled = locked || !isVirtualBackgroundSupported();
+
+ return (
+
+ {
+ const { type, name } = JSON.parse(event.target.value);
+ _virtualBgSelected(type, name);
+ }}
+ >
+
+ {intl.formatMessage(intlMessages.noneLabel)}
+
+
+
+ {intl.formatMessage(intlMessages.blurLabel)}
+
+
+ {IMAGE_NAMES.map((imageName, i) => {
+ const k = `${imageName}-${i}`;
+ return (
+
+ {imageName.split(".")[0]}
+
+ );
+ })}
+
+
+ );
+ }
+
+ const handleCustomBgChange = (event) => {
+ const file = event.target.files[0];
+
+ const onSuccess = (background) => {
+ dispatch({
+ type: 'new',
+ background: {
+ ...background,
+ custom: true,
+ lastActivityDate: Date.now(),
+ },
+ });
+ const { filename, data, uniqueId } = background;
+ _virtualBgSelected(
+ EFFECT_TYPES.IMAGE_TYPE,
+ filename,
+ 0,
+ { file: data, uniqueId },
+ );
+ };
+
+ const onError = (error) => {
+ logger.warn({
+ logCode: 'read_file_error',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, error.message);
+ };
+
+ readFile(file, onSuccess, onError);
+ };
+
+ const renderThumbnailSelector = () => {
+ const disabled = locked || !isVirtualBackgroundSupported();
+ const isRTL = Settings.application.isRTL;
+
+ const renderBlurButton = (index) => {
+ return (
+
+ { inputElementsRef.current[index] = ref; }}
+ onClick={() => _virtualBgSelected(EFFECT_TYPES.BLUR_TYPE, 'Blur', index)}
+ isVisualEffects={isVisualEffects}
+ />
+
+ {intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.BLUR_TYPE })}
+
+
+ );
+ };
+
+ const renderDefaultButton = (imageName, index) => {
+ const label = intl.formatMessage(intlMessages[imageName.split('.').shift()], {
+ index: index + 1,
+ background: intl.formatMessage(intlMessages.background),
+ });
+
+ return (
+
+ inputElementsRef.current[index] = ref}
+ onClick={() => _virtualBgSelected(EFFECT_TYPES.IMAGE_TYPE, imageName, index)}
+ disabled={disabled}
+ isVisualEffects={isVisualEffects}
+ background={getVirtualBackgroundThumbnail(imageName)}
+ data-test="selectDefaultBackground"
+ />
+
+ {intl.formatMessage(intlMessages.camBgAriaDesc, { 0: label })}
+
+
+ )
+ };
+
+ const renderCustomButton = (background, index) => {
+ const {
+ filename, data, uniqueId, sessionOnly,
+ } = background;
+ const label = intl.formatMessage(intlMessages.backgroundWithIndex, {
+ 0: index + 1,
+ });
+
+ return (
+
+ inputElementsRef.current[index] = ref}
+ onClick={() => _virtualBgSelected(
+ EFFECT_TYPES.IMAGE_TYPE,
+ filename,
+ index,
+ { file: data, uniqueId, sessionOnly },
+ )}
+ disabled={disabled}
+ isVisualEffects={isVisualEffects}
+ background={data}
+ data-test="selectCustomBackground"
+ />
+
+ {
+ dispatch({
+ type: 'delete',
+ uniqueId,
+ });
+ _virtualBgSelected(EFFECT_TYPES.NONE_TYPE);
+ }}
+ />
+
+
+ {label}
+
+
+ );
+ };
+
+ const renderInputButton = () => (
+ <>
+ {
+ if (customBgSelectorRef.current) {
+ customBgSelectorRef.current.click();
+ }
+ }}
+ isVisualEffects={isVisualEffects}
+ data-test="inputBackgroundButton"
+ />
+
+
+ {intl.formatMessage(intlMessages.customDesc)}
+
+ >
+ );
+
+ const renderNoneButton = () => (
+ <>
+ _virtualBgSelected(EFFECT_TYPES.NONE_TYPE)}
+ isVisualEffects={isVisualEffects}
+ data-test="noneBackgroundButton"
+ />
+
+ {intl.formatMessage(intlMessages.camBgAriaDesc, { 0: EFFECT_TYPES.NONE_TYPE })}
+
+ >
+ );
+
+ const renderSkeleton = () => (
+
+ {new Array(SKELETON_COUNT).fill(null).map((_, index) => (
+
+
+
+ ))}
+
+ );
+
+ const ready = loaded && defaultSetUp;
+
+ return (
+
+
+ {shouldEnableBackgroundUpload() && (
+ <>
+ {!ready && renderSkeleton()}
+
+ {ready && (
+ <>
+ {renderNoneButton()}
+
+ {Object.values(backgrounds)
+ .sort((a, b) => b.lastActivityDate - a.lastActivityDate)
+ .map((background, index) => {
+ if (background.custom !== false) {
+ return renderCustomButton(background, index);
+ }
+ const isBlur = background.uniqueId.includes('Blur');
+ return isBlur
+ ? renderBlurButton(index)
+ : renderDefaultButton(background.uniqueId, index);
+ })}
+
+ {renderInputButton()}
+ >
+ )}
+ >
+ )}
+
+ {!shouldEnableBackgroundUpload() && (
+ <>
+ {renderNoneButton()}
+
+ {renderBlurButton(0)}
+
+ {IMAGE_NAMES.map((imageName, index) => {
+ const actualIndex = index + 1;
+ return renderDefaultButton(imageName, actualIndex);
+ })}
+ >
+ )}
+
+
+ );
+ };
+
+ const renderSelector = () => {
+ if (showThumbnails) return renderThumbnailSelector();
+ return renderDropdownSelector();
+ };
+
+ return (
+ <>
+ {!isVisualEffects && (
+
+ {!isVirtualBackgroundSupported()
+ ? intl.formatMessage(intlMessages.virtualBackgroundSettingsDisabledLabel)
+ : intl.formatMessage(intlMessages.virtualBackgroundSettingsLabel)}
+
+ )}
+
+ {renderSelector()}
+ >
+ );
+};
+
+VirtualBgSelector.propTypes = propTypes;
+VirtualBgSelector.defaultProps = {
+ showThumbnails: false,
+ initialVirtualBgState: {
+ type: EFFECT_TYPES.NONE_TYPE,
+ },
+};
+
+export default injectIntl(withFileReader(VirtualBgSelector, MIME_TYPES_ALLOWED, MAX_FILE_SIZE));
diff --git a/src/2.7.12/imports/ui/components/video-preview/virtual-background/context.jsx b/src/2.7.12/imports/ui/components/video-preview/virtual-background/context.jsx
new file mode 100644
index 00000000..1f051331
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/virtual-background/context.jsx
@@ -0,0 +1,104 @@
+import React, { useReducer } from 'react';
+import { throttle } from '/imports/utils/throttle';
+import Service from './service';
+
+export const CustomVirtualBackgroundsContext = React.createContext();
+
+const reducer = (state, action) => {
+ const { save, del, update } = Service;
+
+ switch (action.type) {
+ case 'load': {
+ const backgrounds = { ...state.backgrounds };
+ action.backgrounds.forEach((background) => {
+ backgrounds[background.uniqueId] = background;
+ });
+ return {
+ ...state,
+ loaded: true,
+ backgrounds,
+ };
+ }
+ case 'new': {
+ save(action.background);
+ return {
+ ...state,
+ backgrounds: {
+ ...state.backgrounds,
+ [action.background.uniqueId]: action.background,
+ },
+ };
+ }
+ case 'delete': {
+ const { backgrounds } = state;
+ delete backgrounds[action.uniqueId];
+ del(action.uniqueId);
+ return {
+ ...state,
+ backgrounds,
+ };
+ }
+ case 'update': {
+ if (action.background.custom && !action.background.sessionOnly) update(action.background);
+ return {
+ ...state,
+ backgrounds: {
+ ...state.backgrounds,
+ [action.background.uniqueId]: action.background,
+ },
+ };
+ }
+ case 'setDefault': {
+ const backgrounds = { ...state.backgrounds };
+ action.backgrounds.forEach((background) => {
+ backgrounds[background.uniqueId] = background;
+ });
+ return {
+ ...state,
+ defaultSetUp: true,
+ backgrounds,
+ };
+ }
+ default: {
+ throw new Error('Unknown custom background action.');
+ }
+ }
+};
+
+export const CustomBackgroundsProvider = ({ children }) => {
+ const [state, dispatch] = useReducer(reducer, {
+ loaded: false,
+ defaultSetUp: false,
+ backgrounds: {},
+ });
+
+ const { load } = Service;
+
+ const loadFromDB = () => {
+ const onError = () => dispatch({
+ type: 'load',
+ backgrounds: [],
+ });
+
+ const onSuccess = (backgrounds) => dispatch({
+ type: 'load',
+ backgrounds,
+ });
+
+ load(onError, onSuccess);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/2.7.12/imports/ui/components/video-preview/virtual-background/service.js b/src/2.7.12/imports/ui/components/video-preview/virtual-background/service.js
new file mode 100644
index 00000000..0a4e2da2
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/virtual-background/service.js
@@ -0,0 +1,137 @@
+import logger from '/imports/startup/client/logger';
+
+const MIME_TYPES_ALLOWED = ['image/png', 'image/jpeg', 'image/webp'];
+const MAX_FILE_SIZE = 5000; // KBytes
+
+const withObjectStore = ({
+ onError,
+ onSuccess,
+}) => {
+ const request = window.indexedDB.open('BBB', 1);
+
+ request.onerror = onError;
+
+ request.onupgradeneeded = (e) => {
+ const db = e.target.result;
+ db.createObjectStore('CustomBackgrounds', { keyPath: 'uniqueId' });
+ };
+
+ request.onsuccess = (e) => {
+ const db = e.target.result;
+ const transaction = db.transaction(['CustomBackgrounds'], 'readwrite');
+ const objectStore = transaction.objectStore('CustomBackgrounds');
+
+ onSuccess(objectStore);
+ };
+};
+
+const genericErrorHandlerBuilder = (
+ code,
+ message,
+ callback,
+) => (e) => {
+ logger.warn({
+ logCode: code,
+ extraInfo: {
+ errorName: e.name,
+ errorMessage: e.message,
+ },
+ }, `${message}: ${e.message}`);
+
+ if (callback) callback(e);
+};
+
+const load = (onError, onSuccess) => {
+ withObjectStore({
+ onError: genericErrorHandlerBuilder(
+ 'IndexedDB_access',
+ 'Error on load custom backgrounds from IndexedDB',
+ onError,
+ ),
+ onSuccess: (objectStore) => {
+ const backgrounds = [];
+
+ objectStore.openCursor().onsuccess = (e) => {
+ const cursor = e.target.result;
+
+ if (cursor) {
+ backgrounds.push(cursor.value);
+ cursor.continue();
+ } else {
+ onSuccess(backgrounds);
+ }
+ };
+ },
+ });
+};
+
+const save = (background, onError) => {
+ withObjectStore({
+ onError: genericErrorHandlerBuilder(
+ 'IndexedDB_access',
+ 'Error on save custom background to IndexedDB',
+ onError,
+ ),
+ onSuccess: (objectStore) => {
+ objectStore.add(background);
+ },
+ });
+};
+
+const del = (key, onError) => {
+ withObjectStore({
+ onError: genericErrorHandlerBuilder(
+ 'IndexedDB_access',
+ 'Error on delete custom background from IndexedDB',
+ onError,
+ ),
+ onSuccess: (objectStore) => {
+ objectStore.delete(key);
+ },
+ });
+};
+
+const update = (background) => {
+ withObjectStore({
+ onError: genericErrorHandlerBuilder(
+ 'IndexedDB_access',
+ 'Error on update custom background in IndexedDB',
+ 'Something wrong while updating custom background',
+ ),
+ onSuccess: (objectStore) => {
+ objectStore.put(background);
+ },
+ });
+};
+
+// Function to convert image URL to a File object
+async function getFileFromUrl(url) {
+ try {
+ const response = await fetch(url, {
+ credentials: 'omit',
+ mode: 'cors',
+ headers: {
+ Accept: 'image/*',
+ },
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const blob = await response.blob();
+ const file = new File([blob], 'fetchedWebcamBackground', { type: blob.type });
+ return file;
+ } catch (error) {
+ logger.error('Fetch error:', error);
+ return null;
+ }
+}
+
+export default {
+ load,
+ save,
+ del,
+ update,
+ MIME_TYPES_ALLOWED,
+ MAX_FILE_SIZE,
+ getFileFromUrl,
+};
diff --git a/src/2.7.12/imports/ui/components/video-preview/virtual-background/styles.js b/src/2.7.12/imports/ui/components/video-preview/virtual-background/styles.js
new file mode 100644
index 00000000..d02d983a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-preview/virtual-background/styles.js
@@ -0,0 +1,181 @@
+import styled from 'styled-components';
+import {
+ borderSize,
+ borderSizeLarge,
+ borderSizeSmall,
+ smPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ userThumbnailBorder,
+ btnPrimaryBorder,
+ btnDefaultColor,
+ colorGrayLabel,
+ colorGrayLighter,
+ colorPrimary,
+ colorWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+import { fontSizeSmallest } from '/imports/ui/stylesheets/styled-components/typography';
+import Button from '/imports/ui/components/common/button/component';
+
+const VirtualBackgroundRowThumbnail = styled.div`
+ margin-top: 0.4rem;
+`;
+
+const BgWrapper = styled(ScrollboxVertical)`
+ display: flex;
+ justify-content: flex-start;
+ overflow-x: auto;
+ margin: ${borderSizeLarge};
+ padding: ${borderSizeLarge};
+
+ ${({ isVisualEffects }) => isVisualEffects && `
+ height: 15rem;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ `}
+
+ ${({ brightnessEnabled, isVisualEffects }) => brightnessEnabled && isVisualEffects && `
+ height: 10rem;
+ `}
+`;
+
+const BgNoneButton = styled(Button)`
+ border-radius: ${borderSizeLarge};
+ height: 48px;
+ width: 48px;
+ border: ${borderSizeSmall} solid ${userThumbnailBorder};
+ margin: 0 0.15rem;
+ flex-shrink: 0;
+
+ ${({ isVisualEffects }) => isVisualEffects && `
+ margin: 0.15rem;
+ `}
+`;
+
+const ThumbnailButton = styled(Button)`
+ outline: none;
+ display: flex;
+ position: relative;
+ justify-content: center;
+ align-items: center;
+ border-radius: ${borderSizeLarge};
+ cursor: pointer;
+ height: 48px;
+ width: 48px;
+ z-index: 1;
+ background-color: transparent;
+ border: ${borderSizeSmall} solid ${userThumbnailBorder};
+ flex-shrink: 0;
+
+ & + img {
+ border-radius: ${borderSizeLarge};
+ }
+
+ &:focus {
+ color: ${btnDefaultColor};
+ background-color: transparent;
+ background-clip: padding-box;
+ box-shadow: 0 0 0 ${borderSize} ${btnPrimaryBorder};
+ }
+
+ ${({ disabled }) => disabled && `
+ filter: grayscale(1);
+
+ & + img {
+ filter: grayscale(1);
+ }
+ `}
+
+ ${({ background }) => background && `
+ background-image: url(${background});
+ background-size: cover;
+ background-position: center;
+ background-origin: padding-box;
+
+ &:active {
+ background-image: url(${background});
+ }
+ `}
+`;
+
+const Select = styled.select`
+ background-color: ${colorWhite};
+ border: ${borderSize} solid ${colorWhite};
+ border-radius: ${borderSize};
+ border-bottom: 0.1rem solid ${colorGrayLighter};
+ color: ${colorGrayLabel};
+ width: 100%;
+ height: 1.75rem;
+ padding: 1px;
+
+ &:focus {
+ outline: none;
+ box-shadow: inset 0 0 0 ${borderSizeLarge} ${colorPrimary};
+ border-radius: ${borderSize};
+ }
+
+ &:hover,
+ &:focus {
+ outline: transparent;
+ outline-style: dotted;
+ outline-width: ${borderSize};
+ }
+`;
+
+const Label = styled.label`
+ margin-top: 8px;
+ font-size: 0.85rem;
+ font-weight: bold;
+ color: ${colorGrayLabel};
+`;
+
+const ThumbnailButtonWrapper = styled.div`
+ position: relative;
+ margin: 0 0.15rem;
+
+ ${({ isVisualEffects }) => isVisualEffects && `
+ margin: 0.15rem;
+ `}
+`;
+
+const ButtonWrapper = styled.div`
+ position: absolute;
+ z-index: 2;
+ right: 0;
+ top: 0;
+`;
+
+const ButtonRemove = styled(Button)`
+ span {
+ font-size: ${fontSizeSmallest};
+ padding: ${smPaddingY};
+ }
+`;
+
+const BgCustomButton = styled(BgNoneButton)``;
+
+const SkeletonWrapper = styled.div`
+ flex-basis: 0 0 48px;
+ margin: 0 0.15rem;
+ height: 48px;
+
+ & .react-loading-skeleton {
+ height: 48px;
+ width: 48px;
+ }
+`;
+
+export default {
+ VirtualBackgroundRowThumbnail,
+ BgWrapper,
+ BgNoneButton,
+ ThumbnailButton,
+ Select,
+ Label,
+ ThumbnailButtonWrapper,
+ ButtonWrapper,
+ ButtonRemove,
+ BgCustomButton,
+ SkeletonWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/component.jsx b/src/2.7.12/imports/ui/components/video-provider/component.jsx
new file mode 100644
index 00000000..9d88dd82
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/component.jsx
@@ -0,0 +1,1280 @@
+/* eslint react/sort-comp: 0 */
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import ReconnectingWebSocket from 'reconnecting-websocket';
+import { defineMessages, injectIntl } from 'react-intl';
+import { debounce } from '/imports/utils/debounce';
+import VideoService from './service';
+import VideoListContainer from './video-list/container';
+import {
+ fetchWebRTCMappedStunTurnServers,
+ getMappedFallbackStun,
+} from '/imports/utils/fetchStunTurnServers';
+import logger from '/imports/startup/client/logger';
+import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
+import VideoPreviewService from '../video-preview/service';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+import BBBVideoStream from '/imports/ui/services/webrtc-base/bbb-video-stream';
+import {
+ EFFECT_TYPES,
+ getSessionVirtualBackgroundInfoWithDefault,
+} from '/imports/ui/services/virtual-background/service';
+import { notify } from '/imports/ui/services/notification';
+import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
+import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
+
+// Default values and default empty object to be backwards compat with 2.2.
+// FIXME Remove hardcoded defaults 2.3.
+const {
+ connectionTimeout: WS_CONN_TIMEOUT = 4000,
+ maxRetries: WS_MAX_RETRIES = 5,
+ debug: WS_DEBUG,
+ heartbeat: WS_HEARTBEAT_OPTS = {
+ interval: 15000,
+ delay: 3000,
+ reconnectOnFailure: true,
+ },
+} = Meteor.settings.public.kurento.cameraWsOptions;
+
+const { webcam: NETWORK_PRIORITY } = Meteor.settings.public.media.networkPriorities || {};
+const {
+ baseTimeout: CAMERA_SHARE_FAILED_WAIT_TIME = 15000,
+ maxTimeout: MAX_CAMERA_SHARE_FAILED_WAIT_TIME = 60000,
+} = Meteor.settings.public.kurento.cameraTimeouts || {};
+const {
+ enabled: CAMERA_QUALITY_THRESHOLDS_ENABLED = true,
+ privilegedStreams: CAMERA_QUALITY_THR_PRIVILEGED = true,
+} = Meteor.settings.public.kurento.cameraQualityThresholds;
+const SIGNAL_CANDIDATES = Meteor.settings.public.kurento.signalCandidates;
+const TRACE_LOGS = Meteor.settings.public.kurento.traceLogs;
+const GATHERING_TIMEOUT = Meteor.settings.public.kurento.gatheringTimeout;
+
+const intlClientErrors = defineMessages({
+ permissionError: {
+ id: 'app.video.permissionError',
+ description: 'Webcam permission error',
+ },
+ iceConnectionStateError: {
+ id: 'app.video.iceConnectionStateError',
+ description: 'Ice connection state failed',
+ },
+ mediaFlowTimeout: {
+ id: 'app.video.mediaFlowTimeout1020',
+ description: 'Media flow timeout',
+ },
+ mediaTimedOutError: {
+ id: 'app.video.mediaTimedOutError',
+ description: 'Media was ejected by the server due to lack of valid media',
+ },
+ virtualBgGenericError: {
+ id: 'app.video.virtualBackground.genericError',
+ description: 'Failed to apply camera effect',
+ },
+ inactiveError: {
+ id: 'app.video.inactiveError',
+ description: 'Camera stopped unexpectedly',
+ },
+});
+
+const intlSFUErrors = defineMessages({
+ 2000: {
+ id: 'app.sfu.mediaServerConnectionError2000',
+ description: 'SFU connection to the media server',
+ },
+ 2001: {
+ id: 'app.sfu.mediaServerOffline2001',
+ description: 'SFU is offline',
+ },
+ 2002: {
+ id: 'app.sfu.mediaServerNoResources2002',
+ description: 'Media server lacks disk, CPU or FDs',
+ },
+ 2003: {
+ id: 'app.sfu.mediaServerRequestTimeout2003',
+ description: 'Media requests timeout due to lack of resources',
+ },
+ 2021: {
+ id: 'app.sfu.serverIceGatheringFailed2021',
+ description: 'Server cannot enact ICE gathering',
+ },
+ 2022: {
+ id: 'app.sfu.serverIceStateFailed2022',
+ description: 'Server endpoint transitioned to a FAILED ICE state',
+ },
+ 2200: {
+ id: 'app.sfu.mediaGenericError2200',
+ description: 'SFU component generated a generic error',
+ },
+ 2202: {
+ id: 'app.sfu.invalidSdp2202',
+ description: 'Client provided an invalid SDP',
+ },
+ 2203: {
+ id: 'app.sfu.noAvailableCodec2203',
+ description: 'Server has no available codec for the client',
+ },
+});
+
+const propTypes = {
+ streams: PropTypes.arrayOf(Array).isRequired,
+ intl: PropTypes.objectOf(Object).isRequired,
+ isUserLocked: PropTypes.bool.isRequired,
+ swapLayout: PropTypes.bool.isRequired,
+ currentVideoPageIndex: PropTypes.number.isRequired,
+ totalNumberOfStreams: PropTypes.number.isRequired,
+ isMeteorConnected: PropTypes.bool.isRequired,
+};
+
+class VideoProvider extends Component {
+ static onBeforeUnload() {
+ VideoService.onBeforeUnload();
+ }
+
+ static shouldAttachVideoStream(peer, videoElement) {
+ // Conditions to safely attach a stream to a video element in all browsers:
+ // 1 - Peer exists, video element exists
+ // 2 - Target stream differs from videoElement's (diff)
+ // 3a - If the stream is a remote one, the safest (*ahem* Safari) moment to
+ // do so is waiting for the server to confirm that media has flown out of it
+ // towards te remote end (peer.started)
+ // 3b - If the stream is a local one (webcam sharer) and is started
+ // 4 - If the stream is local one, check if there area video tracks there are
+ // video tracks: attach it
+ if (peer == null || videoElement == null) return false;
+ const stream = peer.isPublisher ? peer.getLocalStream() : peer.getRemoteStream();
+ const diff = stream && (stream.id !== videoElement.srcObject?.id || !videoElement.paused);
+
+ if (peer.started && diff) return true;
+
+ return peer.isPublisher
+ && peer.getLocalStream()
+ && peer.getLocalStream().getVideoTracks().length > 0
+ && diff;
+ }
+
+ constructor(props) {
+ super(props);
+
+ // socketOpen state is there to force update when the signaling socket opens or closes
+ this.state = {
+ socketOpen: false,
+ };
+ this._isMounted = false;
+ this.info = VideoService.getInfo();
+ // Signaling message queue arrays indexed by stream (== cameraId)
+ this.wsQueues = {};
+ this.restartTimeout = {};
+ this.restartTimer = {};
+ this.webRtcPeers = {};
+ this.outboundIceQueues = {};
+ this.videoTags = {};
+
+ this.createVideoTag = this.createVideoTag.bind(this);
+ this.destroyVideoTag = this.destroyVideoTag.bind(this);
+ this.onWsOpen = this.onWsOpen.bind(this);
+ this.onWsClose = this.onWsClose.bind(this);
+ this.onWsMessage = this.onWsMessage.bind(this);
+ this.updateStreams = this.updateStreams.bind(this);
+ this.connectStreams = this.connectStreams.bind(this);
+ this.debouncedConnectStreams = debounce(
+ this.connectStreams,
+ VideoService.getPageChangeDebounceTime(),
+ { leading: false, trailing: true },
+ );
+ this.startVirtualBackgroundByDrop = this.startVirtualBackgroundByDrop.bind(this);
+ }
+
+ componentDidMount() {
+ this._isMounted = true;
+ VideoService.updatePeerDictionaryReference(this.webRtcPeers);
+ this.ws = this.openWs();
+ window.addEventListener('beforeunload', VideoProvider.onBeforeUnload);
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ isUserLocked,
+ streams,
+ currentVideoPageIndex,
+ isMeteorConnected
+ } = this.props;
+ const { socketOpen } = this.state;
+
+ // Only debounce when page changes to avoid unecessary debouncing
+ const shouldDebounce = VideoService.isPaginationEnabled()
+ && prevProps.currentVideoPageIndex !== currentVideoPageIndex;
+
+ if (isMeteorConnected && socketOpen) this.updateStreams(streams, shouldDebounce);
+ if (!prevProps.isUserLocked && isUserLocked) VideoService.lockUser();
+
+ // Signaling socket expired its retries and meteor is connected - create
+ // a new signaling socket instance from scratch
+ if (!socketOpen
+ && isMeteorConnected
+ && this.ws == null) {
+ this.ws = this.openWs();
+ }
+ }
+
+ componentWillUnmount() {
+ this._isMounted = false;
+ VideoService.updatePeerDictionaryReference({});
+
+ if (this.ws) {
+ this.ws.onmessage = null;
+ this.ws.onopen = null;
+ this.ws.onclose = null;
+ }
+
+ window.removeEventListener('beforeunload', VideoProvider.onBeforeUnload);
+ VideoService.exitVideo();
+ Object.keys(this.webRtcPeers).forEach((stream) => {
+ this.stopWebRTCPeer(stream, false);
+ });
+ this.terminateWs();
+ }
+
+ openWs() {
+ const ws = new ReconnectingWebSocket(
+ VideoService.getAuthenticatedURL(), [], {
+ connectionTimeout: WS_CONN_TIMEOUT,
+ debug: WS_DEBUG,
+ maxRetries: WS_MAX_RETRIES,
+ maxEnqueuedMessages: 0,
+ }
+ );
+ ws.onopen = this.onWsOpen;
+ ws.onclose = this.onWsClose;
+ ws.onmessage = this.onWsMessage;
+
+ return ws;
+ }
+
+ terminateWs() {
+ if (this.ws) {
+ this.clearWSHeartbeat();
+ this.ws.close();
+ this.ws = null;
+ }
+ }
+
+ _updateLastMsgTime() {
+ this.ws.isAlive = true;
+ this.ws.lastMsgTime = Date.now();
+ }
+
+ _getTimeSinceLastMsg() {
+ return Date.now() - this.ws.lastMsgTime;
+ }
+
+ setupWSHeartbeat() {
+ if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null || this.ws.wsHeartbeat) return;
+
+ this.ws.isAlive = true;
+ this.ws.wsHeartbeat = setInterval(() => {
+ if (this.ws.isAlive === false) {
+ logger.warn({
+ logCode: 'video_provider_ws_heartbeat_failed',
+ }, 'Video provider WS heartbeat failed.');
+
+ if (WS_HEARTBEAT_OPTS.reconnectOnFailure) this.ws.reconnect();
+ return;
+ }
+
+ if (this._getTimeSinceLastMsg() < (
+ WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay
+ )) {
+ return;
+ }
+
+ this.ws.isAlive = false;
+ this.ping();
+ }, WS_HEARTBEAT_OPTS.interval);
+
+ this.ping();
+ }
+
+ clearWSHeartbeat() {
+ if (this.ws?.wsHeartbeat) {
+ clearInterval(this.ws.wsHeartbeat);
+ this.ws.wsHeartbeat = null;
+ }
+ }
+
+ onWsMessage(message) {
+ this._updateLastMsgTime();
+ const parsedMessage = JSON.parse(message.data);
+
+ if (parsedMessage.id === 'pong') return;
+
+ switch (parsedMessage.id) {
+ case 'startResponse':
+ this.startResponse(parsedMessage);
+ break;
+
+ case 'playStart':
+ this.handlePlayStart(parsedMessage);
+ break;
+
+ case 'playStop':
+ this.handlePlayStop(parsedMessage);
+ break;
+
+ case 'iceCandidate':
+ this.handleIceCandidate(parsedMessage);
+ break;
+
+ case 'pong':
+ break;
+
+ case 'error':
+ default:
+ this.handleSFUError(parsedMessage);
+ break;
+ }
+ }
+
+ onWsClose() {
+ logger.info({
+ logCode: 'video_provider_onwsclose',
+ }, 'Multiple video provider websocket connection closed.');
+
+ this.clearWSHeartbeat();
+ VideoService.exitVideo();
+ // Media is currently tied to signaling state - so if signaling shuts down,
+ // media will shut down server-side. This cleans up our local state faster
+ // and notify the state change as failed so the UI rolls back to the placeholder
+ // avatar UI in the camera container
+ Object.keys(this.webRtcPeers).forEach((stream) => {
+ if (this.stopWebRTCPeer(stream, false)) {
+ notifyStreamStateChange(stream, 'failed');
+ }
+ });
+ this.setState({ socketOpen: false });
+
+ if (this.ws && this.ws.retryCount >= WS_MAX_RETRIES) {
+ this.terminateWs();
+ }
+ }
+
+ onWsOpen() {
+ logger.info({
+ logCode: 'video_provider_onwsopen',
+ }, 'Multiple video provider websocket connection opened.');
+
+ this._updateLastMsgTime();
+ this.setupWSHeartbeat();
+ this.setState({ socketOpen: true });
+ // Resend queued messages that happened when socket was not connected
+ Object.entries(this.wsQueues).forEach(([stream, queue]) => {
+ if (this.webRtcPeers[stream]) {
+ // Peer - send enqueued
+ while (queue.length > 0) {
+ this.sendMessage(queue.pop());
+ }
+ } else {
+ // No peer - delete queue
+ this.wsQueues[stream] = null;
+ }
+ });
+ }
+
+ findAllPrivilegedStreams () {
+ const { streams } = this.props;
+ // Privileged streams are: floor holders, pinned users
+ return streams.filter(stream => stream.floor || stream.pin);
+ }
+
+ updateQualityThresholds(numberOfPublishers) {
+ const { threshold, profile } = VideoService.getThreshold(numberOfPublishers);
+ if (profile) {
+ const privilegedStreams = this.findAllPrivilegedStreams();
+ Object.values(this.webRtcPeers)
+ .filter(peer => peer.isPublisher)
+ .forEach((peer) => {
+ // Conditions which make camera revert their original profile
+ // 1) Threshold 0 means original profile/inactive constraint
+ // 2) Privileged streams
+ const exempt = threshold === 0
+ || (CAMERA_QUALITY_THR_PRIVILEGED && privilegedStreams.some(vs => vs.stream === peer.stream))
+ const profileToApply = exempt ? peer.originalProfileId : profile;
+ VideoService.applyCameraProfile(peer, profileToApply);
+ });
+ }
+ }
+
+ getStreamsToConnectAndDisconnect(streams) {
+ const streamsCameraIds = streams.filter(s => !s?.isGridItem).map(s => s.stream);
+ const streamsConnected = Object.keys(this.webRtcPeers);
+
+ const streamsToConnect = streamsCameraIds.filter(stream => {
+ return !streamsConnected.includes(stream);
+ });
+
+ const streamsToDisconnect = streamsConnected.filter(stream => {
+ return !streamsCameraIds.includes(stream);
+ });
+
+ return [streamsToConnect, streamsToDisconnect];
+ }
+
+ connectStreams(streamsToConnect) {
+ streamsToConnect.forEach((stream) => {
+ const isLocal = VideoService.isLocalStream(stream);
+ this.createWebRTCPeer(stream, isLocal);
+ });
+ }
+
+ disconnectStreams(streamsToDisconnect) {
+ streamsToDisconnect.forEach(stream => this.stopWebRTCPeer(stream, false));
+ }
+
+ updateStreams(streams, shouldDebounce = false) {
+ const [streamsToConnect, streamsToDisconnect] = this.getStreamsToConnectAndDisconnect(streams);
+
+ if (shouldDebounce) {
+ this.debouncedConnectStreams(streamsToConnect);
+ } else {
+ this.connectStreams(streamsToConnect);
+ }
+
+ this.disconnectStreams(streamsToDisconnect);
+
+ if (CAMERA_QUALITY_THRESHOLDS_ENABLED) {
+ this.updateQualityThresholds(this.props.totalNumberOfStreams);
+ }
+ }
+
+ ping() {
+ const message = { id: 'ping' };
+ this.sendMessage(message);
+ }
+
+ sendMessage(message) {
+ const { ws } = this;
+
+ if (this.connectedToMediaServer()) {
+ const jsonMessage = JSON.stringify(message);
+ try {
+ ws.send(jsonMessage);
+ } catch (error) {
+ logger.error({
+ logCode: 'video_provider_ws_send_error',
+ extraInfo: {
+ errorMessage: error.message || 'Unknown',
+ errorCode: error.code,
+ },
+ }, 'Camera request failed to be sent to SFU');
+ }
+ } else if (message.id !== 'stop') {
+ // No need to queue video stop messages
+ const { cameraId } = message;
+ if (cameraId) {
+ if (this.wsQueues[cameraId] == null) this.wsQueues[cameraId] = [];
+ this.wsQueues[cameraId].push(message);
+ }
+ }
+ }
+
+ connectedToMediaServer() {
+ return this.ws && this.ws.readyState === ReconnectingWebSocket.OPEN;
+ }
+
+ processOutboundIceQueue(peer, role, stream) {
+ const queue = this.outboundIceQueues[stream];
+ while (queue && queue.length) {
+ const candidate = queue.shift();
+ this.sendIceCandidateToSFU(peer, role, candidate, stream);
+ }
+ }
+
+ sendLocalAnswer (peer, stream, answer) {
+ const message = {
+ id: 'subscriberAnswer',
+ type: 'video',
+ role: VideoService.getRole(peer.isPublisher),
+ cameraId: stream,
+ answer,
+ };
+
+ this.sendMessage(message);
+ }
+
+ startResponse(message) {
+ const { cameraId: stream, role } = message;
+ const peer = this.webRtcPeers[stream];
+
+ logger.debug({
+ logCode: 'video_provider_start_response_success',
+ extraInfo: { cameraId: stream, role },
+ }, `Camera start request accepted by SFU. Role: ${role}`);
+
+ if (peer) {
+ const processorFunc = peer.isPublisher
+ ? peer.processAnswer.bind(peer)
+ : peer.processOffer.bind(peer);
+
+ processorFunc(message.sdpAnswer).then((answer) => {
+ if (answer) this.sendLocalAnswer(peer, stream, answer);
+
+ peer.didSDPAnswered = true;
+ this.processOutboundIceQueue(peer, role, stream);
+ VideoService.processInboundIceQueue(peer, stream);
+ }).catch((error) => {
+ logger.error({
+ logCode: 'video_provider_peerconnection_process_error',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ errorMessage: error.message,
+ errorCode: error.code,
+ },
+ }, 'Camera answer processing failed');
+ });
+ } else {
+ logger.warn({
+ logCode: 'video_provider_startresponse_no_peer',
+ extraInfo: { cameraId: stream, role },
+ }, 'No peer on SFU camera start response handler');
+ }
+ }
+
+ handleIceCandidate(message) {
+ const { cameraId: stream, candidate } = message;
+ const peer = this.webRtcPeers[stream];
+
+ if (peer) {
+ if (peer.didSDPAnswered) {
+ VideoService.addCandidateToPeer(peer, candidate, stream);
+ } else {
+ // ICE candidates are queued until a SDP answer has been processed.
+ // This was done due to a long term iOS/Safari quirk where it'd
+ // fail if candidates were added before the offer/answer cycle was completed.
+ // Dunno if that still happens, but it works even if it slows the ICE checks
+ // a bit - prlanzarin july 2019
+ if (peer.inboundIceQueue == null) {
+ peer.inboundIceQueue = [];
+ }
+ peer.inboundIceQueue.push(candidate);
+ }
+ } else {
+ logger.warn({
+ logCode: 'video_provider_addicecandidate_no_peer',
+ extraInfo: { cameraId: stream },
+ }, 'Trailing camera ICE candidate, discarded');
+ }
+ }
+
+ clearRestartTimers(stream) {
+ if (this.restartTimeout[stream]) {
+ clearTimeout(this.restartTimeout[stream]);
+ delete this.restartTimeout[stream];
+ }
+
+ if (this.restartTimer[stream]) {
+ delete this.restartTimer[stream];
+ }
+ }
+
+ stopWebRTCPeer(stream, restarting = false) {
+ const isLocal = VideoService.isLocalStream(stream);
+
+ // in this case, 'closed' state is not caused by an error;
+ // we stop listening to prevent this from being treated as an error
+ const peer = this.webRtcPeers[stream];
+ if (peer && peer.peerConnection) {
+ const conn = peer.peerConnection;
+ conn.oniceconnectionstatechange = null;
+ }
+
+ if (isLocal) {
+ VideoService.stopVideo(stream);
+ }
+
+ const role = VideoService.getRole(isLocal);
+
+ logger.info({
+ logCode: 'video_provider_stopping_webcam_sfu',
+ extraInfo: { role, cameraId: stream, restarting },
+ }, `Camera feed stop requested. Role ${role}, restarting ${restarting}`);
+
+ this.sendMessage({
+ id: 'stop',
+ type: 'video',
+ cameraId: stream,
+ role,
+ });
+
+ // Clear the shared camera media flow timeout and current reconnect period
+ // when destroying it if the peer won't restart
+ if (!restarting) {
+ this.clearRestartTimers(stream);
+ }
+
+ return this.destroyWebRTCPeer(stream);
+ }
+
+ destroyWebRTCPeer(stream) {
+ let stopped = false;
+ const peer = this.webRtcPeers[stream];
+ const isLocal = VideoService.isLocalStream(stream);
+ const role = VideoService.getRole(isLocal);
+
+ if (peer) {
+ if (peer && peer.bbbVideoStream) {
+ if (typeof peer.inactivationHandler === 'function') {
+ peer.bbbVideoStream.removeListener('inactive', peer.inactivationHandler);
+ }
+ peer.bbbVideoStream.stop();
+ }
+
+ if (typeof peer.dispose === 'function') {
+ peer.dispose();
+ }
+
+ delete this.webRtcPeers[stream];
+ stopped = true;
+ } else {
+ logger.warn({
+ logCode: 'video_provider_destroywebrtcpeer_no_peer',
+ extraInfo: { cameraId: stream, role },
+ }, 'Trailing camera destroy request.');
+ }
+
+ delete this.outboundIceQueues[stream];
+ delete this.wsQueues[stream];
+
+ return stopped;
+ }
+
+ _createPublisher(stream, peerOptions) {
+ return new Promise((resolve, reject) => {
+ try {
+ const { id: profileId } = VideoService.getCameraProfile();
+ let bbbVideoStream = VideoService.getPreloadedStream();
+
+ if (bbbVideoStream) {
+ peerOptions.videoStream = bbbVideoStream.mediaStream;
+ }
+
+ const peer = new WebRtcPeer('sendonly', peerOptions);
+ peer.bbbVideoStream = bbbVideoStream;
+ this.webRtcPeers[stream] = peer;
+ peer.stream = stream;
+ peer.started = false;
+ peer.didSDPAnswered = false;
+ peer.inboundIceQueue = [];
+ peer.isPublisher = true;
+ peer.originalProfileId = profileId;
+ peer.currentProfileId = profileId;
+ peer.start();
+ peer.generateOffer().then((offer) => {
+ // Store the media stream if necessary. The scenario here is one where
+ // there is no preloaded stream stored.
+ if (peer.bbbVideoStream == null) {
+ bbbVideoStream = new BBBVideoStream(peer.getLocalStream());
+ VideoPreviewService.storeStream(
+ MediaStreamUtils.extractDeviceIdFromStream(
+ bbbVideoStream.mediaStream,
+ 'video',
+ ),
+ bbbVideoStream,
+ );
+ }
+
+ peer.bbbVideoStream = bbbVideoStream;
+ bbbVideoStream.on('streamSwapped', ({ newStream }) => {
+ if (newStream && newStream instanceof MediaStream) {
+ this.replacePCVideoTracks(stream, newStream);
+ }
+ });
+ peer.inactivationHandler = () => this._handleLocalStreamInactive(stream);
+ bbbVideoStream.once('inactive', peer.inactivationHandler);
+ resolve(offer);
+ }).catch(reject);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+
+ _createSubscriber(stream, peerOptions) {
+ return new Promise((resolve, reject) => {
+ try {
+ const peer = new WebRtcPeer('recvonly', peerOptions);
+ this.webRtcPeers[stream] = peer;
+ peer.stream = stream;
+ peer.started = false;
+ peer.didSDPAnswered = false;
+ peer.inboundIceQueue = [];
+ peer.isPublisher = false;
+ peer.start();
+ resolve();
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+
+ async createWebRTCPeer(stream, isLocal) {
+ let iceServers = [];
+ const role = VideoService.getRole(isLocal);
+ const peerBuilderFunc = isLocal
+ ? this._createPublisher.bind(this)
+ : this._createSubscriber.bind(this);
+
+ // Check if the peer is already being processed
+ if (this.webRtcPeers[stream]) {
+ return;
+ }
+
+ this.webRtcPeers[stream] = {};
+ this.outboundIceQueues[stream] = [];
+ const { constraints, bitrate } = VideoService.getCameraProfile();
+ const peerOptions = {
+ mediaConstraints: {
+ audio: false,
+ video: constraints,
+ },
+ onicecandidate: this._getOnIceCandidateCallback(stream, isLocal),
+ configuration: {
+ },
+ trace: TRACE_LOGS,
+ networkPriorities: NETWORK_PRIORITY ? { video: NETWORK_PRIORITY } : undefined,
+ gatheringTimeout: GATHERING_TIMEOUT,
+ };
+
+ try {
+ iceServers = await fetchWebRTCMappedStunTurnServers(this.info.sessionToken);
+ } catch (error) {
+ logger.error({
+ logCode: 'video_provider_fetchstunturninfo_error',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ errorCode: error.code,
+ errorMessage: error.message,
+ },
+ }, 'video-provider failed to fetch STUN/TURN info, using default');
+ // Use fallback STUN server
+ iceServers = getMappedFallbackStun();
+ } finally {
+ // we need to set iceTransportPolicy after `fetchWebRTCMappedStunTurnServers`
+ // because `shouldForceRelay` uses the information from the stun API
+ peerOptions.configuration.iceTransportPolicy = shouldForceRelay() ? 'relay' : undefined;
+ if (iceServers.length > 0) {
+ peerOptions.configuration.iceServers = iceServers;
+ }
+
+ peerBuilderFunc(stream, peerOptions).then((offer) => {
+ if (!this._isMounted) {
+ return this.stopWebRTCPeer(stream, false);
+ }
+ const peer = this.webRtcPeers[stream];
+
+ if (peer && peer.peerConnection) {
+ const conn = peer.peerConnection;
+ conn.onconnectionstatechange = () => {
+ this._handleIceConnectionStateChange(stream, isLocal);
+ };
+ }
+
+ const message = {
+ id: 'start',
+ type: 'video',
+ cameraId: stream,
+ role,
+ sdpOffer: offer,
+ bitrate,
+ record: VideoService.getRecord(),
+ mediaServer: VideoService.getMediaServerAdapter(),
+ };
+
+ logger.info({
+ logCode: 'video_provider_sfu_request_start_camera',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ },
+ }, `Camera offer generated. Role: ${role}`);
+
+ this.setReconnectionTimeout(stream, isLocal, false);
+ this.sendMessage(message);
+
+ return;
+ }).catch(error => {
+ return this._onWebRTCError(error, stream, isLocal);
+ });
+ }
+ }
+
+ _getWebRTCStartTimeout(stream, isLocal) {
+ const { intl } = this.props;
+
+ return () => {
+ const role = VideoService.getRole(isLocal);
+ if (!isLocal) {
+ // Peer that timed out is a subscriber/viewer
+ // Subscribers try to reconnect according to their timers if media could
+ // not reach the server. That's why we pass the restarting flag as true
+ // to the stop procedure as to not destroy the timers
+ // Create new reconnect interval time
+ const oldReconnectTimer = this.restartTimer[stream];
+ const newReconnectTimer = Math.min(
+ 2 * oldReconnectTimer,
+ MAX_CAMERA_SHARE_FAILED_WAIT_TIME,
+ );
+ this.restartTimer[stream] = newReconnectTimer;
+
+ // Clear the current reconnect interval so it can be re-set in createWebRTCPeer
+ if (this.restartTimeout[stream]) {
+ delete this.restartTimeout[stream];
+ }
+
+ logger.error({
+ logCode: 'video_provider_camera_view_timeout',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ oldReconnectTimer,
+ newReconnectTimer,
+ },
+ }, 'Camera VIEWER failed. Reconnecting.');
+
+ this.reconnect(stream, isLocal);
+ } else {
+ // Peer that timed out is a sharer/publisher, clean it up, stop.
+ logger.error({
+ logCode: 'video_provider_camera_share_timeout',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ },
+ }, 'Camera SHARER failed.');
+ VideoService.notify(intl.formatMessage(intlClientErrors.mediaFlowTimeout));
+ this.stopWebRTCPeer(stream, false);
+ }
+ };
+ }
+
+ _onWebRTCError(error, stream, isLocal) {
+ const { intl, streams } = this.props;
+ const { name: errorName, message: errorMessage } = error;
+ const errorLocale = intlClientErrors[errorName]
+ || intlClientErrors[errorMessage]
+ || intlSFUErrors[error];
+
+ logger.error({
+ logCode: 'video_provider_webrtc_peer_error',
+ extraInfo: {
+ cameraId: stream,
+ role: VideoService.getRole(isLocal),
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, 'Camera peer failed');
+
+ // Only display WebRTC negotiation error toasts to sharers. The viewer streams
+ // will try to autoreconnect silently, but the error will log nonetheless
+ if (isLocal) {
+ this.stopWebRTCPeer(stream, false);
+ if (errorLocale) VideoService.notify(intl.formatMessage(errorLocale));
+ } else {
+ // If it's a viewer, set the reconnection timeout. There's a good chance
+ // no local candidate was generated and it wasn't set.
+ const peer = this.webRtcPeers[stream];
+ const stillExists = streams.some(({ stream: streamId }) => streamId === stream);
+
+ if (stillExists) {
+ const isEstablishedConnection = peer && peer.started;
+ this.setReconnectionTimeout(stream, isLocal, isEstablishedConnection);
+ }
+
+ // second argument means it will only try to reconnect if
+ // it's a viewer instance (see stopWebRTCPeer restarting argument)
+ this.stopWebRTCPeer(stream, stillExists);
+ }
+ }
+
+ reconnect(stream, isLocal) {
+ this.stopWebRTCPeer(stream, true);
+ this.createWebRTCPeer(stream, isLocal);
+ }
+
+ setReconnectionTimeout(stream, isLocal, isEstablishedConnection) {
+ const peer = this.webRtcPeers[stream];
+ const shouldSetReconnectionTimeout = !this.restartTimeout[stream] && !isEstablishedConnection;
+
+ // This is an ongoing reconnection which succeeded in the first place but
+ // then failed mid call. Try to reconnect it right away. Clear the restart
+ // timers since we don't need them in this case.
+ if (isEstablishedConnection) {
+ this.clearRestartTimers(stream);
+ return this.reconnect(stream, isLocal);
+ }
+
+ // This is a reconnection timer for a peer that hasn't succeeded in the first
+ // place. Set reconnection timeouts with random intervals between them to try
+ // and reconnect without flooding the server
+ if (shouldSetReconnectionTimeout) {
+ const newReconnectTimer = this.restartTimer[stream] || CAMERA_SHARE_FAILED_WAIT_TIME;
+ this.restartTimer[stream] = newReconnectTimer;
+
+ this.restartTimeout[stream] = setTimeout(
+ this._getWebRTCStartTimeout(stream, isLocal),
+ this.restartTimer[stream]
+ );
+ }
+ }
+
+ _getOnIceCandidateCallback(stream, isLocal) {
+ if (SIGNAL_CANDIDATES) {
+ return (candidate) => {
+ const peer = this.webRtcPeers[stream];
+ const role = VideoService.getRole(isLocal);
+
+ if (peer && !peer.didSDPAnswered) {
+ this.outboundIceQueues[stream].push(candidate);
+ return;
+ }
+
+ this.sendIceCandidateToSFU(peer, role, candidate, stream);
+ };
+ }
+
+ return null;
+ }
+
+ sendIceCandidateToSFU(peer, role, candidate, stream) {
+ const message = {
+ type: 'video',
+ role,
+ id: 'onIceCandidate',
+ candidate,
+ cameraId: stream,
+ };
+ this.sendMessage(message);
+ }
+
+ _handleLocalStreamInactive(stream) {
+ const peer = this.webRtcPeers[stream];
+ const isLocal = VideoService.isLocalStream(stream);
+ const role = VideoService.getRole(isLocal);
+
+ // Peer == null: this is a trailing event.
+ // !isLocal: someone is misusing this handler - local streams only.
+ if (peer == null || !isLocal) return;
+
+ logger.error({
+ logCode: 'video_provider_local_stream_inactive',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ },
+ }, 'Local camera stream stopped unexpectedly');
+
+ const error = new Error('inactiveError');
+ this._onWebRTCError(error, stream, isLocal);
+ }
+
+ _handleIceConnectionStateChange(stream, isLocal) {
+ const { intl } = this.props;
+ const peer = this.webRtcPeers[stream];
+ const role = VideoService.getRole(isLocal);
+
+ if (peer && peer.peerConnection) {
+ const pc = peer.peerConnection;
+ const connectionState = pc.connectionState;
+ notifyStreamStateChange(stream, connectionState);
+
+ if (connectionState === 'failed' || connectionState === 'closed') {
+ const error = new Error('iceConnectionStateError');
+ // prevent the same error from being detected multiple times
+ pc.onconnectionstatechange = null;
+
+ logger.error({
+ logCode: 'video_provider_ice_connection_failed_state',
+ extraInfo: {
+ cameraId: stream,
+ connectionState,
+ role,
+ },
+ }, `Camera ICE connection state changed: ${connectionState}. Role: ${role}.`);
+
+ this._onWebRTCError(error, stream, isLocal);
+ }
+ } else {
+ logger.error({
+ logCode: 'video_provider_ice_connection_nopeer',
+ extraInfo: { cameraId: stream, role },
+ }, `No peer at ICE connection state handler. Camera: ${stream}. Role: ${role}`);
+ }
+ }
+
+ attach (peer, videoElement) {
+ if (peer && videoElement) {
+ const stream = peer.isPublisher ? peer.getLocalStream() : peer.getRemoteStream();
+ videoElement.pause();
+ videoElement.srcObject = stream;
+ videoElement.load();
+ }
+ }
+
+ getVideoElement(streamId) {
+ return this.videoTags[streamId];
+ }
+
+ attachVideoStream(stream) {
+ const videoElement = this.getVideoElement(stream);
+ const isLocal = VideoService.isLocalStream(stream);
+ const peer = this.webRtcPeers[stream];
+
+ if (VideoProvider.shouldAttachVideoStream(peer, videoElement)) {
+ const pc = peer.peerConnection;
+ // Notify current stream state again on attachment since the
+ // video-list-item component may not have been mounted before the stream
+ // reached the connected state.
+ // This is necessary to ensure that the video element is properly
+ // hidden/shown when the stream is attached.
+ notifyStreamStateChange(stream, pc.connectionState);
+ this.attach(peer, videoElement);
+
+ if (isLocal) {
+ if (peer.bbbVideoStream == null) {
+ this.handleVirtualBgError(new TypeError('Undefined media stream'));
+ return;
+ }
+
+ const deviceId = MediaStreamUtils.extractDeviceIdFromStream(
+ peer.bbbVideoStream.mediaStream,
+ 'video',
+ );
+ const { type, name } = getSessionVirtualBackgroundInfoWithDefault(deviceId);
+
+ this.restoreVirtualBackground(peer.bbbVideoStream, type, name).catch((error) => {
+ this.handleVirtualBgError(error, type, name);
+ });
+ }
+ }
+ }
+
+ startVirtualBackgroundByDrop(stream, type, name, data) {
+ return new Promise((resolve, reject) => {
+ const peer = this.webRtcPeers[stream];
+ const { bbbVideoStream } = peer;
+ const video = this.getVideoElement(stream);
+
+ if (peer && video && video.srcObject) {
+ bbbVideoStream.startVirtualBackground(type, name, { file: data })
+ .then(resolve)
+ .catch(reject);
+ }
+ }).catch((error) => {
+ this.handleVirtualBgErrorByDropping(error, type, name);
+ });
+ }
+
+ handleVirtualBgErrorByDropping(error, type, name) {
+ logger.error({
+ logCode: `video_provider_virtualbg_error`,
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ virtualBgType: type,
+ virtualBgName: name,
+ },
+ }, `Failed to start virtual background by dropping image: ${error.message}`);
+ }
+
+ restoreVirtualBackground(stream, type, name) {
+ return new Promise((resolve, reject) => {
+ if (type !== EFFECT_TYPES.NONE_TYPE) {
+ stream.startVirtualBackground(type, name).then(() => {
+ resolve();
+ }).catch((error) => {
+ reject(error);
+ });
+ }
+ resolve();
+ });
+ }
+
+ handleVirtualBgError(error, type, name) {
+ const { intl } = this.props;
+ logger.error({
+ logCode: `video_provider_virtualbg_error`,
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ virtualBgType: type,
+ virtualBgName: name,
+ },
+ }, `Failed to restore virtual background after reentering the room: ${error.message}`);
+
+ notify(intl.formatMessage(intlClientErrors.virtualBgGenericError), 'error', 'video');
+ }
+
+ createVideoTag(stream, video) {
+ const peer = this.webRtcPeers[stream];
+ this.videoTags[stream] = video;
+
+ if (peer && peer.stream === stream) {
+ this.attachVideoStream(stream);
+ }
+ }
+
+ destroyVideoTag(stream) {
+ const videoElement = this.videoTags[stream];
+
+ if (videoElement == null) return;
+
+ if (typeof videoElement.pause === 'function') {
+ videoElement.pause();
+ videoElement.srcObject = null;
+ }
+
+ delete this.videoTags[stream];
+ }
+
+ handlePlayStop(message) {
+ const { intl } = this.props;
+ const { cameraId: stream, role } = message;
+
+ logger.info({
+ logCode: 'video_provider_handle_play_stop',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ },
+ }, `Received request from SFU to stop camera. Role: ${role}`);
+
+ VideoService.notify(intl.formatMessage(intlClientErrors.mediaTimedOutError));
+ this.stopWebRTCPeer(stream, false);
+ }
+
+ handlePlayStart(message) {
+ const { cameraId: stream, role } = message;
+ const peer = this.webRtcPeers[stream];
+
+ if (peer) {
+ logger.info({
+ logCode: 'video_provider_handle_play_start_flowing',
+ extraInfo: {
+ cameraId: stream,
+ role,
+ },
+ }, `Camera media is flowing (server). Role: ${role}`);
+
+ peer.started = true;
+
+ // Clear camera shared timeout when camera succesfully starts
+ this.clearRestartTimers(stream);
+ this.attachVideoStream(stream);
+
+ VideoService.playStart(stream);
+ } else {
+ logger.warn({
+ logCode: 'video_provider_playstart_no_peer',
+ extraInfo: { cameraId: stream, role },
+ }, 'Trailing camera playStart response.');
+ }
+ }
+
+ handleSFUError(message) {
+ const { intl, streams } = this.props;
+ const { code, reason, streamId } = message;
+ const isLocal = VideoService.isLocalStream(streamId);
+ const role = VideoService.getRole(isLocal);
+
+ logger.error({
+ logCode: 'video_provider_handle_sfu_error',
+ extraInfo: {
+ errorCode: code,
+ errorReason: reason,
+ cameraId: streamId,
+ role,
+ },
+ }, `SFU returned an error. Code: ${code}, reason: ${reason}`);
+
+ if (isLocal) {
+ // The publisher instance received an error from the server. There's no reconnect,
+ // stop it.
+ VideoService.notify(intl.formatMessage(intlSFUErrors[code] || intlSFUErrors[2200]));
+ VideoService.stopVideo(streamId);
+ } else {
+ const peer = this.webRtcPeers[streamId];
+ const stillExists = streams.some(({ stream }) => streamId === stream);
+
+ if (stillExists) {
+ const isEstablishedConnection = peer && peer.started;
+ this.setReconnectionTimeout(streamId, isLocal, isEstablishedConnection);
+ }
+
+ this.stopWebRTCPeer(streamId, stillExists);
+ }
+ }
+
+ replacePCVideoTracks(streamId, mediaStream) {
+ const peer = this.webRtcPeers[streamId];
+ const videoElement = this.getVideoElement(streamId);
+
+ if (peer == null || mediaStream == null || videoElement == null) return;
+
+ const pc = peer.peerConnection;
+ const newTracks = mediaStream.getVideoTracks();
+
+ if (pc) {
+ const trackReplacers = pc.getSenders().map(async (sender, index) => {
+ if (sender.track == null || sender.track.kind !== 'video') return false;
+ const newTrack = newTracks[index];
+ if (newTrack == null) return false;
+ try {
+ await sender.replaceTrack(newTrack);
+ return true;
+ } catch (error) {
+ logger.warn({
+ logCode: 'video_provider_replacepc_error',
+ extraInfo: { errorMessage: error.message, cameraId: streamId },
+ }, `Failed to replace peer connection tracks: ${error.message}`);
+ return false;
+ }
+ });
+ Promise.all(trackReplacers).then(() => {
+ this.attach(peer, videoElement);
+ });
+ }
+ }
+
+ render() {
+ const {
+ swapLayout,
+ currentVideoPageIndex,
+ streams,
+ cameraDockBounds,
+ focusedId,
+ handleVideoFocus,
+ isGridEnabled,
+ } = this.props;
+
+ return (
+
+ );
+ }
+}
+
+VideoProvider.propTypes = propTypes;
+
+export default injectIntl(VideoProvider);
diff --git a/src/2.7.12/imports/ui/components/video-provider/container.jsx b/src/2.7.12/imports/ui/components/video-provider/container.jsx
new file mode 100644
index 00000000..f5480a38
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/container.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import VideoProvider from './component';
+import VideoService from './service';
+import { sortVideoStreams } from '/imports/ui/components/video-provider/stream-sorting';
+
+const { defaultSorting: DEFAULT_SORTING } = Meteor.settings.public.kurento.cameraSortingModes;
+
+const VideoProviderContainer = ({ children, ...props }) => {
+ const { streams, isGridEnabled } = props;
+ return (!streams.length && !isGridEnabled ? null : {children} );
+};
+
+export default withTracker(({ swapLayout, ...rest }) => {
+ // getVideoStreams returns a dictionary consisting of:
+ // {
+ // streams: array of mapped streams
+ // totalNumberOfStreams: total number of shared streams in the server
+ // }
+ const {
+ streams,
+ gridUsers,
+ totalNumberOfStreams,
+ } = VideoService.getVideoStreams();
+
+ let usersVideo = streams;
+
+ if(gridUsers.length > 0) {
+ const items = usersVideo.concat(gridUsers);
+ usersVideo = sortVideoStreams(items, DEFAULT_SORTING);
+ }
+
+ return {
+ swapLayout,
+ streams: usersVideo,
+ totalNumberOfStreams,
+ isUserLocked: VideoService.isUserLocked(),
+ currentVideoPageIndex: VideoService.getCurrentVideoPageIndex(),
+ isMeteorConnected: Meteor.status().connected,
+ ...rest,
+ };
+})(VideoProviderContainer);
diff --git a/src/2.7.12/imports/ui/components/video-provider/many-users-notify/component.jsx b/src/2.7.12/imports/ui/components/video-provider/many-users-notify/component.jsx
new file mode 100644
index 00000000..251cc328
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/many-users-notify/component.jsx
@@ -0,0 +1,101 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import { notify } from '/imports/ui/services/notification';
+import { toast } from 'react-toastify';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ suggestLockTitle: {
+ id: 'app.video.suggestWebcamLock',
+ description: 'Label for notification title',
+ },
+ suggestLockReason: {
+ id: 'app.video.suggestWebcamLockReason',
+ description: 'Reason for activate the webcams\'s lock',
+ },
+ enable: {
+ id: 'app.video.enable',
+ description: 'Enable button label',
+ },
+ cancel: {
+ id: 'app.video.cancel',
+ description: 'Cancel button label',
+ },
+});
+
+const REPEAT_INTERVAL = 120000;
+
+class LockViewersNotifyComponent extends Component {
+ constructor(props) {
+ super(props);
+ this.interval = null;
+ this.intervalCallback = this.intervalCallback.bind(this);
+ }
+
+ componentDidUpdate() {
+ const {
+ viewersInWebcam,
+ lockSettings,
+ limitOfViewersInWebcam,
+ webcamOnlyForModerator,
+ currentUserIsModerator,
+ limitOfViewersInWebcamIsEnable,
+ } = this.props;
+ const viwerersInWebcamGreaterThatLimit = (viewersInWebcam >= limitOfViewersInWebcam)
+ && limitOfViewersInWebcamIsEnable;
+ const webcamForViewersIsLocked = lockSettings.disableCam || webcamOnlyForModerator;
+
+ if (viwerersInWebcamGreaterThatLimit
+ && !webcamForViewersIsLocked
+ && currentUserIsModerator
+ && !this.interval) {
+ this.interval = setInterval(this.intervalCallback, REPEAT_INTERVAL);
+ this.intervalCallback();
+ }
+ if (webcamForViewersIsLocked || (!viwerersInWebcamGreaterThatLimit && this.interval)) {
+ clearInterval(this.interval);
+ this.interval = null;
+ }
+ }
+
+ intervalCallback() {
+ const {
+ toggleWebcamsOnlyForModerator,
+ intl,
+ } = this.props;
+ const lockToastId = `suggestLock-${new Date().getTime()}`;
+
+ notify(
+ (
+ <>
+ {intl.formatMessage(intlMessages.suggestLockTitle)}
+
+
+ |
+ toast.dismiss(lockToastId)}
+ />
+
+ {intl.formatMessage(intlMessages.suggestLockReason)}
+ >
+ ),
+ 'info',
+ 'rooms',
+ {
+ toastId: lockToastId,
+ },
+ );
+ }
+
+ render() {
+ return null;
+ }
+}
+
+export default injectIntl(LockViewersNotifyComponent);
diff --git a/src/2.7.12/imports/ui/components/video-provider/many-users-notify/container.jsx b/src/2.7.12/imports/ui/components/video-provider/many-users-notify/container.jsx
new file mode 100644
index 00000000..05f3de23
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/many-users-notify/container.jsx
@@ -0,0 +1,37 @@
+import { withTracker } from 'meteor/react-meteor-data';
+import Meetings from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users/';
+import VideoStreams from '/imports/api/video-streams';
+import LockViewersService from '/imports/ui/components/lock-viewers/service';
+import ManyUsersComponent from './component';
+
+const USER_CONFIG = Meteor.settings.public.user;
+const ROLE_MODERATOR = USER_CONFIG.role_moderator;
+const ROLE_VIEWER = USER_CONFIG.role_viewer;
+
+export default withTracker(() => {
+ const meeting = Meetings.findOne({
+ meetingId: Auth.meetingID,
+ }, { fields: { 'usersProp.webcamsOnlyForModerator': 1, lockSettingsProps: 1 } });
+ const videoStreams = VideoStreams.find({ meetingId: Auth.meetingID },
+ { fields: { userId: 1 } }).fetch();
+ const videoUsersIds = videoStreams.map(u => u.userId);
+ return {
+ viewersInWebcam: Users.find({
+ meetingId: Auth.meetingID,
+ userId: {
+ $in: videoUsersIds,
+ },
+ role: ROLE_VIEWER,
+ presenter: false,
+ }, { fields: {} }).count(),
+ currentUserIsModerator: Users.findOne({ userId: Auth.userID },
+ { fields: { role: 1 } })?.role === ROLE_MODERATOR,
+ lockSettings: meeting.lockSettingsProps,
+ webcamOnlyForModerator: meeting.usersProp.webcamsOnlyForModerator,
+ limitOfViewersInWebcam: Meteor.settings.public.app.viewersInWebcam,
+ limitOfViewersInWebcamIsEnable: Meteor.settings.public.app.enableLimitOfViewersInWebcam,
+ toggleWebcamsOnlyForModerator: LockViewersService.toggleWebcamsOnlyForModerator,
+ };
+})(ManyUsersComponent);
diff --git a/src/2.7.12/imports/ui/components/video-provider/many-users-notify/styles.js b/src/2.7.12/imports/ui/components/video-provider/many-users-notify/styles.js
new file mode 100644
index 00000000..64af0dfa
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/many-users-notify/styles.js
@@ -0,0 +1,35 @@
+import styled from 'styled-components';
+import { colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
+import Styled from '/imports/ui/components/breakout-room/styles';
+import Button from '/imports/ui/components/common/button/component';
+
+const Info = styled.p`
+ margin: 0;
+`;
+
+const ButtonWrapper = styled(Styled.BreakoutActions)`
+ background-color: inherit;
+
+ &:focus,&:hover {
+ background-color: inherit;
+ }
+`;
+
+const ManyUsersButton = styled(Button)`
+ flex: 0 1 48%;
+ color: ${colorPrimary};
+ margin: 0;
+ font-weight: inherit;
+
+ background-color: inherit;
+
+ &:focus,&:hover {
+ background-color: inherit;
+ }
+`;
+
+export default {
+ Info,
+ ButtonWrapper,
+ ManyUsersButton,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/service.js b/src/2.7.12/imports/ui/components/video-provider/service.js
new file mode 100644
index 00000000..74413810
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/service.js
@@ -0,0 +1,1099 @@
+import { Tracker } from 'meteor/tracker';
+import { Session } from 'meteor/session';
+import Settings from '/imports/ui/services/settings';
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+import Users from '/imports/api/users';
+import VideoStreams from '/imports/api/video-streams';
+import UserListService from '/imports/ui/components/user-list/service';
+import { meetingIsBreakout } from '/imports/ui/components/app/service';
+import { makeCall } from '/imports/ui/services/api';
+import { notify } from '/imports/ui/services/notification';
+import deviceInfo from '/imports/utils/deviceInfo';
+import browserInfo from '/imports/utils/browserInfo';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import VideoPreviewService from '../video-preview/service';
+import Storage from '/imports/ui/services/storage/session';
+import BBBStorage from '/imports/ui/services/storage';
+import logger from '/imports/startup/client/logger';
+import { debounce } from '/imports/utils/debounce';
+import { partition } from '/imports/utils/array-utils';
+import {
+ getSortingMethod,
+ sortVideoStreams,
+} from '/imports/ui/components/video-provider/stream-sorting';
+import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
+
+const CAMERA_PROFILES = Meteor.settings.public.kurento.cameraProfiles;
+const MULTIPLE_CAMERAS = Meteor.settings.public.app.enableMultipleCameras;
+
+const SFU_URL = Meteor.settings.public.kurento.wsUrl;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
+const MIRROR_WEBCAM = Meteor.settings.public.app.mirrorOwnWebcam;
+const PIN_WEBCAM = Meteor.settings.public.kurento.enableVideoPin;
+const {
+ thresholds: CAMERA_QUALITY_THRESHOLDS = [],
+ applyConstraints: CAMERA_QUALITY_THR_CONSTRAINTS = false,
+ debounceTime: CAMERA_QUALITY_THR_DEBOUNCE = 2500,
+} = Meteor.settings.public.kurento.cameraQualityThresholds;
+const {
+ paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED,
+ pageChangeDebounceTime: PAGE_CHANGE_DEBOUNCE_TIME,
+ desktopPageSizes: DESKTOP_PAGE_SIZES,
+ mobilePageSizes: MOBILE_PAGE_SIZES,
+ desktopGridSizes: DESKTOP_GRID_SIZES,
+ mobileGridSizes: MOBILE_GRID_SIZES,
+} = Meteor.settings.public.kurento.pagination;
+const PAGINATION_THRESHOLDS_CONF = Meteor.settings.public.kurento.paginationThresholds;
+const PAGINATION_THRESHOLDS = PAGINATION_THRESHOLDS_CONF.thresholds.sort((t1, t2) => t1.users - t2.users);
+const PAGINATION_THRESHOLDS_ENABLED = PAGINATION_THRESHOLDS_CONF.enabled;
+const {
+ paginationSorting: PAGINATION_SORTING,
+ defaultSorting: DEFAULT_SORTING,
+} = Meteor.settings.public.kurento.cameraSortingModes;
+const DEFAULT_VIDEO_MEDIA_SERVER = Meteor.settings.public.kurento.videoMediaServer;
+
+const FILTER_VIDEO_STATS = [
+ 'outbound-rtp',
+ 'inbound-rtp',
+];
+
+const TOKEN = '_';
+
+class VideoService {
+ constructor() {
+ this.defineProperties({
+ isConnecting: false,
+ isConnected: false,
+ currentVideoPageIndex: 0,
+ numberOfPages: 0,
+ pageSize: 0,
+ });
+ this.userParameterProfile = null;
+
+ this.isMobile = deviceInfo.isMobile;
+ this.isSafari = browserInfo.isSafari;
+ this.numberOfDevices = 0;
+
+ this.record = null;
+ this.hackRecordViewer = null;
+
+ // If the page isn't served over HTTPS there won't be mediaDevices
+ if (navigator.mediaDevices) {
+ this.updateNumberOfDevices = this.updateNumberOfDevices.bind(this);
+ // Safari doesn't support ondevicechange
+ if (!this.isSafari) {
+ navigator.mediaDevices.ondevicechange = event => this.updateNumberOfDevices();
+ }
+ this.updateNumberOfDevices();
+ }
+
+ // FIXME this is abhorrent. Remove when peer lifecycle is properly decoupled
+ // from the React component's lifecycle. Any attempt at a half-baked
+ // decoupling will most probably generate problems - prlanzarin Dec 16 2021
+ this.webRtcPeersRef = {};
+ }
+
+ defineProperties(obj) {
+ Object.keys(obj).forEach((key) => {
+ const privateKey = `_${key}`;
+ this[privateKey] = {
+ value: obj[key],
+ tracker: new Tracker.Dependency(),
+ };
+
+ Object.defineProperty(this, key, {
+ set: (value) => {
+ this[privateKey].value = value;
+ this[privateKey].tracker.changed();
+ },
+ get: () => {
+ this[privateKey].tracker.depend();
+ return this[privateKey].value;
+ },
+ });
+ });
+ }
+
+ fetchNumberOfDevices(devices) {
+ const deviceIds = [];
+ devices.forEach(d => {
+ const validDeviceId = d.deviceId !== '' && !deviceIds.includes(d.deviceId)
+ if (d.kind === 'videoinput' && validDeviceId) {
+ deviceIds.push(d.deviceId);
+ }
+ });
+
+ return deviceIds.length;
+ }
+
+ updateNumberOfDevices(devices = null) {
+ if (devices) {
+ this.numberOfDevices = this.fetchNumberOfDevices(devices);
+ } else {
+ navigator.mediaDevices.enumerateDevices().then(devices => {
+ this.numberOfDevices = this.fetchNumberOfDevices(devices);
+ });
+ }
+ }
+
+ joinVideo(deviceId) {
+ this.deviceId = deviceId;
+ this.isConnecting = true;
+ Storage.setItem('isFirstJoin', false);
+ }
+
+ joinedVideo() {
+ this.isConnected = true;
+ }
+
+ storeDeviceIds() {
+ const streams = VideoStreams.find(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ }, { fields: { deviceId: 1 } },
+ ).fetch();
+
+ let deviceIds = [];
+ streams.forEach(s => {
+ deviceIds.push(s.deviceId);
+ }
+ );
+ Session.set('deviceIds', deviceIds.join());
+ }
+
+ exitVideo() {
+ if (this.isConnected) {
+ logger.info({
+ logCode: 'video_provider_unsharewebcam',
+ }, `Sending unshare all ${Auth.userID} webcams notification to meteor`);
+ const streams = VideoStreams.find(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ }, { fields: { stream: 1 } },
+ ).fetch();
+
+ streams.forEach(s => this.sendUserUnshareWebcam(s.stream));
+ this.exitedVideo();
+ }
+ }
+
+ exitedVideo() {
+ this.isConnecting = false;
+ this.deviceId = null;
+ this.isConnected = false;
+ }
+
+ stopVideo(cameraId) {
+ const streams = VideoStreams.find(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ }, { fields: { stream: 1 } },
+ ).fetch();
+
+ const hasTargetStream = streams.some(s => s.stream === cameraId);
+ const hasOtherStream = streams.some(s => s.stream !== cameraId);
+
+ // Check if the target (cameraId) stream exists in the remote collection.
+ // If it does, means it was successfully shared. So do the full stop procedure.
+ if (hasTargetStream) {
+ this.sendUserUnshareWebcam(cameraId);
+ }
+
+ if (!hasOtherStream) {
+ // There's no other remote stream, meaning (OR)
+ // a) This was effectively the last webcam being unshared
+ // b) This was a connecting stream timing out (not effectively shared)
+ // For both cases, we clean everything up.
+ this.exitedVideo();
+ } else {
+ // It was not the last webcam the user had successfully shared,
+ // nor was cameraId present in the server collection.
+ // Hence it's a connecting stream (not effectively shared) which timed out
+ this.stopConnectingStream();
+ }
+ }
+
+ getSharedDevices() {
+ const devices = VideoStreams.find(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ }, { fields: { deviceId: 1 } },
+ ).fetch().map(vs => vs.deviceId);
+
+ return devices;
+ }
+
+ sendUserShareWebcam(cameraId) {
+ makeCall('userShareWebcam', cameraId);
+ }
+
+ sendUserUnshareWebcam(cameraId) {
+ makeCall('userUnshareWebcam', cameraId);
+ }
+
+ getAuthenticatedURL() {
+ return Auth.authenticateURL(SFU_URL);
+ }
+
+ shouldRenderPaginationToggle() {
+ // Only enable toggle if configured to do so and if we have a page size properly setup
+ return PAGINATION_TOGGLE_ENABLED && (this.getMyPageSize() > 0);
+ }
+
+ isPaginationEnabled () {
+ return Settings.application.paginationEnabled && (this.getMyPageSize() > 0);
+ }
+
+ setNumberOfPages (numberOfPublishers, numberOfSubscribers, pageSize) {
+ // Page size 0 means no pagination, return itself
+ if (pageSize === 0) return 0;
+
+ // Page size refers only to the number of subscribers. Publishers are always
+ // shown, hence not accounted for
+ const nofPages = Math.ceil(numberOfSubscribers / pageSize);
+
+ if (nofPages !== this.numberOfPages) {
+ this.numberOfPages = nofPages;
+ // Check if we have to page back on the current video page index due to a
+ // page ceasing to exist
+ if (nofPages === 0) {
+ this.currentVideoPageIndex = 0;
+ } else if ((this.currentVideoPageIndex + 1) > this.numberOfPages) {
+ this.getPreviousVideoPage();
+ }
+ }
+
+ return this.numberOfPages;
+ }
+
+ getNumberOfPages () {
+ return this.numberOfPages;
+ }
+
+ setCurrentVideoPageIndex (newVideoPageIndex) {
+ if (this.currentVideoPageIndex !== newVideoPageIndex) {
+ this.currentVideoPageIndex = newVideoPageIndex;
+ }
+ }
+
+ getCurrentVideoPageIndex () {
+ return this.currentVideoPageIndex;
+ }
+
+ calculateNextPage () {
+ if (this.numberOfPages === 0) {
+ return 0;
+ }
+
+ return ((this.currentVideoPageIndex + 1) % this.numberOfPages + this.numberOfPages) % this.numberOfPages;
+ }
+
+ calculatePreviousPage () {
+ if (this.numberOfPages === 0) {
+ return 0;
+ }
+
+ return ((this.currentVideoPageIndex - 1) % this.numberOfPages + this.numberOfPages) % this.numberOfPages;
+ }
+
+ getNextVideoPage() {
+ const nextPage = this.calculateNextPage();
+ this.setCurrentVideoPageIndex(nextPage);
+
+ return this.currentVideoPageIndex;
+ }
+
+ getPreviousVideoPage() {
+ const previousPage = this.calculatePreviousPage();
+ this.setCurrentVideoPageIndex(previousPage);
+
+ return this.currentVideoPageIndex;
+ }
+
+ getPageSizeDictionary () {
+ // Dynamic page sizes are disabled. Fetch the stock page sizes.
+ if (!PAGINATION_THRESHOLDS_ENABLED || PAGINATION_THRESHOLDS.length <= 0) {
+ return !this.isMobile ? DESKTOP_PAGE_SIZES : MOBILE_PAGE_SIZES;
+ }
+
+ // Dynamic page sizes are enabled. Get the user count, isolate the
+ // matching threshold entry, return the val.
+ let targetThreshold;
+ const userCount = UserListService.getUserCount();
+ const processThreshold = (threshold = {
+ desktopPageSizes: DESKTOP_PAGE_SIZES,
+ mobilePageSizes: MOBILE_PAGE_SIZES
+ }) => {
+ // We don't demand that all page sizes should be set in pagination profiles.
+ // That saves us some space because don't necessarily need to scale mobile
+ // endpoints.
+ // If eg mobile isn't set, then return the default value.
+ if (!this.isMobile) {
+ return threshold.desktopPageSizes || DESKTOP_PAGE_SIZES;
+ } else {
+ return threshold.mobilePageSizes || MOBILE_PAGE_SIZES;
+ }
+ };
+
+ // Short-circuit: no threshold yet, return stock values (processThreshold has a default arg)
+ if (userCount < PAGINATION_THRESHOLDS[0].users) return processThreshold();
+
+ // Reverse search for the threshold where our participant count is directly equal or great
+ // The PAGINATION_THRESHOLDS config is sorted when imported.
+ for (let mapIndex = PAGINATION_THRESHOLDS.length - 1; mapIndex >= 0; --mapIndex) {
+ targetThreshold = PAGINATION_THRESHOLDS[mapIndex];
+ if (targetThreshold.users <= userCount) {
+ return processThreshold(targetThreshold);
+ }
+ }
+ }
+
+ setPageSize (size) {
+ if (this.pageSize !== size) {
+ this.pageSize = size;
+ }
+
+ return this.pageSize;
+ }
+
+ getMyPageSize () {
+ let size;
+ const myRole = this.getMyRole();
+ const pageSizes = this.getPageSizeDictionary();
+ switch (myRole) {
+ case ROLE_MODERATOR:
+ size = pageSizes.moderator;
+ break;
+ case ROLE_VIEWER:
+ default:
+ size = pageSizes.viewer
+ }
+
+ return this.setPageSize(size);
+ }
+
+ getGridSize () {
+ let size;
+ const myRole = this.getMyRole();
+ const gridSizes = !this.isMobile ? DESKTOP_GRID_SIZES : MOBILE_GRID_SIZES;
+
+ switch (myRole) {
+ case ROLE_MODERATOR:
+ size = gridSizes.moderator;
+ break;
+ case ROLE_VIEWER:
+ default:
+ size = gridSizes.viewer
+ }
+
+ return size;
+ }
+
+ getVideoPage (streams, pageSize) {
+ // Publishers are taken into account for the page size calculations. They
+ // also appear on every page. Same for pinned user.
+ const [filtered, others] = partition(streams, (vs) => Auth.userID === vs.userId || vs.pin);
+
+ // Separate pin from local cameras
+ const [pin, mine] = partition(filtered, (vs) => vs.pin);
+
+ // Recalculate total number of pages
+ this.setNumberOfPages(filtered.length, others.length, pageSize);
+ const chunkIndex = this.currentVideoPageIndex * pageSize;
+
+ // This is an extra check because pagination is globally in effect (hard
+ // limited page sizes, toggles on), but we might still only have one page.
+ // Use the default sorting method if that's the case.
+ const sortingMethod = (this.numberOfPages > 1) ? PAGINATION_SORTING : DEFAULT_SORTING;
+ const paginatedStreams = sortVideoStreams(others, sortingMethod)
+ .slice(chunkIndex, (chunkIndex + pageSize)) || [];
+
+ if (getSortingMethod(sortingMethod).localFirst) {
+ return [...pin, ...mine, ...paginatedStreams];
+ }
+ return [...pin, ...paginatedStreams, ...mine];
+ }
+
+ getUsersIdFromVideoStreams() {
+ const usersId = VideoStreams.find(
+ { meetingId: Auth.meetingID },
+ { fields: { userId: 1 } },
+ ).fetch().map(user => user.userId);
+
+ return usersId;
+ }
+
+ getVideoPinByUser(userId) {
+ const user = Users.findOne({ userId }, { fields: { pin: 1 } });
+
+ return user.pin;
+ }
+
+ toggleVideoPin(userId, userIsPinned) {
+ makeCall('changePin', userId, !userIsPinned);
+ }
+
+ isGridEnabled() {
+ return Session.get('isGridEnabled');
+ }
+
+ getVideoStreams() {
+ const pageSize = this.getMyPageSize();
+ const isPaginationDisabled = !this.isPaginationEnabled() || pageSize === 0;
+ const { neededDataTypes } = isPaginationDisabled
+ ? getSortingMethod(DEFAULT_SORTING)
+ : getSortingMethod(PAGINATION_SORTING);
+ const isGridEnabled = this.isGridEnabled();
+ let gridUsers = [];
+ let users = [];
+
+ if (isGridEnabled) {
+ const selector = {
+ meetingId: Auth.meetingID,
+ };
+
+ if (this.hideUserlist() && this.getMyRole() === ROLE_VIEWER) {
+ selector.role = { $ne: ROLE_VIEWER };
+ }
+
+ users = Users.find(
+ selector,
+ { fields: { loggedOut: 1, left: 1, ...neededDataTypes} },
+ ).fetch();
+ }
+
+ let streams = VideoStreams.find(
+ { meetingId: Auth.meetingID },
+ { fields: neededDataTypes },
+ ).fetch();
+
+ // Data savings enabled will only show local streams
+ const { viewParticipantsWebcams } = Settings.dataSaving;
+ if (!viewParticipantsWebcams) streams = this.filterLocalOnly(streams);
+
+ const moderatorOnly = this.webcamsOnlyForModerator();
+ if (moderatorOnly) streams = this.filterModeratorOnly(streams);
+ const connectingStream = this.getConnectingStream(streams);
+ if (connectingStream) streams.push(connectingStream);
+
+ // Pagination is either explictly disabled or pagination is set to 0 (which
+ // is equivalent to disabling it), so return the mapped streams as they are
+ // which produces the original non paginated behaviour
+ if (isPaginationDisabled) {
+ if (isGridEnabled) {
+ const streamUsers = streams.map((stream) => stream.userId);
+
+ gridUsers = users.filter(
+ (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId)
+ ).map((user) => ({
+ isGridItem: true,
+ ...user,
+ }));
+ }
+
+ return {
+ streams: sortVideoStreams(streams, DEFAULT_SORTING),
+ gridUsers,
+ totalNumberOfStreams: streams.length
+ };
+ }
+
+ const paginatedStreams = this.getVideoPage(streams, pageSize);
+
+ if (isGridEnabled) {
+ const streamUsers = paginatedStreams.map((stream) => stream.userId);
+
+ gridUsers = users.filter(
+ (user) => !user.loggedOut && !user.left && !streamUsers.includes(user.userId)
+ ).map((user) => ({
+ isGridItem: true,
+ ...user,
+ }));
+ }
+
+ return { streams: paginatedStreams, gridUsers, totalNumberOfStreams: streams.length };
+ }
+
+ stopConnectingStream () {
+ this.deviceId = null;
+ this.isConnecting = false;
+ }
+
+ getConnectingStream(streams) {
+ let connectingStream;
+
+ if (this.isConnecting) {
+ if (this.deviceId) {
+ const stream = this.buildStreamName(Auth.userID, this.deviceId);
+ if (!this.hasStream(streams, stream) && !this.isUserLocked()) {
+ connectingStream = {
+ stream,
+ userId: Auth.userID,
+ name: Auth.fullname,
+ };
+ } else {
+ // Connecting stream is already stored at database
+ this.stopConnectingStream();
+ }
+ } else {
+ logger.error({
+ logCode: 'video_provider_missing_deviceid',
+ }, 'Could not retrieve a valid deviceId');
+ }
+ }
+
+ return connectingStream;
+ }
+
+ buildStreamName(userId, deviceId) {
+ return `${userId}${TOKEN}${deviceId}`;
+ }
+
+ hasVideoStream() {
+ const videoStreams = VideoStreams.findOne({ userId: Auth.userID },
+ { fields: {} });
+ return !!videoStreams;
+ }
+
+ hasStream(streams, stream) {
+ return streams.find(s => s.stream === stream);
+ }
+
+ getMediaServerAdapter() {
+ return getFromMeetingSettings('media-server-video', DEFAULT_VIDEO_MEDIA_SERVER);
+ }
+
+ getMyRole () {
+ return Users.findOne({ userId: Auth.userID },
+ { fields: { role: 1 } })?.role;
+ }
+
+ getRecord() {
+ if (this.record === null) {
+ this.record = getFromUserSettings('bbb_record_video', true);
+ }
+
+ // TODO: Remove this
+ // This is a hack to handle a missing piece at the backend of a particular deploy.
+ // If, at the time the video is shared, the user has a viewer role and
+ // meta_hack-record-viewer-video is 'false' this user won't have this video
+ // stream recorded.
+ if (this.hackRecordViewer === null) {
+ const value = getFromMeetingSettings('hack-record-viewer-video', null);
+ this.hackRecordViewer = value ? value.toLowerCase() === 'true' : true;
+ }
+
+ const hackRecord = this.getMyRole() === ROLE_MODERATOR || this.hackRecordViewer;
+
+ return this.record && hackRecord;
+ }
+
+ filterModeratorOnly(streams) {
+ const amIViewer = this.getMyRole() === ROLE_VIEWER;
+
+ if (amIViewer) {
+ const moderators = Users.find(
+ {
+ meetingId: Auth.meetingID,
+ role: ROLE_MODERATOR,
+ },
+ { fields: { userId: 1 } },
+ ).fetch().map(user => user.userId);
+
+ return streams.reduce((result, stream) => {
+ const { userId } = stream;
+
+ const isModerator = moderators.includes(userId);
+ const isMe = Auth.userID === userId;
+
+ if (isModerator || isMe) result.push(stream);
+
+ return result;
+ }, []);
+ }
+ return streams;
+ }
+
+ filterLocalOnly(streams) {
+ return streams.filter(stream => stream.userId === Auth.userID);
+ }
+
+ disableCam() {
+ const m = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'lockSettingsProps.disableCam': 1 } });
+ return m.lockSettingsProps ? m.lockSettingsProps.disableCam : false;
+ }
+
+ webcamsOnlyForModerator() {
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'usersProp.webcamsOnlyForModerator': 1 } });
+ const user = Users.findOne({ userId: Auth.userID }, { fields: { locked: 1, role: 1 } });
+
+ if (meeting?.usersProp && user?.role !== ROLE_MODERATOR && user?.locked) {
+ return meeting.usersProp.webcamsOnlyForModerator;
+ }
+ return false;
+ }
+
+ hideUserlist() {
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'lockSettingsProps.hideUserList': 1 } });
+ return meeting.lockSettingsProps ? meeting.lockSettingsProps.hideUserList : false;
+ }
+
+ hasCapReached() {
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ {
+ fields: {
+ 'meetingProp.meetingCameraCap': 1,
+ 'usersProp.userCameraCap': 1,
+ },
+ },
+ );
+
+ // If the meeting prop data is unreachable, force a safe return
+ if (!meeting?.usersProp || !meeting?.meetingProp) return true;
+
+ const { meetingCameraCap } = meeting.meetingProp;
+ const { userCameraCap } = meeting.usersProp;
+
+ const meetingCap = meetingCameraCap !== 0 && this.getVideoStreamsCount() >= meetingCameraCap;
+ const userCap = userCameraCap !== 0 && this.getLocalVideoStreamsCount() >= userCameraCap;
+
+ return meetingCap || userCap;
+ }
+
+ getVideoStreamsCount() {
+ const streams = VideoStreams.find({}).count();
+
+ return streams;
+ }
+
+ getLocalVideoStreamsCount() {
+ const localStreams = VideoStreams.find(
+ { userId: Auth.userID }
+ ).count();
+
+ return localStreams;
+ }
+
+ getInfo() {
+ const m = Meetings.findOne({ meetingId: Auth.meetingID },
+ { fields: { 'voiceProp.voiceConf': 1 } });
+ const voiceBridge = m.voiceProp ? m.voiceProp.voiceConf : null;
+ return {
+ userId: Auth.userID,
+ userName: Auth.fullname,
+ meetingId: Auth.meetingID,
+ sessionToken: Auth.sessionToken,
+ voiceBridge,
+ };
+ }
+
+ mirrorOwnWebcam(userId = null) {
+ // only true if setting defined and video ids match
+ const isOwnWebcam = userId ? Auth.userID === userId : true;
+ const isEnabledMirroring = getFromUserSettings('bbb_mirror_own_webcam', MIRROR_WEBCAM);
+ return isOwnWebcam && isEnabledMirroring;
+ }
+
+ isPinEnabled() {
+ return PIN_WEBCAM;
+ }
+
+ // In user-list it is necessary to check if the user is sharing his webcam
+ isVideoPinEnabledForCurrentUser() {
+ const currentUser = Users.findOne({ userId: Auth.userID },
+ { fields: { role: 1 } });
+
+ const isModerator = currentUser?.role === 'MODERATOR';
+ const isBreakout = meetingIsBreakout();
+ const isPinEnabled = this.isPinEnabled();
+
+ return !!(isModerator
+ && isPinEnabled
+ && !isBreakout);
+ }
+
+ getMyStreamId(deviceId) {
+ const videoStream = VideoStreams.findOne(
+ {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ deviceId,
+ }, { fields: { stream: 1 } },
+ );
+ return videoStream ? videoStream.stream : null;
+ }
+
+ isUserLocked() {
+ return !!Users.findOne({
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ locked: true,
+ role: { $ne: ROLE_MODERATOR },
+ }, { fields: {} }) && this.disableCam();
+ }
+
+ lockUser() {
+ if (this.isConnected) {
+ this.exitVideo();
+ }
+ }
+
+ isLocalStream(cameraId) {
+ return cameraId?.startsWith(Auth.userID);
+ }
+
+ playStart(cameraId) {
+ if (this.isLocalStream(cameraId)) {
+ this.sendUserShareWebcam(cameraId);
+ this.joinedVideo();
+ }
+ }
+
+ getCameraProfile() {
+ const profileId = BBBStorage.getItem('WebcamProfileId') || '';
+ const cameraProfile = CAMERA_PROFILES.find(profile => profile.id === profileId)
+ || CAMERA_PROFILES.find(profile => profile.default)
+ || CAMERA_PROFILES[0];
+ const deviceId = BBBStorage.getItem('WebcamDeviceId');
+ if (deviceId) {
+ cameraProfile.constraints = cameraProfile.constraints || {};
+ cameraProfile.constraints.deviceId = { exact: deviceId };
+ }
+
+ return cameraProfile;
+ }
+
+ addCandidateToPeer(peer, candidate, cameraId) {
+ peer.addIceCandidate(candidate).catch((error) => {
+ if (error) {
+ // Just log the error. We can't be sure if a candidate failure on add is
+ // fatal or not, so that's why we have a timeout set up for negotiations
+ // and listeners for ICE state transitioning to failures, so we won't
+ // act on it here
+ logger.error({
+ logCode: 'video_provider_addicecandidate_error',
+ extraInfo: {
+ cameraId,
+ error,
+ },
+ }, `Adding ICE candidate failed for ${cameraId} due to ${error.message}`);
+ }
+ });
+ }
+
+ processInboundIceQueue(peer, cameraId) {
+ while (peer.inboundIceQueue.length) {
+ const candidate = peer.inboundIceQueue.shift();
+ this.addCandidateToPeer(peer, candidate, cameraId);
+ }
+ }
+
+ onBeforeUnload() {
+ this.exitVideo();
+ }
+
+ getStatus() {
+ if (this.isConnecting) return 'videoConnecting';
+ if (this.isConnected) return 'connected';
+ return 'disconnected';
+ }
+
+ disableReason() {
+ const locks = {
+ videoLocked: this.isUserLocked(),
+ camCapReached: this.hasCapReached() && !this.hasVideoStream(),
+ meteorDisconnected: !Meteor.status().connected
+ };
+ const locksKeys = Object.keys(locks);
+ const disableReason = locksKeys.filter( i => locks[i]).shift();
+ return disableReason ? disableReason : false;
+ }
+
+ getRole(isLocal) {
+ return isLocal ? 'share' : 'viewer';
+ }
+
+ getUserParameterProfile() {
+ if (this.userParameterProfile === null) {
+ this.userParameterProfile = getFromUserSettings(
+ 'bbb_preferred_camera_profile',
+ (CAMERA_PROFILES.find(i => i.default) || {}).id || null,
+ );
+ }
+
+ return this.userParameterProfile;
+ }
+
+ isMultipleCamerasEnabled() {
+ // Multiple cameras shouldn't be enabled with video preview skipping
+ // Mobile shouldn't be able to share more than one camera at the same time
+ // Safari needs to implement devicechange event for safe device control
+ return MULTIPLE_CAMERAS
+ && !VideoPreviewService.getSkipVideoPreview()
+ && !this.isMobile
+ && !this.isSafari
+ && this.numberOfDevices > 1;
+ }
+
+ isProfileBetter (newProfileId, originalProfileId) {
+ return CAMERA_PROFILES.findIndex(({ id }) => id === newProfileId)
+ > CAMERA_PROFILES.findIndex(({ id }) => id === originalProfileId);
+ }
+
+ applyBitrate (peer, bitrate) {
+ const peerConnection = peer.peerConnection;
+ if ('RTCRtpSender' in window
+ && 'setParameters' in window.RTCRtpSender.prototype
+ && 'getParameters' in window.RTCRtpSender.prototype) {
+ peerConnection.getSenders().forEach(sender => {
+ const { track } = sender;
+ if (track && track.kind === 'video') {
+ const parameters = sender.getParameters();
+ const normalizedBitrate = bitrate * 1000;
+
+ // The encoder parameters might not be up yet; if that's the case,
+ // add a filler object so we can alter the parameters anyways
+ if (parameters.encodings == null || parameters.encodings.length === 0) {
+ parameters.encodings = [{}];
+ }
+
+ // Only reset bitrate if it changed in some way to avoid enconder fluctuations
+ if (parameters.encodings[0].maxBitrate !== normalizedBitrate) {
+ parameters.encodings[0].maxBitrate = normalizedBitrate;
+ sender.setParameters(parameters)
+ .then(() => {
+ logger.info({
+ logCode: 'video_provider_bitratechange',
+ extraInfo: { bitrate },
+ }, `Bitrate changed: ${bitrate}`);
+ })
+ .catch(error => {
+ logger.warn({
+ logCode: 'video_provider_bitratechange_failed',
+ extraInfo: { bitrate, errorMessage: error.message, errorCode: error.code },
+ }, `Bitrate change failed.`);
+ });
+ }
+ }
+ })
+ }
+ }
+
+ // Some browsers (mainly iOS Safari) garble the stream if a constraint is
+ // reconfigured without propagating previous height/width info
+ reapplyResolutionIfNeeded (track, constraints) {
+ if (typeof track.getSettings !== 'function') {
+ return constraints;
+ }
+
+ const trackSettings = track.getSettings();
+
+ if (trackSettings.width && trackSettings.height) {
+ return {
+ ...constraints,
+ width: trackSettings.width,
+ height: trackSettings.height,
+ };
+ }
+
+ return constraints;
+ }
+
+ applyCameraProfile (peer, profileId) {
+ const profile = CAMERA_PROFILES.find((targetProfile) => targetProfile.id === profileId);
+
+ // When this should be skipped:
+ // 1 - Badly defined profile
+ // 2 - Badly defined peer (ie {})
+ // 3 - The target profile is already applied
+ // 4 - The targetr profile is better than the original profile
+ if (!profile
+ || peer == null
+ || peer.peerConnection == null
+ || peer.currentProfileId === profileId
+ || this.isProfileBetter(profileId, peer.originalProfileId)) {
+ return;
+ }
+
+ const { bitrate, constraints } = profile;
+
+ if (bitrate) this.applyBitrate(peer, bitrate);
+
+ if (CAMERA_QUALITY_THR_CONSTRAINTS
+ && constraints
+ && typeof constraints === 'object'
+ ) {
+ peer.peerConnection.getSenders().forEach((sender) => {
+ const { track } = sender;
+ if (track && track.kind === 'video' && typeof track.applyConstraints === 'function') {
+ const normalizedVideoConstraints = this.reapplyResolutionIfNeeded(track, constraints);
+ track.applyConstraints(normalizedVideoConstraints)
+ .catch((error) => {
+ logger.warn({
+ logCode: 'video_provider_constraintchange_failed',
+ extraInfo: { errorName: error.name, errorCode: error.code },
+ }, 'Error applying camera profile');
+ });
+ }
+ });
+ }
+
+ logger.info({
+ logCode: 'video_provider_profile_applied',
+ extraInfo: { profileId },
+ }, `New camera profile applied: ${profileId}`);
+
+ peer.currentProfileId = profileId;
+ }
+
+ getThreshold (numberOfPublishers) {
+ let targetThreshold = { threshold: 0, profile: 'original' };
+ let finalThreshold = { threshold: 0, profile: 'original' };
+
+ for(let mapIndex = 0; mapIndex < CAMERA_QUALITY_THRESHOLDS.length; mapIndex++) {
+ targetThreshold = CAMERA_QUALITY_THRESHOLDS[mapIndex];
+ if (targetThreshold.threshold <= numberOfPublishers) {
+ finalThreshold = targetThreshold;
+ }
+ }
+
+ return finalThreshold;
+ }
+
+ getPreloadedStream () {
+ if (this.deviceId == null) return;
+ return VideoPreviewService.getStream(this.deviceId);
+ }
+
+ /**
+ * Get all active video peers.
+ * @returns An Object containing the reference for all active peers peers
+ */
+ getActivePeers() {
+ const videoData = this.getVideoStreams();
+
+ if (!videoData) return null;
+
+ const { streams: activeVideoStreams } = videoData;
+
+ if (!activeVideoStreams) return null;
+
+ const activePeers = {};
+
+ activeVideoStreams.forEach((stream) => {
+ if (this.webRtcPeersRef[stream.stream]) {
+ activePeers[stream.stream] = this.webRtcPeersRef[stream.stream].peerConnection;
+ }
+ });
+
+ return activePeers;
+ }
+
+ /**
+ * Get stats about all active video peer.
+ * We filter the status based on FILTER_VIDEO_STATS constant.
+ *
+ * For more information see:
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats
+ * and
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
+ * @returns An Object containing the information about each active peer.
+ * The returned object follows the format:
+ * {
+ * peerId: RTCStatsReport
+ * }
+ */
+ async getStats() {
+ const peers = this.getActivePeers();
+
+ if (!peers) return null;
+
+ const stats = {};
+
+ await Promise.all(
+ Object.keys(peers).map(async (peerId) => {
+ const peerStats = await peers[peerId].getStats();
+
+ const videoStats = {};
+
+ peerStats.forEach((stat) => {
+ if (FILTER_VIDEO_STATS.includes(stat.type)) {
+ videoStats[stat.type] = stat;
+ }
+ });
+ stats[peerId] = videoStats;
+ })
+ );
+
+ return stats;
+ }
+
+ updatePeerDictionaryReference(newRef) {
+ this.webRtcPeersRef = newRef;
+ }
+}
+
+const videoService = new VideoService();
+
+export default {
+ storeDeviceIds: () => videoService.storeDeviceIds(),
+ exitVideo: () => videoService.exitVideo(),
+ joinVideo: deviceId => videoService.joinVideo(deviceId),
+ stopVideo: cameraId => videoService.stopVideo(cameraId),
+ getVideoStreams: () => videoService.getVideoStreams(),
+ getInfo: () => videoService.getInfo(),
+ getMyStreamId: deviceId => videoService.getMyStreamId(deviceId),
+ isUserLocked: () => videoService.isUserLocked(),
+ lockUser: () => videoService.lockUser(),
+ getAuthenticatedURL: () => videoService.getAuthenticatedURL(),
+ isLocalStream: cameraId => videoService.isLocalStream(cameraId),
+ hasVideoStream: () => videoService.hasVideoStream(),
+ getStatus: () => videoService.getStatus(),
+ disableReason: () => videoService.disableReason(),
+ playStart: cameraId => videoService.playStart(cameraId),
+ getCameraProfile: () => videoService.getCameraProfile(),
+ addCandidateToPeer: (peer, candidate, cameraId) => videoService.addCandidateToPeer(peer, candidate, cameraId),
+ processInboundIceQueue: (peer, cameraId) => videoService.processInboundIceQueue(peer, cameraId),
+ getRole: isLocal => videoService.getRole(isLocal),
+ getMediaServerAdapter: () => videoService.getMediaServerAdapter(),
+ getRecord: () => videoService.getRecord(),
+ getSharedDevices: () => videoService.getSharedDevices(),
+ getUserParameterProfile: () => videoService.getUserParameterProfile(),
+ isMultipleCamerasEnabled: () => videoService.isMultipleCamerasEnabled(),
+ mirrorOwnWebcam: userId => videoService.mirrorOwnWebcam(userId),
+ hasCapReached: () => videoService.hasCapReached(),
+ onBeforeUnload: () => videoService.onBeforeUnload(),
+ notify: message => notify(message, 'error', 'video'),
+ updateNumberOfDevices: devices => videoService.updateNumberOfDevices(devices),
+ applyCameraProfile: debounce(
+ videoService.applyCameraProfile.bind(videoService),
+ CAMERA_QUALITY_THR_DEBOUNCE,
+ { leading: false, trailing: true },
+ ),
+ getThreshold: (numberOfPublishers) => videoService.getThreshold(numberOfPublishers),
+ isPaginationEnabled: () => videoService.isPaginationEnabled(),
+ getNumberOfPages: () => videoService.getNumberOfPages(),
+ getCurrentVideoPageIndex: () => videoService.getCurrentVideoPageIndex(),
+ getPreviousVideoPage: () => videoService.getPreviousVideoPage(),
+ getNextVideoPage: () => videoService.getNextVideoPage(),
+ getPageChangeDebounceTime: () => { return PAGE_CHANGE_DEBOUNCE_TIME },
+ getUsersIdFromVideoStreams: () => videoService.getUsersIdFromVideoStreams(),
+ shouldRenderPaginationToggle: () => videoService.shouldRenderPaginationToggle(),
+ toggleVideoPin: (userId, pin) => videoService.toggleVideoPin(userId, pin),
+ getVideoPinByUser: (userId) => videoService.getVideoPinByUser(userId),
+ isVideoPinEnabledForCurrentUser: () => videoService.isVideoPinEnabledForCurrentUser(),
+ isPinEnabled: () => videoService.isPinEnabled(),
+ getPreloadedStream: () => videoService.getPreloadedStream(),
+ getStats: () => videoService.getStats(),
+ updatePeerDictionaryReference: (newRef) => videoService.updatePeerDictionaryReference(newRef),
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/stream-sorting.js b/src/2.7.12/imports/ui/components/video-provider/stream-sorting.js
new file mode 100644
index 00000000..dcee88b9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/stream-sorting.js
@@ -0,0 +1,140 @@
+import UserListService from '/imports/ui/components/user-list/service';
+import Auth from '/imports/ui/services/auth';
+
+const DEFAULT_SORTING_MODE = 'LOCAL_ALPHABETICAL';
+
+// pin first
+export const sortPin = (s1, s2) => {
+ if (s1.pin) {
+ return -1;
+ } if (s2.pin) {
+ return 1;
+ }
+ return 0;
+};
+
+export const mandatorySorting = (s1, s2) => sortPin(s1, s2);
+
+// lastFloorTime, descending
+export const sortVoiceActivity = (s1, s2) => {
+ if (s2.lastFloorTime < s1.lastFloorTime) {
+ return -1;
+ } else if (s2.lastFloorTime > s1.lastFloorTime) {
+ return 1;
+ } else return 0;
+};
+
+// pin -> lastFloorTime (descending) -> alphabetical -> local
+export const sortVoiceActivityLocal = (s1, s2) => {
+ if (s1.userId === Auth.userID) {
+ return 1;
+ } if (s2.userId === Auth.userID) {
+ return -1;
+ }
+
+ return mandatorySorting(s1, s2)
+ || sortVoiceActivity(s1, s2)
+ || UserListService.sortUsersByName(s1, s2);
+};
+
+// pin -> local -> lastFloorTime (descending) -> alphabetical
+export const sortLocalVoiceActivity = (s1, s2) => mandatorySorting(s1, s2)
+ || UserListService.sortUsersByCurrent(s1, s2)
+ || sortVoiceActivity(s1, s2)
+ || UserListService.sortUsersByName(s1, s2);
+
+// pin -> local -> alphabetic
+export const sortLocalAlphabetical = (s1, s2) => mandatorySorting(s1, s2)
+ || UserListService.sortUsersByCurrent(s1, s2)
+ || UserListService.sortUsersByName(s1, s2);
+
+export const sortPresenter = (s1, s2) => {
+ if (UserListService.isUserPresenter(s1.userId)) {
+ return -1;
+ } else if (UserListService.isUserPresenter(s2.userId)) {
+ return 1;
+ } else return 0;
+};
+
+// pin -> local -> presenter -> alphabetical
+export const sortLocalPresenterAlphabetical = (s1, s2) => mandatorySorting(s1, s2)
+ || UserListService.sortUsersByCurrent(s1, s2)
+ || sortPresenter(s1, s2)
+ || UserListService.sortUsersByName(s1, s2);
+
+// SORTING_METHODS: registrar of configurable video stream sorting modes
+// Keys are the method name (String) which are to be configured in settings.yml
+// ${streamSortingMethod} flag.
+//
+// Values are a objects which describe the sorting mode:
+// - sortingMethod (function): a sorting function defined in this module
+// - neededData (Object): data members that will be fetched from the server's
+// video-streams collection
+// - filter (Boolean): whether the sorted stream list has to be post processed
+// to remove uneeded attributes. The needed attributes are: userId, streams
+// and name. Anything other than that is superfluous.
+// - localFirst (Boolean): true pushes local streams to the beginning of the list,
+// false to the end
+// The reason why this flags exists is due to pagination: local streams are
+// stripped out of the streams list prior to sorting+partiotioning. They're
+// added (pushed) afterwards. To avoid re-sorting the page, this flag indicates
+// where it should go.
+//
+// To add a new sorting flavor:
+// 1 - implement a sorting function, add it here (like eg sortPresenterAlphabetical)
+// 1.1.: the sorting function has the same behaviour as a regular .sort callback
+// 2 - add an entry to SORTING_METHODS, the key being the name to be used
+// in settings.yml and the value object like the aforementioned
+const MANDATORY_DATA_TYPES = {
+ userId: 1, stream: 1, name: 1, sortName: 1, deviceId: 1, floor: 1, pin: 1,
+};
+const SORTING_METHODS = Object.freeze({
+ // Default
+ LOCAL_ALPHABETICAL: {
+ sortingMethod: sortLocalAlphabetical,
+ neededDataTypes: MANDATORY_DATA_TYPES,
+ localFirst: true,
+ },
+ VOICE_ACTIVITY_LOCAL: {
+ sortingMethod: sortVoiceActivityLocal,
+ neededDataTypes: {
+ lastFloorTime: 1, floor: 1, ...MANDATORY_DATA_TYPES,
+ },
+ filter: true,
+ localFirst: false,
+ },
+ LOCAL_VOICE_ACTIVITY: {
+ sortingMethod: sortLocalVoiceActivity,
+ neededDataTypes: {
+ lastFloorTime: 1, floor: 1, ...MANDATORY_DATA_TYPES,
+ },
+ filter: true,
+ localFirst: true,
+ },
+ LOCAL_PRESENTER_ALPHABETICAL: {
+ sortingMethod: sortLocalPresenterAlphabetical,
+ neededDataTypes: MANDATORY_DATA_TYPES,
+ localFirst: true,
+ }
+});
+
+export const getSortingMethod = (identifier) => {
+ return SORTING_METHODS[identifier] || SORTING_METHODS[DEFAULT_SORTING_MODE];
+};
+
+export const sortVideoStreams = (streams, mode) => {
+ const { sortingMethod, filter } = getSortingMethod(mode);
+ const sorted = streams.sort(sortingMethod);
+
+ if (!filter) return sorted;
+
+ return sorted.map(videoStream => ({
+ stream: videoStream.stream,
+ isGridItem: videoStream?.isGridItem,
+ userId: videoStream.userId,
+ name: videoStream.name,
+ sortName: videoStream.sortName,
+ floor: videoStream.floor,
+ pin: videoStream.pin,
+ }));
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-button/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-button/component.jsx
new file mode 100644
index 00000000..d5490af5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-button/component.jsx
@@ -0,0 +1,244 @@
+import React, { memo, useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
+import VideoService from '../service';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+import deviceInfo from '/imports/utils/deviceInfo';
+import { debounce } from '/imports/utils/debounce';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import { isVirtualBackgroundsEnabled } from '/imports/ui/services/features';
+import Button from '/imports/ui/components/common/button/component';
+import VideoPreviewContainer from '/imports/ui/components/video-preview/container';
+import Settings from '/imports/ui/services/settings';
+import PreviewService from '/imports/ui/components/video-preview/service';
+
+const ENABLE_WEBCAM_SELECTOR_BUTTON = Meteor.settings.public.app.enableWebcamSelectorButton;
+const ENABLE_CAMERA_BRIGHTNESS = Meteor.settings.public.app.enableCameraBrightness;
+
+const intlMessages = defineMessages({
+ videoSettings: {
+ id: 'app.video.videoSettings',
+ description: 'Open video settings',
+ },
+ visualEffects: {
+ id: 'app.video.visualEffects',
+ description: 'Visual effects label',
+ },
+ joinVideo: {
+ id: 'app.video.joinVideo',
+ description: 'Join video button label',
+ },
+ leaveVideo: {
+ id: 'app.video.leaveVideo',
+ description: 'Leave video button label',
+ },
+ advancedVideo: {
+ id: 'app.video.advancedVideo',
+ description: 'Open advanced video label',
+ },
+ videoLocked: {
+ id: 'app.video.videoLocked',
+ description: 'video disabled label',
+ },
+ videoConnecting: {
+ id: 'app.video.connecting',
+ description: 'video connecting label',
+ },
+ camCapReached: {
+ id: 'app.video.meetingCamCapReached',
+ description: 'meeting camera cap label',
+ },
+ meteorDisconnected: {
+ id: 'app.video.clientDisconnected',
+ description: 'Meteor disconnected label',
+ },
+});
+
+const JOIN_VIDEO_DELAY_MILLISECONDS = 500;
+
+const propTypes = {
+ intl: PropTypes.object.isRequired,
+ hasVideoStream: PropTypes.bool.isRequired,
+ status: PropTypes.string.isRequired,
+};
+
+const JoinVideoButton = ({
+ intl,
+ hasVideoStream,
+ status,
+ disableReason,
+ updateSettings,
+}) => {
+ const { isMobile } = deviceInfo;
+ const isMobileSharingCamera = hasVideoStream && isMobile;
+ const isDesktopSharingCamera = hasVideoStream && !isMobile;
+ const shouldEnableWebcamSelectorButton = ENABLE_WEBCAM_SELECTOR_BUTTON
+ && isDesktopSharingCamera;
+ const shouldEnableWebcamVisualEffectsButton = (isVirtualBackgroundsEnabled()
+ || ENABLE_CAMERA_BRIGHTNESS)
+ && hasVideoStream
+ && !isMobile;
+ const exitVideo = () => isDesktopSharingCamera && (!VideoService.isMultipleCamerasEnabled()
+ || shouldEnableWebcamSelectorButton);
+
+ const [propsToPassModal, setPropsToPassModal] = useState({});
+ const [forceOpen, setForceOpen] = useState(false);
+ const [isVideoPreviewModalOpen, setVideoPreviewModalIsOpen] = useState(false);
+ const [wasSelfViewDisabled, setWasSelfViewDisabled] = useState(false);
+
+ useEffect(() => {
+ const isSelfViewDisabled = Settings.application.selfViewDisable;
+
+ if (isVideoPreviewModalOpen && isSelfViewDisabled) {
+ setWasSelfViewDisabled(true);
+ const obj = {
+ application:
+ { ...Settings.application, selfViewDisable: false },
+ };
+ updateSettings(obj);
+ }
+ }, [isVideoPreviewModalOpen]);
+
+ const handleOnClick = debounce(() => {
+ switch (status) {
+ case 'videoConnecting':
+ VideoService.stopVideo();
+ break;
+ case 'connected':
+ default:
+ if (exitVideo()) {
+ VideoService.exitVideo();
+ PreviewService.clearStreams();
+ } else {
+ setForceOpen(isMobileSharingCamera);
+ setVideoPreviewModalIsOpen(true);
+ }
+ }
+ }, JOIN_VIDEO_DELAY_MILLISECONDS);
+
+ const handleOpenAdvancedOptions = (callback) => {
+ if (callback) callback();
+ setForceOpen(isDesktopSharingCamera);
+ setVideoPreviewModalIsOpen(true);
+ };
+
+ const getMessageFromStatus = () => {
+ let statusMessage = status;
+ if (status !== 'videoConnecting') {
+ statusMessage = exitVideo() ? 'leaveVideo' : 'joinVideo';
+ }
+ return statusMessage;
+ };
+
+ const label = disableReason
+ ? intl.formatMessage(intlMessages[disableReason])
+ : intl.formatMessage(intlMessages[getMessageFromStatus()]);
+
+ const isSharing = hasVideoStream || status === 'videoConnecting';
+
+ const renderUserActions = () => {
+ const actions = [];
+
+ if (shouldEnableWebcamSelectorButton) {
+ actions.push(
+ {
+ key: 'advancedVideo',
+ label: intl.formatMessage(intlMessages.advancedVideo),
+ onClick: () => handleOpenAdvancedOptions(),
+ dataTest: 'advancedVideoSettingsButton',
+ },
+ );
+ }
+
+ if (shouldEnableWebcamVisualEffectsButton) {
+ actions.push(
+ {
+ key: 'virtualBgSelection',
+ label: intl.formatMessage(intlMessages.visualEffects),
+ onClick: () => handleOpenAdvancedOptions((
+ ) => setPropsToPassModal({ isVisualEffects: true })),
+ },
+ );
+ }
+
+ if (actions.length === 0) return null;
+ const customStyles = { top: '-3.6rem' };
+
+ return (
+
+ )}
+ actions={actions}
+ opts={{
+ id: 'video-dropdown-menu',
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'top', horizontal: 'center' },
+ transformOrigin: { vertical: 'top', horizontal: 'center' },
+ }}
+ />
+ );
+ };
+
+ return (
+ <>
+
+
+ {renderUserActions()}
+
+ {isVideoPreviewModalOpen ? (
+ {
+ if (wasSelfViewDisabled) {
+ setTimeout(() => {
+ const obj = {
+ application:
+ { ...Settings.application, selfViewDisable: true },
+ };
+ updateSettings(obj);
+ setWasSelfViewDisabled(false);
+ }, 100);
+ }
+ setPropsToPassModal({});
+ setForceOpen(false);
+ },
+ forceOpen,
+ priority: 'low',
+ setIsOpen: setVideoPreviewModalIsOpen,
+ isOpen: isVideoPreviewModalOpen,
+ }}
+ {...propsToPassModal}
+ />
+ ) : null}
+ >
+ );
+};
+
+JoinVideoButton.propTypes = propTypes;
+
+export default injectIntl(memo(JoinVideoButton));
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-button/container.jsx b/src/2.7.12/imports/ui/components/video-provider/video-button/container.jsx
new file mode 100644
index 00000000..0d04ffb5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-button/container.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import { injectIntl } from 'react-intl';
+import JoinVideoButton from './component';
+import VideoService from '../service';
+import {
+ updateSettings,
+} from '/imports/ui/components/settings/service';
+
+const JoinVideoOptionsContainer = (props) => {
+ const {
+ updateSettings,
+ hasVideoStream,
+ disableReason,
+ status,
+ intl,
+ ...restProps
+ } = props;
+
+ return (
+
+ );
+};
+
+export default injectIntl(withTracker(() => ({
+ hasVideoStream: VideoService.hasVideoStream(),
+ updateSettings,
+ disableReason: VideoService.disableReason(),
+ status: VideoService.getStatus(),
+}))(JoinVideoOptionsContainer));
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-button/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-button/styles.js
new file mode 100644
index 00000000..dbb4f2dd
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-button/styles.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+const OffsetBottom = styled.div`
+ position: relative;
+`;
+
+export default {
+ OffsetBottom,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/component.jsx
new file mode 100644
index 00000000..45a96b3d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/component.jsx
@@ -0,0 +1,397 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { throttle } from '/imports/utils/throttle';
+import { range } from '/imports/utils/array-utils';
+import Styled from './styles';
+import VideoListItemContainer from './video-list-item/container';
+import AutoplayOverlay from '../../media/autoplay-overlay/component';
+import logger from '/imports/startup/client/logger';
+import playAndRetry from '/imports/utils/mediaElementPlayRetry';
+import VideoService from '/imports/ui/components/video-provider/service';
+import { ACTIONS } from '../../layout/enums';
+
+const propTypes = {
+ streams: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onVideoItemMount: PropTypes.func.isRequired,
+ onVideoItemUnmount: PropTypes.func.isRequired,
+ intl: PropTypes.objectOf(Object).isRequired,
+ swapLayout: PropTypes.bool.isRequired,
+ numberOfPages: PropTypes.number.isRequired,
+ currentVideoPageIndex: PropTypes.number.isRequired,
+};
+
+const intlMessages = defineMessages({
+ autoplayBlockedDesc: {
+ id: 'app.videoDock.autoplayBlockedDesc',
+ },
+ autoplayAllowLabel: {
+ id: 'app.videoDock.autoplayAllowLabel',
+ },
+ nextPageLabel: {
+ id: 'app.video.pagination.nextPage',
+ },
+ prevPageLabel: {
+ id: 'app.video.pagination.prevPage',
+ },
+});
+
+const findOptimalGrid = (canvasWidth, canvasHeight, gutter, aspectRatio, numItems, columns = 1) => {
+ const rows = Math.ceil(numItems / columns);
+ const gutterTotalWidth = (columns - 1) * gutter;
+ const gutterTotalHeight = (rows - 1) * gutter;
+ const usableWidth = canvasWidth - gutterTotalWidth;
+ const usableHeight = canvasHeight - gutterTotalHeight;
+ let cellWidth = Math.floor(usableWidth / columns);
+ let cellHeight = Math.ceil(cellWidth / aspectRatio);
+ if ((cellHeight * rows) > usableHeight) {
+ cellHeight = Math.floor(usableHeight / rows);
+ cellWidth = Math.ceil(cellHeight * aspectRatio);
+ }
+ return {
+ columns,
+ rows,
+ width: (cellWidth * columns) + gutterTotalWidth,
+ height: (cellHeight * rows) + gutterTotalHeight,
+ filledArea: (cellWidth * cellHeight) * numItems,
+ };
+};
+
+const ASPECT_RATIO = 4 / 3;
+// const ACTION_NAME_BACKGROUND = 'blurBackground';
+
+class VideoList extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ optimalGrid: {
+ cols: 1,
+ rows: 1,
+ filledArea: 0,
+ },
+ autoplayBlocked: false,
+ };
+
+ this.ticking = false;
+ this.grid = null;
+ this.canvas = null;
+ this.failedMediaElements = [];
+ this.handleCanvasResize = throttle(this.handleCanvasResize.bind(this), 66,
+ {
+ leading: true,
+ trailing: true,
+ });
+ this.setOptimalGrid = this.setOptimalGrid.bind(this);
+ this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this);
+ this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
+ this.autoplayWasHandled = false;
+ }
+
+ componentDidMount() {
+ this.handleCanvasResize();
+ window.addEventListener('resize', this.handleCanvasResize, false);
+ window.addEventListener('videoPlayFailed', this.handlePlayElementFailed);
+ }
+
+ componentDidUpdate(prevProps) {
+ const { layoutType, cameraDock, streams, focusedId } = this.props;
+ const { width: cameraDockWidth, height: cameraDockHeight } = cameraDock;
+ const {
+ layoutType: prevLayoutType,
+ cameraDock: prevCameraDock,
+ streams: prevStreams,
+ focusedId: prevFocusedId,
+ } = prevProps;
+ const { width: prevCameraDockWidth, height: prevCameraDockHeight } = prevCameraDock;
+
+ if (layoutType !== prevLayoutType
+ || focusedId !== prevFocusedId
+ || cameraDockWidth !== prevCameraDockWidth
+ || cameraDockHeight !== prevCameraDockHeight
+ || streams.length !== prevStreams.length) {
+ this.handleCanvasResize();
+ }
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.handleCanvasResize, false);
+ window.removeEventListener('videoPlayFailed', this.handlePlayElementFailed);
+ }
+
+ handleAllowAutoplay() {
+ const { autoplayBlocked } = this.state;
+
+ logger.info({
+ logCode: 'video_provider_autoplay_allowed',
+ }, 'Video media autoplay allowed by the user');
+
+ this.autoplayWasHandled = true;
+ window.removeEventListener('videoPlayFailed', this.handlePlayElementFailed);
+ while (this.failedMediaElements.length) {
+ const mediaElement = this.failedMediaElements.shift();
+ if (mediaElement) {
+ const played = playAndRetry(mediaElement);
+ if (!played) {
+ logger.error({
+ logCode: 'video_provider_autoplay_handling_failed',
+ }, 'Video autoplay handling failed to play media');
+ } else {
+ logger.info({
+ logCode: 'video_provider_media_play_success',
+ }, 'Video media played successfully');
+ }
+ }
+ }
+ if (autoplayBlocked) { this.setState({ autoplayBlocked: false }); }
+ }
+
+ handlePlayElementFailed(e) {
+ const { mediaElement } = e.detail;
+ const { autoplayBlocked } = this.state;
+
+ e.stopPropagation();
+ this.failedMediaElements.push(mediaElement);
+ if (!autoplayBlocked && !this.autoplayWasHandled) {
+ logger.info({
+ logCode: 'video_provider_autoplay_prompt',
+ }, 'Prompting user for action to play video media');
+ this.setState({ autoplayBlocked: true });
+ }
+ }
+
+ handleCanvasResize() {
+ if (!this.ticking) {
+ window.requestAnimationFrame(() => {
+ this.ticking = false;
+ this.setOptimalGrid();
+ });
+ }
+ this.ticking = true;
+ }
+
+ setOptimalGrid() {
+ const {
+ streams,
+ cameraDock,
+ layoutContextDispatch,
+ } = this.props;
+ let numItems = streams.length;
+
+ if (numItems < 1 || !this.canvas || !this.grid) {
+ return;
+ }
+ const { focusedId } = this.props;
+ const canvasWidth = cameraDock?.width;
+ const canvasHeight = cameraDock?.height;
+
+ const gridGutter = parseInt(window.getComputedStyle(this.grid)
+ .getPropertyValue('grid-row-gap'), 10);
+
+ const hasFocusedItem = streams.filter(s => s.stream === focusedId).length && numItems > 2;
+
+ // Has a focused item so we need +3 cells
+ if (hasFocusedItem) {
+ numItems += 3;
+ }
+ const optimalGrid = range(1, numItems + 1)
+ .reduce((currentGrid, col) => {
+ const testGrid = findOptimalGrid(
+ canvasWidth, canvasHeight, gridGutter,
+ ASPECT_RATIO, numItems, col,
+ );
+ // We need a minimun of 2 rows and columns for the focused
+ const focusedConstraint = hasFocusedItem ? testGrid.rows > 1 && testGrid.columns > 1 : true;
+ const betterThanCurrent = testGrid.filledArea > currentGrid.filledArea;
+ return focusedConstraint && betterThanCurrent ? testGrid : currentGrid;
+ }, { filledArea: 0 });
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_OPTIMAL_GRID_SIZE,
+ value: {
+ width: optimalGrid.width,
+ height: optimalGrid.height,
+ },
+ });
+ this.setState({
+ optimalGrid,
+ });
+ }
+
+ displayPageButtons() {
+ const { numberOfPages, cameraDock } = this.props;
+ const { width: cameraDockWidth } = cameraDock;
+
+ if (!VideoService.isPaginationEnabled() || numberOfPages <= 1 || cameraDockWidth === 0) {
+ return false;
+ }
+
+ return true;
+ }
+
+ renderNextPageButton() {
+ const {
+ intl,
+ numberOfPages,
+ currentVideoPageIndex,
+ cameraDock,
+ } = this.props;
+ const { position } = cameraDock;
+
+ if (!this.displayPageButtons()) return null;
+
+ const currentPage = currentVideoPageIndex + 1;
+ const nextPageLabel = intl.formatMessage(intlMessages.nextPageLabel);
+ const nextPageDetailedLabel = `${nextPageLabel} (${currentPage}/${numberOfPages})`;
+
+ return (
+
+ );
+ }
+
+ renderPreviousPageButton() {
+ const {
+ intl,
+ currentVideoPageIndex,
+ numberOfPages,
+ cameraDock,
+ } = this.props;
+ const { position } = cameraDock;
+
+ if (!this.displayPageButtons()) return null;
+
+ const currentPage = currentVideoPageIndex + 1;
+ const prevPageLabel = intl.formatMessage(intlMessages.prevPageLabel);
+ const prevPageDetailedLabel = `${prevPageLabel} (${currentPage}/${numberOfPages})`;
+
+ return (
+
+ );
+ }
+
+ renderVideoList() {
+ const {
+ streams,
+ onVirtualBgDrop,
+ onVideoItemMount,
+ onVideoItemUnmount,
+ swapLayout,
+ handleVideoFocus,
+ focusedId,
+ } = this.props;
+ const numOfStreams = streams.length;
+
+ let videoItems = streams;
+
+ return videoItems.map((item) => {
+ const { userId, name } = item;
+ const isStream = !!item.stream;
+ const stream = isStream ? item.stream : null;
+ const key = isStream ? stream : userId;
+ const isFocused = isStream && focusedId === stream && numOfStreams > 2;
+
+ return (
+
+ {
+ this.handleCanvasResize();
+ if (isStream) onVideoItemMount(stream, videoRef);
+ }}
+ onVideoItemUnmount={onVideoItemUnmount}
+ swapLayout={swapLayout}
+ onVirtualBgDrop={(type, name, data) => { return isStream ? onVirtualBgDrop(stream, type, name, data) : null; }}
+ />
+
+ );
+ });
+ }
+
+ render() {
+ const {
+ streams,
+ intl,
+ cameraDock,
+ isGridEnabled,
+ } = this.props;
+ const { optimalGrid, autoplayBlocked } = this.state;
+ const { position } = cameraDock;
+
+ return (
+ {
+ this.canvas = ref;
+ }}
+ style={{
+ minHeight: 'inherit',
+ }}
+ >
+ {this.renderPreviousPageButton()}
+
+ {!streams.length && !isGridEnabled ? null : (
+ {
+ this.grid = ref;
+ }}
+ style={{
+ width: `${optimalGrid.width}px`,
+ height: `${optimalGrid.height}px`,
+ gridTemplateColumns: `repeat(${optimalGrid.columns}, 1fr)`,
+ gridTemplateRows: `repeat(${optimalGrid.rows}, 1fr)`,
+ }}
+ >
+ {this.renderVideoList()}
+
+ )}
+ {!autoplayBlocked ? null : (
+
+ )}
+
+ {
+ (position === 'contentRight' || position === 'contentLeft')
+ &&
+ }
+
+ {this.renderNextPageButton()}
+
+ );
+ }
+}
+
+VideoList.propTypes = propTypes;
+
+export default injectIntl(VideoList);
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/container.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/container.jsx
new file mode 100644
index 00000000..4d16a5b8
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/container.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import VideoList from '/imports/ui/components/video-provider/video-list/component';
+import VideoService from '/imports/ui/components/video-provider/service';
+import { layoutSelect, layoutSelectOutput, layoutDispatch } from '../../layout/context';
+import Users from '/imports/api/users';
+
+const VideoListContainer = ({ children, ...props }) => {
+ const layoutType = layoutSelect((i) => i.layoutType);
+ const cameraDock = layoutSelectOutput((i) => i.cameraDock);
+ const layoutContextDispatch = layoutDispatch();
+ const { streams } = props;
+
+ return (
+ !streams.length
+ ? null
+ : (
+
+ {children}
+
+ )
+ );
+};
+
+export default withTracker((props) => {
+ const { streams } = props;
+
+ return {
+ ...props,
+ numberOfPages: VideoService.getNumberOfPages(),
+ streams: streams.filter((stream) => Users.findOne({ userId: stream.userId },
+ {
+ fields: {
+ userId: 1,
+ },
+ })),
+ };
+})(VideoListContainer);
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/styles.js
new file mode 100644
index 00000000..ad62d03a
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/styles.js
@@ -0,0 +1,118 @@
+import styled from 'styled-components';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { mdPaddingX } from '/imports/ui/stylesheets/styled-components/general';
+import { mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { actionsBarHeight, navbarHeight } from '/imports/ui/stylesheets/styled-components/general';
+import Button from '/imports/ui/components/common/button/component';
+
+const NextPageButton = styled(Button)`
+ color: ${colorWhite};
+ width: ${mdPaddingX};
+
+ & > i {
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+
+ margin-left: 1px;
+
+ @media ${mediumUp} {
+ margin-left: 2px;
+ }
+
+ ${({ position }) => (position === 'contentRight' || position === 'contentLeft') && `
+ order: 3;
+ margin-right: 2px;
+ `}
+`;
+
+const PreviousPageButton = styled(Button)`
+ color: ${colorWhite};
+ width: ${mdPaddingX};
+
+ i {
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+
+ margin-right: 1px;
+
+ @media ${mediumUp} {
+ margin-right: 2px;
+ }
+
+ ${({ position }) => (position === 'contentRight' || position === 'contentLeft') && `
+ order: 2;
+ margin-left: 2px;
+ `}
+`;
+
+const VideoListItem = styled.div`
+ display: flex;
+ overflow: hidden;
+ width: 100%;
+ max-height: 100%;
+
+ ${({ focused }) => focused && `
+ grid-column: 1 / span 2;
+ grid-row: 1 / span 2;
+ `}
+`;
+
+const VideoCanvas = styled.div`
+ position: absolute;
+ width: 100%;
+ min-height: calc((100vh - calc(${navbarHeight} + ${actionsBarHeight})) * 0.2);
+ height: 100%;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ ${({ position }) => (position === 'contentRight' || position === 'contentLeft') && `
+ flex-wrap: wrap;
+ align-content: center;
+ order: 0;
+ `}
+`;
+
+const VideoList = styled.div`
+ display: grid;
+
+ grid-auto-flow: dense;
+ grid-gap: 1px;
+
+ justify-content: center;
+
+ @media ${mediumUp} {
+ grid-gap: 2px;
+ }
+`;
+
+const Break = styled.div`
+ order: 1;
+ flex-basis: 100%;
+ height: 5px;
+`;
+
+export default {
+ NextPageButton,
+ PreviousPageButton,
+ VideoListItem,
+ VideoCanvas,
+ VideoList,
+ Break,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/component.jsx
new file mode 100644
index 00000000..4aa349f9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/component.jsx
@@ -0,0 +1,305 @@
+/* eslint-disable no-nested-ternary */
+import React, { useEffect, useRef, useState } from 'react';
+import { injectIntl, defineMessages, useIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import UserActions from '/imports/ui/components/video-provider/video-list/video-list-item/user-actions/component';
+import UserStatus from '/imports/ui/components/video-provider/video-list/video-list-item/user-status/component';
+import PinArea from '/imports/ui/components/video-provider/video-list/video-list-item/pin-area/component';
+import UserAvatarVideo from '/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/component';
+import ViewActions from '/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component';
+import {
+ isStreamStateUnhealthy,
+ subscribeToStreamStateChange,
+ unsubscribeFromStreamStateChange,
+} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
+import Settings from '/imports/ui/services/settings';
+import VideoService from '/imports/ui/components/video-provider/service';
+import Styled from './styles';
+import { withDragAndDrop } from './drag-and-drop/component';
+import Auth from '/imports/ui/services/auth';
+
+const intlMessages = defineMessages({
+ disableDesc: {
+ id: 'app.videoDock.webcamDisableDesc',
+ },
+});
+
+const VIDEO_CONTAINER_WIDTH_BOUND = 125;
+
+const VideoListItem = (props) => {
+ const {
+ name, voiceUser, isFullscreenContext, layoutContextDispatch, user, onHandleVideoFocus,
+ cameraId, numOfStreams, focused, onVideoItemMount, onVideoItemUnmount,
+ makeDragOperations, dragging, draggingOver, isRTL, isStream, settingsSelfViewDisable,
+ disabledCams,
+ } = props;
+
+ const intl = useIntl();
+
+ const [videoDataLoaded, setVideoDataLoaded] = useState(false);
+ const [isStreamHealthy, setIsStreamHealthy] = useState(false);
+ const [isMirrored, setIsMirrored] = useState(VideoService.mirrorOwnWebcam(user?.userId));
+ const [isVideoSqueezed, setIsVideoSqueezed] = useState(false);
+ const [isSelfViewDisabled, setIsSelfViewDisabled] = useState(false);
+
+ const resizeObserver = new ResizeObserver((entry) => {
+ if (entry && entry[0]?.contentRect?.width < VIDEO_CONTAINER_WIDTH_BOUND) {
+ return setIsVideoSqueezed(true);
+ }
+ return setIsVideoSqueezed(false);
+ });
+
+ const videoTag = useRef();
+ const videoContainer = useRef();
+
+ const videoIsReady = isStreamHealthy && videoDataLoaded && !isSelfViewDisabled;
+ const { animations } = Settings.application;
+ const talking = voiceUser?.talking;
+
+ const onStreamStateChange = (e) => {
+ const { streamState } = e.detail;
+ const newHealthState = !isStreamStateUnhealthy(streamState);
+ e.stopPropagation();
+ setIsStreamHealthy(newHealthState);
+ };
+
+ const onLoadedData = () => {
+ setVideoDataLoaded(true);
+ window.dispatchEvent(new Event('resize'));
+
+ /* used when re-sharing cameras after leaving a breakout room.
+ it is needed in cases where the user has more than one active camera
+ so we only share the second camera after the first
+ has finished loading (can't share more than one at the same time) */
+ Session.set('canConnect', true);
+ };
+
+ // component did mount
+ useEffect(() => {
+ subscribeToStreamStateChange(cameraId, onStreamStateChange);
+ onVideoItemMount(videoTag.current);
+ resizeObserver.observe(videoContainer.current);
+ videoTag?.current?.addEventListener('loadeddata', onLoadedData);
+
+ return () => {
+ videoTag?.current?.removeEventListener('loadeddata', onLoadedData);
+ resizeObserver.disconnect();
+ };
+ }, []);
+
+ // component will mount
+ useEffect(() => {
+ const playElement = (elem) => {
+ if (elem.paused) {
+ elem.play().catch((error) => {
+ // NotAllowedError equals autoplay issues, fire autoplay handling event
+ if (error.name === 'NotAllowedError') {
+ const tagFailedEvent = new CustomEvent('videoPlayFailed', { detail: { mediaElement: elem } });
+ window.dispatchEvent(tagFailedEvent);
+ }
+ });
+ }
+ };
+ if (!isSelfViewDisabled && videoDataLoaded) {
+ playElement(videoTag.current);
+ }
+ if ((isSelfViewDisabled && user.userId === Auth.userID) || disabledCams?.includes(cameraId)) {
+ videoTag.current.pause();
+ }
+ }, [isSelfViewDisabled, videoDataLoaded]);
+
+ // component will unmount
+ useEffect(() => () => {
+ unsubscribeFromStreamStateChange(cameraId, onStreamStateChange);
+ onVideoItemUnmount(cameraId);
+ }, []);
+
+ useEffect(() => {
+ setIsSelfViewDisabled(settingsSelfViewDisable);
+ }, [settingsSelfViewDisable]);
+
+ const renderSqueezedButton = () => (
+ setIsMirrored((value) => !value)}
+ isRTL={isRTL}
+ isStream={isStream}
+ onHandleDisableCam={() => setIsSelfViewDisabled((value) => !value)}
+ isSelfViewDisabled={isSelfViewDisabled}
+ />
+ );
+
+ const renderWebcamConnecting = () => (
+
+
+
+ setIsMirrored((value) => !value)}
+ isRTL={isRTL}
+ isStream={isStream}
+ onHandleDisableCam={() => setIsSelfViewDisabled((value) => !value)}
+ isSelfViewDisabled={isSelfViewDisabled}
+ />
+
+
+
+ );
+
+ const renderWebcamConnectingSqueezed = () => (
+
+
+ {renderSqueezedButton()}
+
+ );
+
+ const renderDefaultButtons = () => (
+ <>
+
+
+
+
+
+ setIsMirrored((value) => !value)}
+ isRTL={isRTL}
+ isStream={isStream}
+ onHandleDisableCam={() => setIsSelfViewDisabled((value) => !value)}
+ isSelfViewDisabled={isSelfViewDisabled}
+ />
+
+
+ >
+ );
+
+ return (
+
+
+
+
+
+
+ {isStream && ((isSelfViewDisabled && user.userId === Auth.userID)
+ || disabledCams.includes(cameraId)) && (
+
+ {intl.formatMessage(intlMessages.disableDesc)}
+
+ )}
+
+ {/* eslint-disable-next-line no-nested-ternary */}
+
+ {(videoIsReady || (isSelfViewDisabled || disabledCams.includes(cameraId))) && (
+ isVideoSqueezed ? renderSqueezedButton() : renderDefaultButtons()
+ )}
+ {!videoIsReady && (!isSelfViewDisabled || !isStream) && (
+ isVideoSqueezed ? renderWebcamConnectingSqueezed() : renderWebcamConnecting()
+ )}
+ {((isSelfViewDisabled && user.userId === Auth.userID) || disabledCams.includes(cameraId))
+ && renderWebcamConnecting()}
+
+ );
+};
+
+export default withDragAndDrop(injectIntl(VideoListItem));
+
+VideoListItem.defaultProps = {
+ numOfStreams: 0,
+ onVideoItemMount: () => { },
+ onVideoItemUnmount: () => { },
+ onVirtualBgDrop: () => { },
+};
+
+VideoListItem.propTypes = {
+ cameraId: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ numOfStreams: PropTypes.number,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ onHandleVideoFocus: PropTypes.func.isRequired,
+ onVideoItemMount: PropTypes.func,
+ onVideoItemUnmount: PropTypes.func,
+ onVirtualBgDrop: PropTypes.func,
+ isFullscreenContext: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ user: PropTypes.shape({
+ pin: PropTypes.bool.isRequired,
+ userId: PropTypes.string.isRequired,
+ }).isRequired,
+ voiceUser: PropTypes.shape({
+ muted: PropTypes.bool.isRequired,
+ listenOnly: PropTypes.bool.isRequired,
+ talking: PropTypes.bool.isRequired,
+ joined: PropTypes.bool.isRequired,
+ }).isRequired,
+ focused: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/container.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/container.jsx
new file mode 100644
index 00000000..f53dcfb6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/container.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import VoiceUsers from '/imports/api/voice-users/';
+import Users from '/imports/api/users/';
+import VideoListItem from './component';
+import { layoutSelect, layoutDispatch } from '/imports/ui/components/layout/context';
+import Settings from '/imports/ui/services/settings';
+
+const VideoListItemContainer = (props) => {
+ const { cameraId, user } = props;
+
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const { element } = fullscreen;
+ const isFullscreenContext = (element === cameraId);
+ const layoutContextDispatch = layoutDispatch();
+ const isRTL = layoutSelect((i) => i.isRTL);
+
+ if (!user) return null;
+
+ return (
+
+ );
+};
+
+export default withTracker((props) => {
+ const {
+ userId,
+ } = props;
+
+ return {
+ settingsSelfViewDisable: Settings.application.selfViewDisable,
+ voiceUser: VoiceUsers.findOne({ intId: userId },
+ {
+ fields: {
+ muted: 1, listenOnly: 1, talking: 1, joined: 1,
+ },
+ }),
+ user: Users.findOne({ intId: userId }, {
+ fields: {
+ pin: 1,
+ userId: 1,
+ name: 1,
+ avatar: 1,
+ role: 1,
+ color: 1,
+ emoji: 1,
+ presenter: 1,
+ clientType: 1,
+ },
+ }),
+ disabledCams: Session.get('disabledCams') || [],
+ };
+})(VideoListItemContainer);
+
+VideoListItemContainer.propTypes = {
+ cameraId: PropTypes.string.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/drag-and-drop/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/drag-and-drop/component.jsx
new file mode 100644
index 00000000..2d10241c
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/drag-and-drop/component.jsx
@@ -0,0 +1,173 @@
+import React, { useContext, useEffect, useState, useCallback } from 'react';
+import { injectIntl, defineMessages } from 'react-intl';
+import Auth from '/imports/ui/services/auth';
+import ConfirmationModal from '/imports/ui/components/common/modal/confirmation/component';
+import { CustomVirtualBackgroundsContext } from '/imports/ui/components/video-preview/virtual-background/context';
+import { EFFECT_TYPES } from '/imports/ui/services/virtual-background/service';
+import VirtualBgService from '/imports/ui/components/video-preview/virtual-background/service';
+import logger from '/imports/startup/client/logger';
+import withFileReader from '/imports/ui/components/common/file-reader/component';
+
+const { MIME_TYPES_ALLOWED, MAX_FILE_SIZE } = VirtualBgService;
+
+const intlMessages = defineMessages({
+ confirmationTitle: {
+ id: 'app.confirmation.virtualBackground.title',
+ description: 'Confirmation modal title',
+ },
+ confirmationDescription: {
+ id: 'app.confirmation.virtualBackground.description',
+ description: 'Confirmation modal description',
+ },
+});
+
+const ENABLE_WEBCAM_BACKGROUND_UPLOAD = Meteor.settings.public.virtualBackgrounds.enableVirtualBackgroundUpload;
+
+const DragAndDrop = (props) => {
+ const { children, intl, readFile, onVirtualBgDrop: onAction, isStream } = props;
+
+ const [dragging, setDragging] = useState(false);
+ const [draggingOver, setDraggingOver] = useState(false);
+ const [isConfirmModalOpen, setConfirmModalIsOpen] = useState(false);
+ const [file, setFile] = useState(false);
+ const { dispatch: dispatchCustomBackground } = useContext(CustomVirtualBackgroundsContext);
+
+ let callback;
+ const resetEvent = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ useEffect(() => {
+ const onDragOver = (e) => {
+ resetEvent(e);
+ setDragging(true);
+ };
+ const onDragLeave = (e) => {
+ resetEvent(e);
+ setDragging(false);
+ };
+ const onDrop = (e) => {
+ resetEvent(e);
+ setDragging(false);
+ };
+
+ window.addEventListener('dragover', onDragOver);
+ window.addEventListener('dragleave', onDragLeave);
+ window.addEventListener('drop', onDrop);
+
+ return () => {
+ window.removeEventListener('dragover', onDragOver);
+ window.removeEventListener('dragleave', onDragLeave);
+ window.removeEventListener('drop', onDrop);
+ };
+ }, []);
+
+ const handleStartAndSaveVirtualBackground = (file) => {
+ const onSuccess = (background) => {
+ const { filename, data } = background;
+ if (onAction) {
+ onAction(EFFECT_TYPES.IMAGE_TYPE, filename, data).then(() => {
+ dispatchCustomBackground({
+ type: 'new',
+ background: {
+ ...background,
+ custom: true,
+ lastActivityDate: Date.now(),
+ },
+ });
+ });
+ } else dispatchCustomBackground({
+ type: 'new',
+ background: {
+ ...background,
+ custom: true,
+ lastActivityDate: Date.now(),
+ },
+ });
+ };
+
+ const onError = (error) => {
+ logger.warn({
+ logCode: 'read_file_error',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, error.message);
+ };
+
+ readFile(file, onSuccess, onError);
+ };
+
+ callback = (checked) => {
+ handleStartAndSaveVirtualBackground(file);
+ Session.set('skipBackgroundDropConfirmation', checked);
+ };
+
+ const makeDragOperations = useCallback((userId) => {
+ if (!userId || Auth.userID !== userId || !ENABLE_WEBCAM_BACKGROUND_UPLOAD || !isStream) return {};
+
+ const startAndSaveVirtualBackground = (file) => handleStartAndSaveVirtualBackground(file);
+
+ const onDragOverHandler = (e) => {
+ resetEvent(e);
+ setDraggingOver(true);
+ setDragging(false);
+ };
+
+ const onDropHandler = (e) => {
+ resetEvent(e);
+ setDraggingOver(false);
+ setDragging(false);
+
+ const { files } = e.dataTransfer;
+ const file = files[0];
+
+ if (Session.get('skipBackgroundDropConfirmation')) {
+ return startAndSaveVirtualBackground(file);
+ }
+
+ setFile(file);
+ setConfirmModalIsOpen(true);
+ };
+
+ const onDragLeaveHandler = (e) => {
+ resetEvent(e);
+ setDragging(false);
+ setDraggingOver(false);
+ };
+
+ return {
+ onDragOver: onDragOverHandler,
+ onDrop: onDropHandler,
+ onDragLeave: onDragLeaveHandler,
+ };
+ }, [Auth.userID]);
+
+ return <>
+ {React.cloneElement(children, { ...props, dragging, draggingOver, makeDragOperations })}
+ {isConfirmModalOpen ? setConfirmModalIsOpen(false),
+ priority: "low",
+ setIsOpen: setConfirmModalIsOpen,
+ isOpen: isConfirmModalOpen
+ }}
+ /> : null}
+ >;
+};
+
+const Wrapper = (Component) => (props) => (
+
+
+
+);
+
+export const withDragAndDrop = (Component) =>
+ injectIntl(withFileReader(Wrapper(Component), MIME_TYPES_ALLOWED, MAX_FILE_SIZE));
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/component.jsx
new file mode 100644
index 00000000..964391f9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/component.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import VideoService from '/imports/ui/components/video-provider/service';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ unpinLabel: {
+ id: 'app.videoDock.webcamUnpinLabel',
+ },
+ unpinLabelDisabled: {
+ id: 'app.videoDock.webcamUnpinLabelDisabled',
+ },
+});
+
+const PinArea = (props) => {
+ const intl = useIntl();
+
+ const { user } = props;
+ const pinned = user?.pin;
+ const userId = user?.userId;
+ const shouldRenderPinButton = pinned && userId;
+ const videoPinActionAvailable = VideoService.isVideoPinEnabledForCurrentUser();
+
+ if (!shouldRenderPinButton) return ;
+
+ return (
+
+ VideoService.toggleVideoPin(userId, true)}
+ label={videoPinActionAvailable
+ ? intl.formatMessage(intlMessages.unpinLabel)
+ : intl.formatMessage(intlMessages.unpinLabelDisabled)}
+ hideLabel
+ disabled={!videoPinActionAvailable}
+ data-test="pinVideoButton"
+ />
+
+ );
+};
+
+export default PinArea;
+
+PinArea.propTypes = {
+ user: PropTypes.shape({
+ pin: PropTypes.bool.isRequired,
+ userId: PropTypes.string.isRequired,
+ }).isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/styles.js
new file mode 100644
index 00000000..8595979b
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/pin-area/styles.js
@@ -0,0 +1,43 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+import { colorTransparent, colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+
+const PinButton = styled(Button)`
+ padding: 5px;
+ &,
+ &:active,
+ &:hover,
+ &:focus {
+ background-color: ${colorTransparent} !important;
+ border: none !important;
+
+ & > i {
+ border: none !important;
+ color: ${colorWhite};
+ font-size: 1rem;
+ background-color: ${colorTransparent} !important;
+ }
+ }
+`;
+
+const PinButtonWrapper = styled.div`
+ background-color: rgba(0,0,0,.3);
+ cursor: pointer;
+ border: 0;
+ margin: 2px;
+ height: fit-content;
+
+ [dir="rtl"] & {
+ right: auto;
+ left :0;
+ }
+
+ [class*="presentationZoomControls"] & {
+ position: relative !important;
+ }
+`;
+
+export default {
+ PinButtonWrapper,
+ PinButton,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/styles.js
new file mode 100644
index 00000000..22a845e7
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/styles.js
@@ -0,0 +1,192 @@
+import styled, { keyframes, css } from 'styled-components';
+import {
+ colorPrimary,
+ colorBlack,
+ colorWhite,
+ webcamBackgroundColor,
+ colorDanger,
+ webcamPlaceholderBorder,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import { TextElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
+
+const rotate360 = keyframes`
+ from {
+ transform: rotate(360deg);
+ }
+ to {
+ transform: rotate(0deg);
+ }
+`;
+
+const fade = keyframes`
+ from {
+ opacity: 0.7;
+ }
+ to {
+ opacity: 0;
+ }
+`;
+
+const Content = styled.div`
+ position: relative;
+ display: flex;
+ min-width: 100%;
+ border-radius: 10px;
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ pointer-events: none;
+ border: 2px solid ${colorBlack};
+ border-radius: 10px;
+
+ ${({ isStream }) => !isStream && `
+ border: 2px solid ${webcamPlaceholderBorder};
+ `}
+
+ ${({ talking }) => talking && `
+ border: 2px solid ${colorPrimary};
+ `}
+
+ ${({ animations }) => animations && `
+ transition: opacity .1s;
+ `}
+ }
+
+ ${({ dragging, animations }) => dragging && animations && css`
+ &::after {
+ animation: ${fade} .5s linear infinite;
+ animation-direction: alternate;
+ }
+ `}
+
+ ${({ dragging, draggingOver }) => (dragging || draggingOver) && `
+ &::after {
+ opacity: 0.7;
+ border-style: dashed;
+ border-color: ${colorDanger};
+ transition: opacity 0s;
+ }
+ `}
+
+ ${({ fullscreen }) => fullscreen && `
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 99;
+ `}
+`;
+
+const WebcamConnecting = styled.div`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+ min-width: 100%;
+ border-radius: 10px;
+ background-color: ${webcamBackgroundColor};
+ z-index: 0;
+
+ &::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ opacity: 0;
+ pointer-events: none;
+
+ ${({ animations }) => animations && `
+ transition: opacity .1s;
+ `}
+ }
+`;
+
+const LoadingText = styled(TextElipsis)`
+ color: ${colorWhite};
+ font-size: 100%;
+`;
+
+const VideoContainer = styled.div`
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+
+ ${({ $selfViewDisabled }) => $selfViewDisabled && 'display: none'}
+`;
+
+const Video = styled.video`
+ position: relative;
+ height: 100%;
+ width: calc(100% - 1px);
+ object-fit: contain;
+ background-color: ${colorBlack};
+ border-radius: 10px;
+
+ ${({ mirrored }) => mirrored && `
+ transform: scale(-1, 1);
+ `}
+
+ ${({ unhealthyStream }) => unhealthyStream && `
+ filter: grayscale(50%) opacity(50%);
+ `}
+`;
+
+const VideoDisabled = styled.div`
+color: white;
+ width: 100%;
+ height: 20%;
+ background-color: rgba(0, 0, 0, 0.7);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ border-radius: 10px;
+ z-index: 2;
+ top: 40%;
+ transform: translate(-50%, -50%);
+ top: 50%;
+ left: 50%;
+ padding: 20px;
+ backdrop-filter: blur(10px);
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
+}`;
+
+const TopBar = styled.div`
+ position: absolute;
+ display: flex;
+ width: 100%;
+ z-index: 1;
+ top: 0;
+ padding: 5px;
+ justify-content: space-between;
+`;
+
+const BottomBar = styled.div`
+ position: absolute;
+ display: flex;
+ width: 100%;
+ z-index: 1;
+ bottom: 0;
+ padding: 1px 7px;
+ justify-content: space-between;
+`;
+
+export default {
+ Content,
+ WebcamConnecting,
+ LoadingText,
+ VideoContainer,
+ Video,
+ TopBar,
+ BottomBar,
+ VideoDisabled,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/component.jsx
new file mode 100644
index 00000000..e161dfe5
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/component.jsx
@@ -0,0 +1,249 @@
+import React from 'react';
+import { defineMessages, useIntl } from 'react-intl';
+import browserInfo from '/imports/utils/browserInfo';
+import VideoService from '/imports/ui/components/video-provider/service';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import BBBMenu from '/imports/ui/components/common/menu/component';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import Auth from '/imports/ui/services/auth';
+import { notify } from '/imports/ui/services/notification';
+
+const intlMessages = defineMessages({
+ focusLabel: {
+ id: 'app.videoDock.webcamFocusLabel',
+ },
+ focusDesc: {
+ id: 'app.videoDock.webcamFocusDesc',
+ },
+ unfocusLabel: {
+ id: 'app.videoDock.webcamUnfocusLabel',
+ },
+ unfocusDesc: {
+ id: 'app.videoDock.webcamUnfocusDesc',
+ },
+ pinLabel: {
+ id: 'app.videoDock.webcamPinLabel',
+ },
+ unpinLabel: {
+ id: 'app.videoDock.webcamUnpinLabel',
+ },
+ disableLabel: {
+ id: 'app.videoDock.webcamDisableLabel',
+ },
+ enableLabel: {
+ id: 'app.videoDock.webcamEnableLabel',
+ },
+ pinDesc: {
+ id: 'app.videoDock.webcamPinDesc',
+ },
+ unpinDesc: {
+ id: 'app.videoDock.webcamUnpinDesc',
+ },
+ mirrorLabel: {
+ id: 'app.videoDock.webcamMirrorLabel',
+ },
+ mirrorDesc: {
+ id: 'app.videoDock.webcamMirrorDesc',
+ },
+ fullscreenLabel: {
+ id: 'app.videoDock.webcamFullscreenLabel',
+ description: 'Make fullscreen option label',
+ },
+ squeezedLabel: {
+ id: 'app.videoDock.webcamSqueezedButtonLabel',
+ description: 'User selected webcam squeezed options',
+ },
+ disableDesc: {
+ id: 'app.videoDock.webcamDisableDesc',
+ },
+ disableWarning: {
+ id: 'app.videoDock.webcamDisableWarning',
+ },
+});
+
+const UserActions = (props) => {
+ const {
+ name, cameraId, numOfStreams, onHandleVideoFocus, user, focused, onHandleMirror,
+ isVideoSqueezed, videoContainer, isRTL, isStream, isSelfViewDisabled,
+ } = props;
+
+ const intl = useIntl();
+ const enableVideoMenu = Meteor.settings.public.kurento.enableVideoMenu || false;
+ const { isFirefox } = browserInfo;
+
+ const getAvailableActions = () => {
+ const pinned = user?.pin;
+ const userId = user?.userId;
+ const isPinnedIntlKey = !pinned ? 'pin' : 'unpin';
+ const isFocusedIntlKey = !focused ? 'focus' : 'unfocus';
+ const disabledCams = Session.get('disabledCams') || [];
+ const isCameraDisabled = disabledCams && disabledCams?.includes(cameraId);
+ const enableSelfCamIntlKey = !isCameraDisabled ? 'disable' : 'enable';
+
+ const menuItems = [];
+
+ const toggleDisableCam = () => {
+ if (!isCameraDisabled) {
+ Session.set('disabledCams', [...disabledCams, cameraId]);
+ notify(intl.formatMessage(intlMessages.disableWarning), 'info', 'warning');
+ } else {
+ Session.set('disabledCams', disabledCams.filter((cId) => cId !== cameraId));
+ }
+ };
+
+ if (isVideoSqueezed) {
+ menuItems.push({
+ key: `${cameraId}-name`,
+ label: name,
+ description: name,
+ onClick: () => { },
+ disabled: true,
+ });
+
+ if (isStream) {
+ menuItems.push(
+ {
+ key: `${cameraId}-fullscreen`,
+ label: intl.formatMessage(intlMessages.fullscreenLabel),
+ description: intl.formatMessage(intlMessages.fullscreenLabel),
+ onClick: () => FullscreenService.toggleFullScreen(videoContainer.current),
+ },
+ );
+ }
+ }
+ if (userId === Auth.userID && isStream && !isSelfViewDisabled) {
+ menuItems.push({
+ key: `${cameraId}-disable`,
+ label: intl.formatMessage(intlMessages[`${enableSelfCamIntlKey}Label`]),
+ description: intl.formatMessage(intlMessages[`${enableSelfCamIntlKey}Label`]),
+ onClick: () => toggleDisableCam(cameraId),
+ dataTest: 'selfViewDisableBtn',
+ });
+ }
+
+ if (isStream) {
+ menuItems.push({
+ key: `${cameraId}-mirror`,
+ label: intl.formatMessage(intlMessages.mirrorLabel),
+ description: intl.formatMessage(intlMessages.mirrorDesc),
+ onClick: () => onHandleMirror(cameraId),
+ dataTest: 'mirrorWebcamBtn',
+ });
+ }
+
+ if (numOfStreams > 2 && isStream) {
+ menuItems.push({
+ key: `${cameraId}-focus`,
+ label: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Label`]),
+ description: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Desc`]),
+ onClick: () => onHandleVideoFocus(cameraId),
+ dataTest: 'FocusWebcamBtn',
+ });
+ }
+
+ if (VideoService.isVideoPinEnabledForCurrentUser() && isStream) {
+ menuItems.push({
+ key: `${cameraId}-pin`,
+ label: intl.formatMessage(intlMessages[`${isPinnedIntlKey}Label`]),
+ description: intl.formatMessage(intlMessages[`${isPinnedIntlKey}Desc`]),
+ onClick: () => VideoService.toggleVideoPin(userId, pinned),
+ dataTest: 'pinWebcamBtn',
+ });
+ }
+
+ return menuItems;
+ };
+
+ const renderSqueezedButton = () => (
+
+ null}
+ />
+ )}
+ actions={getAvailableActions()}
+ />
+
+ );
+
+ const renderDefaultButton = () => (
+
+ {enableVideoMenu && getAvailableActions().length >= 1
+ ? (
+
+ {name}
+
+ )}
+ actions={getAvailableActions()}
+ opts={{
+ id: `webcam-${user?.userId}-dropdown-menu`,
+ keepMounted: true,
+ transitionDuration: 0,
+ elevation: 3,
+ getcontentanchorel: null,
+ fullwidth: 'true',
+ anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
+ transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
+ }}
+ />
+ )
+ : (
+
+
+ {name}
+
+
+ )}
+
+ );
+
+ return (
+ isVideoSqueezed
+ ? renderSqueezedButton()
+ : renderDefaultButton()
+ );
+};
+
+export default UserActions;
+
+UserActions.defaultProps = {
+ focused: false,
+ isVideoSqueezed: false,
+ videoContainer: () => { },
+ onHandleVideoFocus: () => {},
+};
+
+UserActions.propTypes = {
+ name: PropTypes.string.isRequired,
+ cameraId: PropTypes.string.isRequired,
+ numOfStreams: PropTypes.number.isRequired,
+ onHandleVideoFocus: PropTypes.func,
+ user: PropTypes.shape({
+ pin: PropTypes.bool.isRequired,
+ userId: PropTypes.string.isRequired,
+ }).isRequired,
+ focused: PropTypes.bool,
+ isVideoSqueezed: PropTypes.bool,
+ videoContainer: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+ ]),
+ onHandleMirror: PropTypes.func.isRequired,
+ onHandleDisableCam: PropTypes.func.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/styles.js
new file mode 100644
index 00000000..3231d8c6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-actions/styles.js
@@ -0,0 +1,123 @@
+import styled from 'styled-components';
+import { colorOffWhite } from '/imports/ui/stylesheets/styled-components/palette';
+import { TextElipsis, DivElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
+import { landscape, mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import { fontSizeSmaller } from '/imports/ui/stylesheets/styled-components/typography';
+import Button from '/imports/ui/components/common/button/component';
+
+const DropdownTrigger = styled(DivElipsis)`
+ position: relative;
+ // Keep the background with 0.5 opacity, but leave the text with 1
+ background-color: rgba(0, 0, 0, 0.5);
+ border-radius: 10px;
+ color: ${colorOffWhite};
+ padding: 0 1rem 0 .5rem !important;
+ font-size: 80%;
+ cursor: pointer;
+ white-space: nowrap;
+ width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &::after {
+ content: "\\203a";
+ position: absolute;
+ transform: rotate(90deg);
+ ${({ isRTL }) => isRTL && `
+ transform: rotate(-90deg);
+ `}
+ top: 45%;
+ width: 0;
+ line-height: 0;
+ right: .45rem;
+ }
+`;
+
+const UserName = styled(TextElipsis)`
+ position: relative;
+ // Keep the background with 0.5 opacity, but leave the text with 1
+ color: ${colorOffWhite};
+ padding: 0 1rem 0 .5rem !important;
+ font-size: 80%;
+
+ ${({ noMenu }) => noMenu && `
+ padding: 0 .5rem 0 .5rem !important;
+ `}
+`;
+
+const Dropdown = styled.div`
+ display: flex;
+ outline: none !important;
+ background-color: rgba(0, 0, 0, 0.5);
+ border-radius: 10px;
+ display: inline-block;
+
+ @media ${mediumUp} {
+ >[aria-expanded] {
+ padding: .25rem;
+ }
+ }
+
+ @media ${landscape} {
+ button {
+ width: calc(100vw - 4rem);
+ margin-left: 1rem;
+ }
+ }
+
+ ${({ isFirefox }) => isFirefox && `
+ max-width: 100%;
+ `}
+`;
+
+const MenuWrapper = styled.div`
+ max-width: 75%;
+`;
+
+const MenuWrapperSqueezed = styled.div`
+ position: absolute;
+ right: 0;
+ top: 0;
+`;
+
+const OptionsButton = styled(Button)`
+ position: absolute;
+ right: 7px;
+ top: 7px;
+ z-index: 2;
+ background-color: rgba(0,0,0,0.4);
+ color: ${colorOffWhite};
+ overflow: hidden;
+ border: none !important;
+ padding: 3px;
+
+ i {
+ width: auto;
+ font-size: ${fontSizeSmaller} !important;
+ background-color: transparent !important;
+ }
+
+ &,
+ &:active,
+ &:focus,
+ &:hover {
+ background-color: rgba(0,0,0,0.5) !important;
+ border: none !important;
+ color: white !important;
+ opacity: 100% !important;
+ }
+
+ &:hover {
+ transform: scale(1.3);
+ transition-duration: 150ms;
+ }
+`;
+
+export default {
+ DropdownTrigger,
+ UserName,
+ Dropdown,
+ MenuWrapper,
+ MenuWrapperSqueezed,
+ OptionsButton,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/component.jsx
new file mode 100644
index 00000000..7590d681
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/component.jsx
@@ -0,0 +1,63 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import Icon from '/imports/ui/components/common/icon/component';
+import UserListService from '/imports/ui/components/user-list/service';
+
+const UserAvatarVideo = (props) => {
+ const { user, unhealthyStream, squeezed, voiceUser } = props;
+ const {
+ name, color, avatar, role, emoji,
+ } = user;
+ let {
+ presenter, clientType,
+ } = user;
+
+ const talking = voiceUser?.talking || false;
+
+ const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+ const handleUserIcon = () => {
+ if (emoji !== 'none') {
+ return ;
+ }
+ return name.toLowerCase().slice(0, 2);
+ };
+
+ // hide icons when squeezed
+ if (squeezed) {
+ presenter = false;
+ clientType = false;
+ }
+
+ return (
+
+ {handleUserIcon()}
+
+ );
+};
+
+export default UserAvatarVideo;
+
+UserAvatarVideo.propTypes = {
+ user: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+ avatar: PropTypes.string.isRequired,
+ role: PropTypes.string.isRequired,
+ emoji: PropTypes.string.isRequired,
+ presenter: PropTypes.bool.isRequired,
+ clientType: PropTypes.string.isRequired,
+ }).isRequired,
+ unhealthyStream: PropTypes.bool.isRequired,
+ squeezed: PropTypes.bool.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/styles.js
new file mode 100644
index 00000000..9386fe05
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-avatar/styles.js
@@ -0,0 +1,52 @@
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+import {
+ userIndicatorsOffset,
+ mdPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ colorPrimary,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import styled from 'styled-components';
+
+const UserAvatarStyled = styled(UserAvatar)`
+ height: 60%;
+ width: 45%;
+ max-width: 66px;
+ max-height: 66px;
+ scale: 1.5;
+
+ ${({ unhealthyStream }) => unhealthyStream && `
+ filter: grayscale(50%) opacity(50%);
+ `}
+
+ ${({ dialIn }) => dialIn && `
+ &:before {
+ content: "\\00a0\\e91a\\00a0";
+ padding: ${mdPaddingY};
+ opacity: 1;
+ top: ${userIndicatorsOffset};
+ right: ${userIndicatorsOffset};
+ bottom: auto;
+ left: auto;
+ border-radius: 50%;
+ background-color: ${colorPrimary};
+ padding: 0.7rem !important;
+
+ [dir="rtl"] & {
+ left: auto;
+ right: ${userIndicatorsOffset};
+ letter-spacing: -.33rem;
+ }
+ }
+ `}
+
+ ${({ presenter }) => presenter && `
+ &:before {
+ padding: 0.7rem !important;
+ }
+ `};
+`;
+
+export default {
+ UserAvatarStyled,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/component.jsx
new file mode 100644
index 00000000..b6e887b1
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/component.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+
+const UserStatus = (props) => {
+ const { voiceUser } = props;
+
+ const listenOnly = voiceUser?.listenOnly;
+ const muted = voiceUser?.muted;
+ const voiceUserJoined = voiceUser?.joined;
+
+ return (
+ <>
+ {(muted && !listenOnly) && }
+ {listenOnly && }
+ {(voiceUserJoined && !muted) && }
+ >
+ );
+};
+
+export default UserStatus;
+
+UserStatus.propTypes = {
+ voiceUser: PropTypes.shape({
+ listenOnly: PropTypes.bool.isRequired,
+ muted: PropTypes.bool.isRequired,
+ joined: PropTypes.bool.isRequired,
+ }).isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/styles.js
new file mode 100644
index 00000000..41927137
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/user-status/styles.js
@@ -0,0 +1,32 @@
+import styled from 'styled-components';
+import Icon from '/imports/ui/components/common/icon/component';
+import { colorDanger, colorSuccess, colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+
+const Voice = styled(Icon)`
+ height: 80%;
+ color: ${colorWhite};
+ border-radius: 50%;
+
+ &::before {
+ font-size: 80%;
+ }
+
+ background-color: ${colorSuccess};
+`;
+
+const Muted = styled(Icon)`
+ height: 80%;
+ color: ${colorWhite};
+ border-radius: 50%;
+
+ &::before {
+ font-size: 80%;
+ }
+
+ background-color: ${colorDanger};
+`;
+
+export default {
+ Voice,
+ Muted,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component.jsx b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component.jsx
new file mode 100644
index 00000000..ff4038a0
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/component.jsx
@@ -0,0 +1,55 @@
+import React, { useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { ACTIONS } from '/imports/ui/components/layout/enums';
+import FullscreenButtonContainer from '/imports/ui/components/common/fullscreen-button/container';
+import Styled from './styles';
+
+const ViewActions = (props) => {
+ const {
+ name, cameraId, videoContainer, isFullscreenContext, layoutContextDispatch, isStream,
+ } = props;
+
+ const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
+
+ useEffect(() => () => {
+ // exit fullscreen when component is unmounted
+ if (isFullscreenContext) {
+ layoutContextDispatch({
+ type: ACTIONS.SET_FULLSCREEN_ELEMENT,
+ value: {
+ element: '',
+ group: '',
+ },
+ });
+ }
+ }, []);
+
+ if (!ALLOW_FULLSCREEN || !isStream) return null;
+
+ return (
+
+
+
+ );
+};
+
+export default ViewActions;
+
+ViewActions.propTypes = {
+ name: PropTypes.string.isRequired,
+ cameraId: PropTypes.string.isRequired,
+ videoContainer: PropTypes.oneOfType([
+ PropTypes.func,
+ PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
+ ]).isRequired,
+ isFullscreenContext: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/styles.js b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/styles.js
new file mode 100644
index 00000000..8d339002
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/video-provider/video-list/video-list-item/view-actions/styles.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+const FullscreenWrapper = styled.div`
+ position: relative;
+`;
+
+export default {
+ FullscreenWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/waiting-users/alert/service.js b/src/2.7.12/imports/ui/components/waiting-users/alert/service.js
new file mode 100644
index 00000000..8c46fb5d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/alert/service.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import { throttle } from '/imports/utils/throttle';
+import { notify } from '/imports/ui/services/notification';
+import Settings from '/imports/ui/services/settings';
+import Styled from './styles';
+
+const CDN = Meteor.settings.public.app.cdn;
+const BASENAME = Meteor.settings.public.app.basename;
+const HOST = CDN + BASENAME;
+const GUEST_WAITING_BELL_THROTTLE_TIME = 10000;
+
+function ringGuestWaitingBell() {
+ if (Settings.application.guestWaitingAudioAlerts) {
+ const audio = new Audio(`${HOST}/resources/sounds/doorbell.mp3`);
+ audio.play();
+ }
+}
+
+const ringGuestWaitingBellThrottled = throttle(
+ ringGuestWaitingBell,
+ GUEST_WAITING_BELL_THROTTLE_TIME,
+ { leading: true, trailing: false },
+);
+
+function messageElement(text, type) {
+ if (type === 'title') {
+ return { text } ;
+ }
+ if (type === 'content') {
+ return { text } ;
+ }
+ return false;
+}
+
+function alert(obj, intl) {
+ if (Settings.application.guestWaitingPushAlerts) {
+ notify(
+ messageElement(obj.messageValues[0], 'title'),
+ obj.notificationType,
+ obj.icon,
+ null,
+ messageElement(
+ intl.formatMessage({
+ id: obj.messageId,
+ description: obj.messageDescription,
+ }),
+ 'content',
+ ),
+ true,
+ );
+ }
+
+ ringGuestWaitingBellThrottled();
+}
+
+export default {
+ alert,
+};
diff --git a/src/2.7.12/imports/ui/components/waiting-users/alert/styles.js b/src/2.7.12/imports/ui/components/waiting-users/alert/styles.js
new file mode 100644
index 00000000..7c22b78e
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/alert/styles.js
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+
+const TitleMessage = styled.div`
+ font-weight: bold;
+ font-size: ${fontSizeBase};
+`;
+
+const ContentMessage = styled.div`
+ margin-top: 1rem;
+`;
+
+export default {
+ TitleMessage,
+ ContentMessage,
+};
diff --git a/src/2.7.12/imports/ui/components/waiting-users/component.jsx b/src/2.7.12/imports/ui/components/waiting-users/component.jsx
new file mode 100644
index 00000000..7f4f5613
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/component.jsx
@@ -0,0 +1,408 @@
+import React, { useEffect, useState } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
+import UserAvatar from '/imports/ui/components/user-avatar/component';
+import TextInput from '/imports/ui/components/text-input/component';
+import Styled from './styles';
+import { PANELS, ACTIONS } from '../layout/enums';
+import Settings from '/imports/ui/services/settings';
+import browserInfo from '/imports/utils/browserInfo';
+import Header from '/imports/ui/components/common/control-header/component';
+import { notify } from '/imports/ui/services/notification';
+
+const intlMessages = defineMessages({
+ waitingUsersTitle: {
+ id: 'app.userList.guest.waitingUsersTitle',
+ description: 'Title for the notes list',
+ },
+ title: {
+ id: 'app.userList.guest.waitingUsers',
+ description: 'Label for the waiting users',
+ },
+ optionTitle: {
+ id: 'app.userList.guest.optionTitle',
+ description: 'Label above the options',
+ },
+ allowAllAuthenticated: {
+ id: 'app.userList.guest.allowAllAuthenticated',
+ description: 'Title for the waiting users',
+ },
+ allowAllGuests: {
+ id: 'app.userList.guest.allowAllGuests',
+ description: 'Title for the waiting users',
+ },
+ allowEveryone: {
+ id: 'app.userList.guest.allowEveryone',
+ description: 'Title for the waiting users',
+ },
+ denyEveryone: {
+ id: 'app.userList.guest.denyEveryone',
+ description: 'Title for the waiting users',
+ },
+ pendingUsers: {
+ id: 'app.userList.guest.pendingUsers',
+ description: 'Title for the waiting users',
+ },
+ pendingGuestUsers: {
+ id: 'app.userList.guest.pendingGuestUsers',
+ description: 'Title for the waiting users',
+ },
+ noPendingUsers: {
+ id: 'app.userList.guest.noPendingUsers',
+ description: 'Label for no users waiting',
+ },
+ rememberChoice: {
+ id: 'app.userList.guest.rememberChoice',
+ description: 'Remember label for checkbox',
+ },
+ emptyMessage: {
+ id: 'app.userList.guest.emptyMessage',
+ description: 'Empty guest lobby message label',
+ },
+ inputPlaceholder: {
+ id: 'app.userList.guest.inputPlaceholder',
+ description: 'Placeholder to guest lobby message input',
+ },
+ privateMessageLabel: {
+ id: 'app.userList.guest.privateMessageLabel',
+ description: 'Private message button label',
+ },
+ privateInputPlaceholder: {
+ id: 'app.userList.guest.privateInputPlaceholder',
+ description: 'Private input placeholder',
+ },
+ accept: {
+ id: 'app.userList.guest.acceptLabel',
+ description: 'Accept guest button label',
+ },
+ deny: {
+ id: 'app.userList.guest.denyLabel',
+ description: 'Deny guest button label',
+ },
+ feedbackMessage: {
+ id: 'app.userList.guest.feedbackMessage',
+ description: 'Feedback message moderator action',
+ },
+});
+
+const ALLOW_STATUS = 'ALLOW';
+const DENY_STATUS = 'DENY';
+const { animations } = Settings.application;
+
+const getNameInitials = (name) => {
+ const nameInitials = name.slice(0, 2);
+
+ return nameInitials.replace(/^\w/, (c) => c.toUpperCase());
+};
+
+const renderGuestUserItem = (
+ name, color, handleAccept, handleDeny, role, sequence, userId, avatar, intl,
+ privateMessageVisible, setPrivateGuestLobbyMessage, privateGuestLobbyMessage, isGuestLobbyMessageEnabled,
+) => (
+
+
+
+
+
+ {getNameInitials(name)}
+
+
+
+ {`[${sequence}] ${name}`}
+
+
+
+
+
+ { isGuestLobbyMessageEnabled ? (
+
+ ) : null}
+
+
+
+ { isGuestLobbyMessageEnabled ? (
+
+
+
+
+ "
+ {privateGuestLobbyMessage.length > 0
+ ? privateGuestLobbyMessage
+ : intl.formatMessage(intlMessages.emptyMessage)}
+ "
+
+
+
+ ) : null}
+
+);
+
+const renderNoUserWaitingItem = (message) => (
+
+
+ {message}
+
+
+);
+
+const renderPendingUsers = (message, usersArray, action, intl,
+ privateMessageVisible, setPrivateGuestLobbyMessage,
+ privateGuestLobbyMessage, isGuestLobbyMessageEnabled
+) => {
+ if (!usersArray.length) return null;
+ return (
+
+ {message}
+
+
+ {usersArray.map((user, idx) => renderGuestUserItem(
+ user.name,
+ user.color,
+ () => action([user], ALLOW_STATUS),
+ () => action([user], DENY_STATUS),
+ user.role,
+ idx + 1,
+ user.intId,
+ user.avatar,
+ intl,
+ () => privateMessageVisible(`privateMessage-${user.intId}`),
+ (message) => setPrivateGuestLobbyMessage(message, user.intId),
+ privateGuestLobbyMessage(user.intId),
+ isGuestLobbyMessageEnabled,
+ ))}
+
+
+
+ );
+};
+
+const WaitingUsers = (props) => {
+ const [rememberChoice, setRememberChoice] = useState(false);
+
+ const {
+ intl,
+ authenticatedUsers,
+ privateMessageVisible,
+ guestUsers,
+ guestUsersCall,
+ changeGuestPolicy,
+ isGuestLobbyMessageEnabled,
+ setGuestLobbyMessage,
+ guestLobbyMessage,
+ setPrivateGuestLobbyMessage,
+ privateGuestLobbyMessage,
+ authenticatedGuest,
+ guestPolicyExtraAllowOptions,
+ layoutContextDispatch,
+ allowRememberChoice,
+ } = props;
+
+ const existPendingUsers = authenticatedUsers.length > 0 || guestUsers.length > 0;
+
+ const closePanel = () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
+ value: false,
+ });
+ layoutContextDispatch({
+ type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
+ value: PANELS.NONE,
+ });
+ };
+
+ useEffect(() => {
+ const {
+ isWaitingRoomEnabled,
+ } = props;
+ if (!isWaitingRoomEnabled && !existPendingUsers) {
+ closePanel();
+ }
+ });
+
+ const onCheckBoxChange = (e) => {
+ const { checked } = e.target;
+ setRememberChoice(checked);
+ };
+
+ const changePolicy = (shouldExecutePolicy, policyRule, cb, message) => () => {
+ if (shouldExecutePolicy) {
+ changeGuestPolicy(policyRule);
+ }
+
+ closePanel();
+
+ notify(intl.formatMessage(intlMessages.feedbackMessage) + message.toUpperCase(), 'success');
+
+ return cb();
+ };
+
+ const renderButton = (message, { key, color, policy, action, dataTest }) => (
+
+ );
+
+ const authGuestButtonsData = [
+ {
+ messageId: intlMessages.allowAllAuthenticated,
+ action: () => guestUsersCall(authenticatedUsers, ALLOW_STATUS),
+ key: 'allow-all-auth',
+ color: 'primary',
+ policy: 'ALWAYS_ACCEPT_AUTH',
+ },
+ {
+ messageId: intlMessages.allowAllGuests,
+ action: () => guestUsersCall(
+ [...guestUsers].concat(rememberChoice ? authenticatedUsers : []),
+ ALLOW_STATUS,
+ ),
+ key: 'allow-all-guest',
+ color: 'primary',
+ policy: 'ALWAYS_ACCEPT',
+ },
+ ];
+
+ const guestButtonsData = [
+ {
+ messageId: intlMessages.allowEveryone,
+ action: () => guestUsersCall([...guestUsers, ...authenticatedUsers], ALLOW_STATUS),
+ key: 'allow-everyone',
+ color: 'primary',
+ policy: 'ALWAYS_ACCEPT',
+ dataTest: 'allowEveryone',
+ },
+ {
+ messageId: intlMessages.denyEveryone,
+ action: () => guestUsersCall([...guestUsers, ...authenticatedUsers], DENY_STATUS),
+ key: 'deny-everyone',
+ color: 'danger',
+ policy: 'ALWAYS_DENY',
+ dataTest: 'denyEveryone',
+ },
+ ];
+
+ const buttonsData = ( authenticatedGuest && guestPolicyExtraAllowOptions )
+ ? authGuestButtonsData.concat(guestButtonsData)
+ : guestButtonsData;
+
+ const { isChrome } = browserInfo;
+
+ return (
+
+ closePanel(),
+ label: intl.formatMessage(intlMessages.title),
+ }}
+ />
+
+ {isGuestLobbyMessageEnabled ? (
+
+
+
+
+ "
+ {
+ guestLobbyMessage.length > 0
+ ? guestLobbyMessage
+ : intl.formatMessage(intlMessages.emptyMessage)
+ }
+ "
+
+
+
+ ) : null}
+
+ {intl.formatMessage(intlMessages.optionTitle)}
+ {
+ buttonsData.map((buttonData) => renderButton(
+ intl.formatMessage(buttonData.messageId),
+ buttonData,
+ ))
+ }
+ {allowRememberChoice ? (
+
+
+
+ {intl.formatMessage(intlMessages.rememberChoice)}
+
+
+ ) : null}
+
+ {renderPendingUsers(
+ intl.formatMessage(intlMessages.pendingUsers,
+ { 0: authenticatedUsers.length }),
+ authenticatedUsers,
+ guestUsersCall,
+ intl,
+ privateMessageVisible,
+ setPrivateGuestLobbyMessage,
+ privateGuestLobbyMessage,
+ isGuestLobbyMessageEnabled,
+ )}
+ {renderPendingUsers(
+ intl.formatMessage(intlMessages.pendingGuestUsers,
+ { 0: guestUsers.length }),
+ guestUsers,
+ guestUsersCall,
+ intl,
+ privateMessageVisible,
+ setPrivateGuestLobbyMessage,
+ privateGuestLobbyMessage,
+ isGuestLobbyMessageEnabled,
+ )}
+ {!existPendingUsers && (
+ renderNoUserWaitingItem(intl.formatMessage(intlMessages.noPendingUsers))
+ )}
+
+
+ );
+};
+
+export default injectWbResizeEvent(injectIntl(WaitingUsers));
diff --git a/src/2.7.12/imports/ui/components/waiting-users/container.jsx b/src/2.7.12/imports/ui/components/waiting-users/container.jsx
new file mode 100644
index 00000000..bb0a2073
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/container.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import Auth from '/imports/ui/services/auth';
+import GuestUsers from '/imports/api/guest-users';
+import Meetings from '/imports/api/meetings';
+import Service from './service';
+import WaitingComponent from './component';
+import { layoutDispatch } from '../layout/context';
+
+const WaitingContainer = (props) => {
+ const layoutContextDispatch = layoutDispatch();
+
+ return ;
+};
+
+export default withTracker(() => {
+ const guestUsers = GuestUsers.find({
+ meetingId: Auth.meetingID,
+ guest: true,
+ approved: false,
+ denied: false,
+ }).fetch();
+
+ const authenticatedUsers = GuestUsers.find({
+ meetingId: Auth.meetingID,
+ authenticated: true,
+ guest: false,
+ approved: false,
+ denied: false,
+ }).fetch();
+
+ const meeting = Meetings.findOne({ meetingId: Auth.meetingID });
+ const { usersProp } = meeting;
+ const { authenticatedGuest } = usersProp;
+
+ return {
+ guestUsers,
+ authenticatedUsers,
+ privateMessageVisible: Service.privateMessageVisible,
+ guestUsersCall: Service.guestUsersCall,
+ isWaitingRoomEnabled: Service.isWaitingRoomEnabled(),
+ changeGuestPolicy: Service.changeGuestPolicy,
+ isGuestLobbyMessageEnabled: Service.isGuestLobbyMessageEnabled,
+ setGuestLobbyMessage: Service.setGuestLobbyMessage,
+ guestLobbyMessage: Service.getGuestLobbyMessage(),
+ privateGuestLobbyMessage: Service.getPrivateGuestLobbyMessage,
+ setPrivateGuestLobbyMessage: Service.setPrivateGuestLobbyMessage,
+ authenticatedGuest,
+ guestPolicyExtraAllowOptions: Meteor.settings.public.app.guestPolicyExtraAllowOptions,
+ allowRememberChoice: Service.allowRememberChoice,
+ };
+})(WaitingContainer);
diff --git a/src/2.7.12/imports/ui/components/waiting-users/guest-policy/component.jsx b/src/2.7.12/imports/ui/components/waiting-users/guest-policy/component.jsx
new file mode 100644
index 00000000..1d24a705
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/guest-policy/component.jsx
@@ -0,0 +1,152 @@
+import React, { PureComponent } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import Styled from './styles';
+import { notify } from '/imports/ui/services/notification';
+
+const ASK_MODERATOR = 'ASK_MODERATOR';
+const ALWAYS_ACCEPT = 'ALWAYS_ACCEPT';
+const ALWAYS_DENY = 'ALWAYS_DENY';
+
+const intlMessages = defineMessages({
+ ariaModalTitle: {
+ id: 'app.guest-policy.ariaTitle',
+ description: 'Guest policy aria title',
+ },
+ guestPolicyTitle: {
+ id: 'app.guest-policy.title',
+ description: 'Guest policy title',
+ },
+ guestPolicyDescription: {
+ id: 'app.guest-policy.description',
+ description: 'Guest policy description',
+ },
+ policyBtnDesc: {
+ id: 'app.guest-policy.policyBtnDesc',
+ description: 'aria description for guest policy button',
+ },
+ askModerator: {
+ id: 'app.guest-policy.button.askModerator',
+ description: 'Ask moderator button label',
+ },
+ alwaysAccept: {
+ id: 'app.guest-policy.button.alwaysAccept',
+ description: 'Always accept button label',
+ },
+ alwaysDeny: {
+ id: 'app.guest-policy.button.alwaysDeny',
+ description: 'Always deny button label',
+ },
+ feedbackMessage: {
+ id: 'app.guest-policy.feedbackMessage',
+ description: 'Feedback message for guest policy change',
+ },
+});
+
+const propTypes = {
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ guestPolicy: PropTypes.string.isRequired,
+ changeGuestPolicy: PropTypes.func.isRequired,
+};
+
+class GuestPolicyComponent extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.handleChangePolicy = this.handleChangePolicy.bind(this);
+ }
+
+ componentWillUnmount() {
+ const { setIsOpen } = this.props;
+
+ setIsOpen(false);
+ }
+
+ handleChangePolicy(policyRule, messageId) {
+ const { intl, changeGuestPolicy } = this.props;
+
+ changeGuestPolicy(policyRule);
+
+ notify(intl.formatMessage(intlMessages.feedbackMessage) + intl.formatMessage(messageId), 'success');
+ }
+
+ render() {
+ const {
+ setIsOpen,
+ intl,
+ guestPolicy,
+ isOpen,
+ onRequestClose,
+ priority,
+ } = this.props;
+
+ return (
+ setIsOpen(false)}
+ contentLabel={intl.formatMessage(intlMessages.ariaModalTitle)}
+ title={intl.formatMessage(intlMessages.guestPolicyTitle)}
+ {...{
+ isOpen,
+ onRequestClose,
+ priority,
+ }}
+ >
+
+
+ {intl.formatMessage(intlMessages.guestPolicyDescription)}
+
+
+
+ {
+ this.handleChangePolicy(ASK_MODERATOR, intlMessages.askModerator);
+ setIsOpen(false);
+ }}
+ />
+ {
+ this.handleChangePolicy(ALWAYS_ACCEPT, intlMessages.alwaysAccept);
+ setIsOpen(false);
+ }}
+ />
+ {
+ this.handleChangePolicy(ALWAYS_DENY, intlMessages.alwaysDeny);
+ setIsOpen(false);
+ }}
+ />
+
+
+ {intl.formatMessage(intlMessages.policyBtnDesc)}
+
+
+
+ );
+ }
+}
+
+GuestPolicyComponent.propTypes = propTypes;
+
+export default injectIntl(GuestPolicyComponent);
diff --git a/src/2.7.12/imports/ui/components/waiting-users/guest-policy/container.jsx b/src/2.7.12/imports/ui/components/waiting-users/guest-policy/container.jsx
new file mode 100644
index 00000000..911c37c3
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/guest-policy/container.jsx
@@ -0,0 +1,22 @@
+import React, { useContext } from 'react';
+import { withTracker } from 'meteor/react-meteor-data';
+import GuestPolicyComponent from './component';
+import Service from '../service';
+import Auth from '/imports/ui/services/auth';
+import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const GuestPolicyContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const amIModerator = currentUser.role === ROLE_MODERATOR;
+
+ return amIModerator && ;
+};
+
+export default withTracker(( ) => ({
+ guestPolicy: Service.getGuestPolicy(),
+ changeGuestPolicy: Service.changeGuestPolicy,
+}))(GuestPolicyContainer);
diff --git a/src/2.7.12/imports/ui/components/waiting-users/guest-policy/styles.js b/src/2.7.12/imports/ui/components/waiting-users/guest-policy/styles.js
new file mode 100644
index 00000000..4b6e2a34
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/guest-policy/styles.js
@@ -0,0 +1,50 @@
+import styled from 'styled-components';
+import { colorGrayDark, colorGray } from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ jumboPaddingY,
+ lgPaddingX,
+ lgPaddingY,
+ titlePositionLeft,
+ modalMargin,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeLarge } from '/imports/ui/stylesheets/styled-components/typography';
+import ModalSimple from '/imports/ui/components/common/modal/simple/component';
+import Button from '/imports/ui/components/common/button/component';
+
+const GuestPolicyModal = styled(ModalSimple)``;
+
+const Container = styled.div`
+ margin: 0 ${modalMargin} ${lgPaddingX};
+`;
+
+const Description = styled.div`
+ text-align: center;
+ color: ${colorGray};
+ margin-bottom: ${jumboPaddingY};
+`;
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+`;
+
+const GuestPolicyButton = styled(Button)`
+ width: 200px;
+ box-sizing: border-box;
+ margin: 5px;
+
+ ${({ disabled }) => disabled && `
+ & > span {
+ text-decoration: underline;
+ }
+ `}
+`;
+
+export default {
+ GuestPolicyModal,
+ Container,
+ Description,
+ Content,
+ GuestPolicyButton,
+};
diff --git a/src/2.7.12/imports/ui/components/waiting-users/service.js b/src/2.7.12/imports/ui/components/waiting-users/service.js
new file mode 100644
index 00000000..ed3d8d51
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/service.js
@@ -0,0 +1,83 @@
+import Meetings from '/imports/api/meetings';
+import Auth from '/imports/ui/services/auth';
+import GuestUsers from '/imports/api/guest-users';
+import { makeCall } from '/imports/ui/services/api';
+
+const guestUsersCall = (guestsArray, status) => makeCall('allowPendingUsers', guestsArray, status);
+
+const changeGuestPolicy = (policyRule) => makeCall('changeGuestPolicy', policyRule);
+
+const getGuestPolicy = () => {
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { 'usersProp.guestPolicy': 1 } },
+ );
+
+ return meeting.usersProp.guestPolicy;
+};
+
+const isWaitingRoomEnabled = () => getGuestPolicy() === 'ASK_MODERATOR';
+
+const isGuestLobbyMessageEnabled = Meteor.settings.public.app.enableGuestLobbyMessage;
+
+// We use the dynamicGuestPolicy rule for allowing the rememberChoice checkbox
+const allowRememberChoice = Meteor.settings.public.app.dynamicGuestPolicy;
+
+const getGuestLobbyMessage = () => {
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { guestLobbyMessage: 1 } },
+ );
+
+ if (meeting) return meeting.guestLobbyMessage;
+
+ return '';
+};
+
+const setGuestLobbyMessage = (message) => makeCall('setGuestLobbyMessage', message);
+
+const getPrivateGuestLobbyMessage = (guestId) => {
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { guestLobbyMessage: 1 } },
+ );
+
+ if (meeting) {
+ const guestUser = GuestUsers.findOne(
+ { meetingId: Auth.meetingID, intId: guestId },
+ { fields: { privateGuestLobbyMessage: 1} },
+ );
+
+ if (guestUser.privateGuestLobbyMessage !== '' ) {
+ return guestUser.privateGuestLobbyMessage;
+ } else {
+ return meeting.guestLobbyMessage;
+ }
+ }
+ return '';
+};
+
+const setPrivateGuestLobbyMessage = (message, guestId) => makeCall('setPrivateGuestLobbyMessage', message, guestId);
+
+const privateMessageVisible = (id) => {
+ const privateInputSpace = document.getElementById(id);
+ if (privateInputSpace.style.display === "block") {
+ privateInputSpace.style.display = "none";
+ } else {
+ privateInputSpace.style.display = "block";
+ }
+};
+
+export default {
+ guestUsersCall,
+ privateMessageVisible,
+ changeGuestPolicy,
+ getGuestPolicy,
+ isWaitingRoomEnabled,
+ isGuestLobbyMessageEnabled,
+ getGuestLobbyMessage,
+ setGuestLobbyMessage,
+ getPrivateGuestLobbyMessage,
+ setPrivateGuestLobbyMessage,
+ allowRememberChoice,
+};
diff --git a/src/2.7.12/imports/ui/components/waiting-users/styles.js b/src/2.7.12/imports/ui/components/waiting-users/styles.js
new file mode 100644
index 00000000..7715e025
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/waiting-users/styles.js
@@ -0,0 +1,257 @@
+import styled from 'styled-components';
+import {
+ colorPrimary,
+ listItemBgHover,
+ itemFocusBorder,
+ colorGray,
+ colorWhite,
+ colorGrayLightest,
+ colorOffWhite,
+} from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ borderSize,
+ mdPaddingX,
+ mdPaddingY,
+} from '/imports/ui/stylesheets/styled-components/general';
+import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import Button from '/imports/ui/components/common/button/component';
+import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
+
+const ListItem = styled.div`
+ display: flex;
+ flex-flow: row;
+ flex-direction: row;
+ align-items: center;
+ border-radius: 5px;
+
+ ${({ animations }) => animations && `
+ transition: all .3s;
+ `}
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:focus {
+ background-color: ${listItemBgHover};
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder};
+ outline: none;
+ }
+
+ flex-shrink: 0;
+`;
+
+const UserContentContainer = styled.div`
+ display: flex;
+ flex: 1;
+ overflow: hidden;
+ align-items: center;
+ flex-direction: row;
+`;
+
+const UserAvatarContainer = styled.div`
+ min-width: 2.25rem;
+ margin: .5rem;
+`;
+
+const UserName = styled.p`
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: initial;
+`;
+
+const ButtonContainer = styled.div`
+ display: flex;
+ justify-self: flex-end;
+ align-items: center;
+ color: ${colorPrimary};
+ & > button {
+ padding: ${mdPaddingY};
+ font-size: ${fontSizeBase};
+ border-radius: 50%;
+ }
+`;
+
+const WaitingUsersButton = styled(Button)`
+ font-weight: 400;
+ color: ${colorPrimary};
+
+ &:focus {
+ background-color: ${listItemBgHover} !important;
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder} ;
+ outline: none;
+ }
+
+ &:hover {
+ color: ${colorPrimary};
+ background-color: ${listItemBgHover} !important;
+ }
+`;
+const WaitingUsersButtonMsg = styled(Button)`
+ font-weight: 400;
+ color: ${colorPrimary};
+
+ &:after {
+ font-family: 'bbb-icons';
+ content: "\\E910";
+ }
+
+ &:focus {
+ background-color: ${listItemBgHover} !important;
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder} ;
+ outline: none;
+ }
+
+ &:hover {
+ color: ${colorPrimary};
+ background-color: ${listItemBgHover} !important;
+ }
+`;
+const WaitingUsersButtonDeny = styled(Button)`
+ font-weight: 400;
+ color: #ff0e0e;
+
+ &:focus {
+ background-color: ${listItemBgHover} !important;
+ box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder} ;
+ outline: none;
+ }
+
+ &:hover {
+ color: #ff0e0e;
+ background-color: ${listItemBgHover} !important;
+ }
+`;
+
+const PendingUsers = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const NoPendingUsers = styled.p`
+ text-align: center;
+ font-weight: bold;
+`;
+
+const MainTitle = styled.p`
+ color: ${colorGray};
+`;
+
+const UsersWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const Users = styled.div`
+ display: flex;
+ flex-direction: column;
+`;
+
+const CustomButton = styled(Button)`
+ width: 100%;
+ padding: .75rem;
+ margin: .3rem 0;
+ font-weight: 400;
+ font-size: ${fontSizeBase};
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const Panel = styled.div`
+ background-color: ${colorWhite};
+ padding: ${mdPaddingX} ${mdPaddingY} ${mdPaddingX} ${mdPaddingX};
+
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ justify-content: flex-start;
+ overflow: hidden;
+ height: 100%;
+
+ ${({ isChrome }) => isChrome && `
+ transform: translateZ(0);
+ `}
+
+ @media ${smallOnly} {
+ transform: none !important;
+ }
+`;
+
+const LobbyMessage = styled.div`
+ border-bottom: 1px solid ${colorGrayLightest};
+ margin: 2px 2px 0 2px;
+
+ & > p {
+ background-color: ${colorOffWhite};
+ box-sizing: border-box;
+ color: ${colorGray};
+ padding: 1rem;
+ text-align: center;
+ }
+`;
+
+const PrivateLobbyMessage = styled.div`
+ border-bottom: 1px solid ${colorGrayLightest};
+ display: none;
+ & > p {
+ background-color: ${colorOffWhite};
+ box-sizing: border-box;
+ color: ${colorGray};
+ padding: 1rem;
+ text-align: center;
+ }
+`;
+
+const RememberContainer = styled.div`
+ margin: 1rem 1rem;
+ height: 2rem;
+ display: flex;
+ align-items: center;
+ & > label {
+ height: fit-content;
+ padding: 0;
+ margin: 0;
+ margin-left: .7rem;
+
+ [dir="rtl"] & {
+ margin: 0;
+ margin-right: .7rem;
+ }
+ }
+`;
+
+const ScrollableArea = styled(ScrollboxVertical)`
+ overflow-y: auto;
+ padding-right: 0.25rem;
+`;
+
+const ModeratorActions = styled.div`
+ padding: 0 .2rem;
+`;
+
+export default {
+ ListItem,
+ UserContentContainer,
+ UserAvatarContainer,
+ PrivateLobbyMessage,
+ UserName,
+ ButtonContainer,
+ WaitingUsersButton,
+ WaitingUsersButtonDeny,
+ WaitingUsersButtonMsg,
+ PendingUsers,
+ NoPendingUsers,
+ MainTitle,
+ UsersWrapper,
+ Users,
+ CustomButton,
+ Panel,
+ LobbyMessage,
+ RememberContainer,
+ ScrollableArea,
+ ModeratorActions,
+};
diff --git a/src/2.7.12/imports/ui/components/wake-lock/component.jsx b/src/2.7.12/imports/ui/components/wake-lock/component.jsx
new file mode 100644
index 00000000..dae739f6
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/wake-lock/component.jsx
@@ -0,0 +1,104 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { notify } from '/imports/ui/services/notification';
+import Settings from '/imports/ui/services/settings';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ wakeLockOfferTitle: {
+ id: 'app.toast.wakeLock.offerTitle',
+ },
+ wakeLockAcquireSuccess: {
+ id: 'app.toast.wakeLock.acquireSuccess',
+ },
+ wakeLockAcquireFailed: {
+ id: 'app.toast.wakeLock.acquireFailed',
+ },
+ wakeLockNotSupported: {
+ id: 'app.toast.wakeLock.notSupported',
+ },
+ wakeLockDisclaimer: {
+ id: 'app.toast.wakeLock.disclaimer',
+ }
+});
+
+const propTypes = {
+ intl: PropTypes.objectOf(Object).isRequired,
+ request: PropTypes.func.isRequired,
+ release: PropTypes.func.isRequired,
+ wakeLockSettings: PropTypes.bool.isRequired,
+};
+
+class WakeLock extends Component {
+ constructor() {
+ super();
+ }
+
+ componentDidMount() {
+ const { wakeLockSettings } = this.props;
+
+ if (wakeLockSettings) {
+ this.requestWakeLock();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ const { wakeLockSettings, release } = this.props;
+ if (wakeLockSettings !== prevProps.wakeLockSettings) {
+ if (wakeLockSettings) {
+ this.requestWakeLock();
+ } else {
+ release();
+ }
+ }
+ }
+
+ getToast(id, message) {
+ return (
+
+
+ { message }
+
+
+ );
+ }
+
+ feedbackToast(result) {
+ const { intl } = this.props;
+
+ const feedbackToastProps = {
+ closeOnClick: true,
+ autoClose: true,
+ closeButton: false,
+ };
+
+ const toastType = result.error ? 'error' : 'success';
+ const message = result.error
+ ? intl.formatMessage(intlMessages.wakeLockDisclaimer,{
+ 0: intl.formatMessage(intlMessages[result.locale])
+ } )
+ : intl.formatMessage(intlMessages.wakeLockAcquireSuccess);
+ const feedbackToast = this.getToast('wakeLockToast', message);
+ notify(feedbackToast, toastType, 'lock', feedbackToastProps, null, true);
+ }
+
+ requestWakeLock () {
+ const { request } = this.props;
+ request().then((result) => {
+ if (result && result.error) {
+ Settings.application.wakeLock = false;
+ Settings.save();
+ this.feedbackToast(result);
+ }
+ });
+ }
+
+ render() {
+ return null;
+ }
+}
+
+WakeLock.propTypes = propTypes;
+
+export default injectIntl(WakeLock);
diff --git a/src/2.7.12/imports/ui/components/wake-lock/container.jsx b/src/2.7.12/imports/ui/components/wake-lock/container.jsx
new file mode 100644
index 00000000..d31866fc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/wake-lock/container.jsx
@@ -0,0 +1,55 @@
+import React, { useEffect, useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { withTracker } from 'meteor/react-meteor-data';
+import WakeLock from './component';
+import Service from './service';
+import Settings from '/imports/ui/services/settings';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+
+const APP_CONFIG = Meteor.settings.public.app;
+
+const propTypes = {
+ areAudioModalsOpen: PropTypes.bool,
+ autoJoin: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ areAudioModalsOpen: false,
+};
+
+function usePrevious(value) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+}
+
+const WakeLockContainer = (props) => {
+ if (!Service.isMobile()) return null;
+
+ const { areAudioModalsOpen, autoJoin } = props;
+ const wereAudioModalsOpen = usePrevious(areAudioModalsOpen);
+ const [endedAudioSetup, setEndedAudioSetup] = useState(false || !autoJoin);
+
+ useEffect(() => {
+ if (wereAudioModalsOpen && !areAudioModalsOpen && !endedAudioSetup) {
+ setEndedAudioSetup(true);
+ }
+ }, [areAudioModalsOpen]);
+
+ return endedAudioSetup ? : null;
+};
+
+WakeLockContainer.propTypes = propTypes;
+WakeLockContainer.defaultProps = defaultProps;
+
+export default withTracker(() => {
+ return {
+ request: Service.request,
+ release: Service.release,
+ wakeLockSettings: Settings.application.wakeLock,
+ areAudioModalsOpen: Session.get('audioModalIsOpen') || Session.get('inEchoTest'),
+ autoJoin: getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin),
+ };
+})(WakeLockContainer);
diff --git a/src/2.7.12/imports/ui/components/wake-lock/service.js b/src/2.7.12/imports/ui/components/wake-lock/service.js
new file mode 100644
index 00000000..41ef938d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/wake-lock/service.js
@@ -0,0 +1,100 @@
+import logger from '/imports/startup/client/logger';
+import deviceInfo from '/imports/utils/deviceInfo';
+
+const WAKELOCK_ENABLED = Meteor.settings.public.app.wakeLock.enabled;
+
+const WAKELOCK_ERRORS = {
+ NOT_SUPPORTED: {
+ locale: 'wakeLockNotSupported',
+ error: 'wake_lock_not_supported',
+ },
+ REQUEST_FAILED: {
+ locale: 'wakeLockAcquireFailed',
+ error: 'wake_lock_request_error',
+ },
+};
+
+class WakeLock {
+ constructor() {
+ this.sentinel = null;
+ this.apiSupport = 'wakeLock' in navigator;
+ }
+
+ static isEnabled() {
+ return WAKELOCK_ENABLED;
+ }
+
+ isSupported() {
+ const { isMobile } = deviceInfo;
+ return WakeLock.isEnabled() && this.apiSupport && isMobile;
+ }
+
+ isMobile() {
+ const { isMobile } = deviceInfo;
+ return isMobile;
+ }
+
+ isActive() {
+ return this.sentinel !== null;
+ }
+
+ handleVisibilityChanged() {
+ if (document.visibilityState === 'visible') {
+ this.request();
+ }
+ }
+
+ handleRelease() {
+ document.removeEventListener('visibilitychange', this.handleVisibilityChanged);
+ this.sentinel = null;
+ }
+
+ async request() {
+ if (!this.isSupported()) {
+ logger.warn({
+ logCode: WAKELOCK_ERRORS.NOT_SUPPORTED.error,
+ }, 'Wake lock API not supported');
+ return {
+ ...WAKELOCK_ERRORS.NOT_SUPPORTED,
+ msg: 'Wake lock API not supported',
+ };
+ }
+
+ try {
+ this.sentinel = await navigator.wakeLock.request('screen');
+ this.sentinel.addEventListener('release', this.handleRelease);
+ document.addEventListener('visibilitychange', this.handleVisibilityChanged.bind(this));
+ document.addEventListener('fullscreenchange', this.handleVisibilityChanged.bind(this));
+ } catch (err) {
+ logger.warn({
+ logCode: WAKELOCK_ERRORS.REQUEST_FAILED.error,
+ extraInfo: {
+ errorName: err.name,
+ errorMessage: err.message,
+ },
+ }, 'Error requesting wake lock.');
+ return {
+ ...WAKELOCK_ERRORS.REQUEST_FAILED,
+ msg: `${err.name} - ${err.message}`,
+ };
+ }
+ return {
+ error: false,
+ };
+ }
+
+ release() {
+ if (this.isActive()) this.sentinel.release();
+ }
+}
+
+const wakeLock = new WakeLock();
+
+export default {
+ isEnabled: () => wakeLock.isEnabled(),
+ isSupported: () => wakeLock.isSupported(),
+ isMobile: () => wakeLock.isMobile(),
+ isActive: () => wakeLock.isActive(),
+ request: () => wakeLock.request(),
+ release: () => wakeLock.release(),
+};
diff --git a/src/2.7.12/imports/ui/components/wake-lock/styles.js b/src/2.7.12/imports/ui/components/wake-lock/styles.js
new file mode 100644
index 00000000..65528417
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/wake-lock/styles.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+const Title = styled.h3`
+ margin: 0;
+`;
+
+export default {
+ Title,
+};
\ No newline at end of file
diff --git a/src/2.7.12/imports/ui/components/webcam/component.jsx b/src/2.7.12/imports/ui/components/webcam/component.jsx
new file mode 100644
index 00000000..846e8eb9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/webcam/component.jsx
@@ -0,0 +1,281 @@
+import React, { useState, useEffect } from 'react';
+import Resizable from 're-resizable';
+import Draggable from 'react-draggable';
+import Styled from './styles';
+import { ACTIONS, CAMERADOCK_POSITION } from '../layout/enums';
+import DropAreaContainer from './drop-areas/container';
+import VideoProviderContainer from '/imports/ui/components/video-provider/container';
+import Storage from '/imports/ui/services/storage/session';
+import { colorContentBackground } from '/imports/ui/stylesheets/styled-components/palette';
+import { defineMessages } from 'react-intl';
+import {
+ isPresenter,
+} from '/imports/ui/components/settings/service';
+
+const intlMessages = defineMessages({
+ layoutToastLabelAuto: {
+ id: 'app.layout.modal.layoutToastLabelAuto',
+ description: 'Layout toast label',
+ },
+});
+
+const WebcamComponent = ({
+ cameraDock,
+ swapLayout,
+ focusedId,
+ layoutContextDispatch,
+ fullscreen,
+ isPresenter,
+ displayPresentation,
+ cameraOptimalGridSize: cameraSize,
+ isRTL,
+ isGridEnabled,
+}) => {
+ const [isResizing, setIsResizing] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isFullscreen, setIsFullScreen] = useState(false);
+ const [resizeStart, setResizeStart] = useState({ width: 0, height: 0 });
+ const [cameraMaxWidth, setCameraMaxWidth] = useState(0);
+ const [draggedAtLeastOneTime, setDraggedAtLeastOneTime] = useState(false);
+
+ const lastSize = Storage.getItem('webcamSize') || { width: 0, height: 0 };
+ const { width: lastWidth, height: lastHeight } = lastSize;
+
+ const isCameraTopOrBottom = cameraDock.position === CAMERADOCK_POSITION.CONTENT_TOP
+ || cameraDock.position === CAMERADOCK_POSITION.CONTENT_BOTTOM;
+ const isCameraLeftOrRight = cameraDock.position === CAMERADOCK_POSITION.CONTENT_LEFT
+ || cameraDock.position === CAMERADOCK_POSITION.CONTENT_RIGHT;
+ const isCameraSidebar = cameraDock.position === CAMERADOCK_POSITION.SIDEBAR_CONTENT_BOTTOM;
+
+ useEffect(() => {
+ const handleVisibility = () => {
+ if (document.hidden) {
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ }
+ };
+
+ document.addEventListener('visibilitychange', handleVisibility);
+
+ return () => {
+ document.removeEventListener('visibilitychange', handleVisibility);
+ };
+ }, []);
+
+ useEffect(() => {
+ setIsFullScreen(fullscreen.group === 'webcams');
+ }, [fullscreen]);
+
+ useEffect(() => {
+ const newCameraMaxWidth = (isPresenter && cameraDock.presenterMaxWidth) ? cameraDock.presenterMaxWidth : cameraDock.maxWidth;
+ setCameraMaxWidth(newCameraMaxWidth);
+
+ if (isCameraLeftOrRight && cameraDock.width > newCameraMaxWidth) {
+ layoutContextDispatch(
+ {
+ type: ACTIONS.SET_CAMERA_DOCK_SIZE,
+ value: {
+ width: newCameraMaxWidth,
+ height: cameraDock.height,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ },
+ },
+ );
+ Storage.setItem('webcamSize', { width: newCameraMaxWidth, height: lastHeight });
+ }
+
+ const cams = document.getElementById('cameraDock');
+ cams?.setAttribute("data-position", cameraDock.position);
+ }, [cameraDock.position, cameraDock.maxWidth, isPresenter, displayPresentation]);
+
+ const handleVideoFocus = (id) => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_FOCUSED_CAMERA_ID,
+ value: focusedId !== id ? id : false,
+ });
+ }
+
+ const onResizeHandle = (deltaWidth, deltaHeight) => {
+ if (cameraDock.resizableEdge.top || cameraDock.resizableEdge.bottom) {
+ layoutContextDispatch(
+ {
+ type: ACTIONS.SET_CAMERA_DOCK_SIZE,
+ value: {
+ width: cameraDock.width,
+ height: resizeStart.height + deltaHeight,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ },
+ },
+ );
+ }
+ if (cameraDock.resizableEdge.left || cameraDock.resizableEdge.right) {
+ layoutContextDispatch(
+ {
+ type: ACTIONS.SET_CAMERA_DOCK_SIZE,
+ value: {
+ width: resizeStart.width + deltaWidth,
+ height: cameraDock.height,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ },
+ },
+ );
+ }
+ };
+
+ const handleWebcamDragStart = () => {
+ setIsDragging(true);
+ document.body.style.overflow = 'hidden';
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_IS_DRAGGING,
+ value: true,
+ });
+ };
+
+ const handleWebcamDragStop = (e) => {
+ setIsDragging(false);
+ setDraggedAtLeastOneTime(false);
+ document.body.style.overflow = 'auto';
+
+ if (Object.values(CAMERADOCK_POSITION).includes(e.target.id) && draggedAtLeastOneTime) {
+ const layout = document.getElementById('layout');
+ layout?.setAttribute("data-cam-position", e?.target?.id);
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_POSITION,
+ value: e.target.id,
+ });
+ }
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_IS_DRAGGING,
+ value: false,
+ });
+ };
+
+ let draggableOffset = {
+ left: isDragging && (isCameraTopOrBottom || isCameraSidebar)
+ ? ((cameraDock.width - cameraSize.width) / 2)
+ : 0,
+ top: isDragging && isCameraLeftOrRight
+ ? ((cameraDock.height - cameraSize.height) / 2)
+ : 0,
+ };
+
+ if (isRTL) {
+ draggableOffset.left = draggableOffset.left * -1;
+ }
+ const isIphone = !!(navigator.userAgent.match(/iPhone/i));
+
+ const mobileWidth = `${isDragging ? cameraSize.width : cameraDock.width}pt`;
+ const mobileHeight = `${isDragging ? cameraSize.height : cameraDock.height}pt`;
+ const isDesktopWidth = isDragging ? cameraSize.width : cameraDock.width;
+ const isDesktopHeight = isDragging ? cameraSize.height : cameraDock.height;
+ const camOpacity = isDragging ? 0.5 : undefined;
+ return (
+ <>
+ {isDragging ? : null}
+
+ {
+ if (!draggedAtLeastOneTime) {
+ setDraggedAtLeastOneTime(true);
+ }
+ }}
+ onStop={handleWebcamDragStop}
+ onMouseDown={
+ cameraDock.isDraggable ? (e) => e.preventDefault() : undefined
+ }
+ disabled={!cameraDock.isDraggable || isResizing || isFullscreen}
+ position={
+ {
+ x: cameraDock.left - cameraDock.right + draggableOffset.left,
+ y: cameraDock.top + draggableOffset.top,
+ }
+ }
+ >
+ {
+ setIsResizing(true);
+ setResizeStart({ width: cameraDock.width, height: cameraDock.height });
+ onResizeHandle(cameraDock.width, cameraDock.height);
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
+ value: true,
+ });
+ }}
+ onResize={(e, direction, ref, d) => {
+ onResizeHandle(d.width, d.height);
+ }}
+ onResizeStop={() => {
+ setResizeStart({ width: 0, height: 0 });
+ setTimeout(() => setIsResizing(false), 500);
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
+ value: false,
+ });
+ }}
+ enable={{
+ top: !isFullscreen && !isDragging && !swapLayout && cameraDock.resizableEdge.top,
+ bottom: !isFullscreen && !isDragging && !swapLayout
+ && cameraDock.resizableEdge.bottom,
+ left: !isFullscreen && !isDragging && !swapLayout && cameraDock.resizableEdge.left,
+ right: !isFullscreen && !isDragging && !swapLayout && cameraDock.resizableEdge.right,
+ topLeft: false,
+ topRight: false,
+ bottomLeft: false,
+ bottomRight: false,
+ }}
+ style={{
+ position: 'absolute',
+ zIndex: isCameraSidebar && !isDragging ? 0 : cameraDock.zIndex,
+ }}
+ >
+
+
+
+
+
+
+ >
+ );
+};
+
+export default WebcamComponent;
diff --git a/src/2.7.12/imports/ui/components/webcam/container.jsx b/src/2.7.12/imports/ui/components/webcam/container.jsx
new file mode 100644
index 00000000..60bc1d9f
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/webcam/container.jsx
@@ -0,0 +1,88 @@
+import React, { useContext } from 'react';
+
+import { withTracker } from 'meteor/react-meteor-data';
+import MediaService from '/imports/ui/components/media/service';
+import Auth from '/imports/ui/services/auth';
+import VideoService from '/imports/ui/components/video-provider/service';
+import { UsersContext } from '../components-data/users-context/context';
+import {
+ layoutSelect,
+ layoutSelectInput,
+ layoutSelectOutput,
+ layoutDispatch,
+} from '../layout/context';
+import WebcamComponent from '/imports/ui/components/webcam/component';
+import { LAYOUT_TYPE } from '../layout/enums';
+import { sortVideoStreams } from '/imports/ui/components/video-provider/stream-sorting';
+
+const { defaultSorting: DEFAULT_SORTING } = Meteor.settings.public.kurento.cameraSortingModes;
+
+const WebcamContainer = ({
+ audioModalIsOpen,
+ swapLayout,
+ usersVideo,
+ layoutType,
+}) => {
+ const fullscreen = layoutSelect((i) => i.fullscreen);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const cameraDockInput = layoutSelectInput((i) => i.cameraDock);
+ const focusedId = layoutSelectInput((i) => i.focusedId);
+ const presentation = layoutSelectOutput((i) => i.presentation);
+ const cameraDock = layoutSelectOutput((i) => i.cameraDock);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { cameraOptimalGridSize } = cameraDockInput;
+ const { display: displayPresentation } = presentation;
+
+ const usingUsersContext = useContext(UsersContext);
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+
+ const isGridEnabled = layoutType === LAYOUT_TYPE.VIDEO_FOCUS;
+
+ return !audioModalIsOpen
+ && (usersVideo.length > 0 || isGridEnabled)
+ ? (
+
+ )
+ : null;
+};
+
+export default withTracker((props) => {
+ const { current_presentation: hasPresentation } = MediaService.getPresentationInfo();
+ const data = {
+ audioModalIsOpen: Session.get('audioModalIsOpen'),
+ isMeteorConnected: Meteor.status().connected,
+ };
+
+ const { streams: usersVideo, gridUsers } = VideoService.getVideoStreams();
+
+ if(gridUsers.length > 0) {
+ const items = usersVideo.concat(gridUsers);
+ data.usersVideo = sortVideoStreams(items, DEFAULT_SORTING);
+ } else {
+ data.usersVideo = usersVideo;
+ }
+ data.swapLayout = !hasPresentation || props.isLayoutSwapped;
+
+ if (data.swapLayout) {
+ data.floatingOverlay = true;
+ data.hideOverlay = true;
+ }
+
+ return data;
+})(WebcamContainer);
diff --git a/src/2.7.12/imports/ui/components/webcam/drop-areas/component.jsx b/src/2.7.12/imports/ui/components/webcam/drop-areas/component.jsx
new file mode 100644
index 00000000..a68ef707
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/webcam/drop-areas/component.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import Styled from './styles';
+
+const intlMessages = defineMessages({
+ dropZoneLabel: {
+ id: 'app.video.dropZoneLabel',
+ description: 'message showing where the user can drop cameraDock',
+ },
+});
+
+const DropArea = ({
+ id, dataTest, style, intl,
+}) => (
+ <>
+
+
+ {intl.formatMessage(intlMessages.dropZoneLabel)}
+
+ >
+);
+
+export default injectIntl(DropArea);
diff --git a/src/2.7.12/imports/ui/components/webcam/drop-areas/container.jsx b/src/2.7.12/imports/ui/components/webcam/drop-areas/container.jsx
new file mode 100644
index 00000000..6cf740c9
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/webcam/drop-areas/container.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { layoutSelectOutput } from '../../layout/context';
+import DropArea from './component';
+
+const DropAreaContainer = () => {
+ const dropZoneAreas = layoutSelectOutput((i) => i.dropZoneAreas);
+
+ return (
+ Object.keys(dropZoneAreas).map((objectKey) => (
+
+ ))
+ );
+};
+
+export default DropAreaContainer;
diff --git a/src/2.7.12/imports/ui/components/webcam/drop-areas/styles.js b/src/2.7.12/imports/ui/components/webcam/drop-areas/styles.js
new file mode 100644
index 00000000..42dbdadc
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/webcam/drop-areas/styles.js
@@ -0,0 +1,40 @@
+import styled from 'styled-components';
+import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
+
+const DropZoneArea = styled.div`
+ position: absolute;
+ background: transparent;
+ -webkit-box-shadow: inset 0px 0px 0px 1px rgba(0, 0, 0, .2);
+ -moz-box-shadow: inset 0px 0px 0px 1px rgba(0, 0, 0, .2);
+ box-shadow: inset 0px 0px 0px 1px rgba(0, 0, 0, .2);
+ font-weight: bold;
+ font-family: sans-serif;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grabbing;
+
+ &:hover {
+ background-color: rgba(0, 0, 0, .1);
+ }
+`;
+
+const DropZoneBg = styled.div`
+ position: absolute;
+ background-color: rgba(0, 0, 0, .5);
+ -webkit-box-shadow: inset 0px 0px 0px 1px #666;
+ -moz-box-shadow: inset 0px 0px 0px 1px #666;
+ box-shadow: inset 0px 0px 0px 1px #666;
+ color: ${colorWhite};
+ font-weight: bold;
+ font-family: sans-serif;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+`;
+
+export default {
+ DropZoneArea,
+ DropZoneBg,
+};
diff --git a/src/2.7.12/imports/ui/components/webcam/styles.js b/src/2.7.12/imports/ui/components/webcam/styles.js
new file mode 100644
index 00000000..825f6484
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/webcam/styles.js
@@ -0,0 +1,38 @@
+import styled from 'styled-components';
+
+const Draggable = styled.div`
+ ${({ isDraggable }) => isDraggable && `
+ & > video {
+ cursor: grabbing;
+ }
+ `}
+
+ ${({ isDragging }) => isDragging && `
+ background-color: rgba(200, 200, 200, 0.5);
+ `}
+`;
+
+const ResizableWrapper = styled.div`
+ ${({ horizontal }) => horizontal && `
+ & > div span div {
+ &:hover {
+ background-color: rgba(255, 255, 255, .3);
+ }
+ width: 100% !important;
+ }
+ `}
+
+ ${({ vertical }) => vertical && `
+ & > div span div {
+ &:hover {
+ background-color: rgba(255, 255, 255, .3);
+ }
+ height: 100% !important;
+ }
+ `}
+`;
+
+export default {
+ Draggable,
+ ResizableWrapper,
+};
diff --git a/src/2.7.12/imports/ui/components/whiteboard/component.jsx b/src/2.7.12/imports/ui/components/whiteboard/component.jsx
new file mode 100644
index 00000000..2377b2ce
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/component.jsx
@@ -0,0 +1,1259 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { TldrawApp, Tldraw } from '@tldraw/tldraw';
+import SlideCalcUtil, { HUNDRED_PERCENT, MAX_PERCENT } from '/imports/utils/slideCalcUtils';
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { Utils } from '@tldraw/core';
+import Cursors from './cursors/container';
+import Settings from '/imports/ui/services/settings';
+import logger from '/imports/startup/client/logger';
+import KEY_CODES from '/imports/utils/keyCodes';
+import {
+ presentationMenuHeight,
+ styleMenuOffset,
+ styleMenuOffsetSmall
+} from '/imports/ui/stylesheets/styled-components/general';
+import Styled from './styles';
+import PanToolInjector from './pan-tool-injector/component';
+import {
+ findRemoved, filterInvalidShapes, mapLanguage, sendShapeChanges, usePrevious, TextUtil,
+} from './utils';
+import { isEqual } from 'radash';
+
+const SMALL_HEIGHT = 435;
+const SMALLEST_DOCK_HEIGHT = 475;
+const SMALL_WIDTH = 800;
+const SMALLEST_DOCK_WIDTH = 710;
+const TOOLBAR_SMALL = 28;
+const TOOLBAR_LARGE = 32;
+const MOUNTED_RESIZE_DELAY = 1500;
+
+const LETTER_SPACING = '-0.03em';
+const LINE_HEIGHT = 1.2;
+const fontSizeMap = {
+ small: 12,
+ medium: 16,
+ large: 20
+};
+
+export default function Whiteboard(props) {
+ const {
+ isPresenter,
+ removeShapes,
+ initDefaultPages,
+ persistShape,
+ shapes,
+ assets,
+ currentUser,
+ curPres,
+ whiteboardId,
+ podId,
+ zoomSlide,
+ skipToSlide,
+ slidePosition,
+ curPageId,
+ presentationWidth,
+ presentationHeight,
+ isViewersCursorLocked,
+ zoomChanger,
+ isMultiUserActive,
+ isRTL,
+ fitToWidth,
+ zoomValue,
+ intl,
+ svgUri,
+ maxStickyNoteLength,
+ fontFamily,
+ hasShapeAccess,
+ presentationAreaHeight,
+ presentationAreaWidth,
+ maxNumberOfAnnotations,
+ notifyShapeNumberExceeded,
+ darkTheme,
+ isPanning: shortcutPanning,
+ setTldrawIsMounting,
+ width,
+ height,
+ hasMultiUserAccess,
+ tldrawAPI,
+ setTldrawAPI,
+ whiteboardToolbarAutoHide,
+ toggleToolsAnimations,
+ isIphone,
+ sidebarNavigationWidth,
+ animations,
+ isToolbarVisible,
+ isModerator,
+ fullscreenRef,
+ fullscreenElementId,
+ layoutContextDispatch,
+ } = props;
+ const { pages, pageStates } = initDefaultPages(curPres?.pages.length || 1);
+ const rDocument = React.useRef({
+ name: 'test',
+ version: TldrawApp.version,
+ id: whiteboardId,
+ pages,
+ pageStates,
+ bindings: {},
+ assets: {},
+ });
+ const [history, setHistory] = React.useState(null);
+ const [zoom, setZoom] = React.useState(HUNDRED_PERCENT);
+ const [tldrawZoom, setTldrawZoom] = React.useState(1);
+ const zoomValueRef = React.useRef(zoomValue);
+ const isMouseDownRef = React.useRef(false);
+ const [isMounting, setIsMounting] = React.useState(true);
+ const prevShapes = usePrevious(shapes);
+ const prevSlidePosition = usePrevious(slidePosition);
+ const prevFitToWidth = usePrevious(fitToWidth);
+ const prevSvgUri = usePrevious(svgUri);
+ const prevPageId = usePrevious(curPageId);
+ const language = mapLanguage(Settings?.application?.locale?.toLowerCase() || 'en');
+ const [currentTool, setCurrentTool] = React.useState(null);
+ const [currentStyle, setCurrentStyle] = React.useState({});
+ const [isMoving, setIsMoving] = React.useState(false);
+ const [isPanning, setIsPanning] = React.useState(shortcutPanning);
+ const [panSelected, setPanSelected] = React.useState(isPanning);
+ const isMountedRef = React.useRef(true);
+ const [isToolLocked, setIsToolLocked] = React.useState(tldrawAPI?.appState?.isToolLocked);
+ const [bgShape, setBgShape] = React.useState(null);
+
+ // eslint-disable-next-line arrow-body-style
+ React.useEffect(() => {
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
+
+ React.useEffect(() => {
+ zoomValueRef.current = zoomValue;
+ }, [zoomValue]);
+
+ const setSafeTLDrawAPI = (api) => {
+ if (isMountedRef.current) {
+ setTldrawAPI(api);
+ }
+ };
+
+ const toggleOffCheck = (evt) => {
+ const clickedElement = evt.target;
+ const panBtnClicked = clickedElement?.getAttribute('data-test') === 'panButton'
+ || clickedElement?.parentElement?.getAttribute('data-test') === 'panButton';
+
+ setIsToolLocked(false);
+
+ const panButton = document.querySelector('[data-test="panButton"]');
+ if (panBtnClicked) {
+ const dataZoom = panButton.getAttribute('data-zoom');
+ if ((dataZoom <= HUNDRED_PERCENT && !fitToWidth)) {
+ return;
+ }
+ panButton.classList.add('select');
+ panButton.classList.remove('selectOverride');
+ } else {
+ setIsPanning(false);
+ setPanSelected(false);
+ if (panButton) {
+ // only presenter has the pan button
+ panButton.classList.add('selectOverride');
+ panButton.classList.remove('select');
+ }
+ }
+ };
+
+ const setDockPosition = (setSetting) => {
+ if (hasWBAccess || isPresenter) {
+ if (((height < SMALLEST_DOCK_HEIGHT) || (width < SMALLEST_DOCK_WIDTH))) {
+ setSetting('dockPosition', 'bottom');
+ } else {
+ setSetting('dockPosition', isRTL ? 'left' : 'right');
+ }
+ }
+ }
+
+ React.useEffect(() => {
+ const toolbar = document.getElementById('TD-PrimaryTools');
+ const handleClick = (evt) => {
+ toggleOffCheck(evt);
+ };
+ const handleDBClick = (evt) => {
+ evt.preventDefault();
+ evt.stopPropagation();
+ setIsToolLocked(true);
+ tldrawAPI?.patchState(
+ {
+ appState: {
+ isToolLocked: true,
+ },
+ },
+ );
+ };
+ toolbar?.addEventListener('click', handleClick);
+ toolbar?.addEventListener('dblclick', handleDBClick);
+
+ return () => {
+ toolbar?.removeEventListener('click', handleClick);
+ toolbar?.removeEventListener('dblclick', handleDBClick);
+ };
+ }, [tldrawAPI, isToolLocked]);
+
+ React.useEffect(() => {
+ if (whiteboardToolbarAutoHide) {
+ toggleToolsAnimations('fade-in', 'fade-out', animations ? '3s' : '0s');
+ } else {
+ toggleToolsAnimations('fade-out', 'fade-in', animations ? '.3s' : '0s');
+ }
+ }, [whiteboardToolbarAutoHide]);
+
+ const calculateZoom = (localWidth, localHeight) => {
+ const calcedZoom = fitToWidth ? (presentationWidth / localWidth) : Math.min(
+ (presentationWidth) / localWidth,
+ (presentationHeight) / localHeight,
+ );
+
+ return (calcedZoom === 0 || calcedZoom === Infinity) ? HUNDRED_PERCENT : calcedZoom;
+ };
+
+ React.useEffect(() => {
+ setTldrawIsMounting(true);
+ }, []);
+
+ const checkClientBounds = (e) => {
+ if (
+ e.clientX > document.documentElement.clientWidth
+ || e.clientX < 0
+ || e.clientY > document.documentElement.clientHeight
+ || e.clientY < 0
+ ) {
+ if (tldrawAPI?.session) {
+ tldrawAPI?.completeSession?.();
+ }
+ }
+ isMouseDownRef.current = false;
+ };
+
+ const checkVisibility = () => {
+ if (document.visibilityState === 'hidden' && tldrawAPI?.session) {
+ tldrawAPI?.completeSession?.();
+ }
+ };
+
+ const handleWheelEvent = (event) => {
+ if ((zoomValueRef.current >= MAX_PERCENT && event.deltaY < 0)
+ || (zoomValueRef.current <= HUNDRED_PERCENT && event.deltaY > 0))
+ {
+ event.stopPropagation();
+ event.preventDefault();
+ return window.dispatchEvent(new Event('resize'));
+ }
+
+ if (!event.ctrlKey) {
+ // Prevent the event from reaching the tldraw library
+ event.stopPropagation();
+ event.preventDefault();
+ const newEvent = new WheelEvent('wheel', {
+ deltaX: event.deltaX,
+ deltaY: event.deltaY,
+ deltaZ: event.deltaZ,
+ ctrlKey: true,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ });
+ const canvas = document.getElementById('canvas');
+ if (canvas) {
+ canvas.dispatchEvent(newEvent);
+ }
+ }
+
+ window.dispatchEvent(new Event('resize'));
+ }
+
+ const setIsMouseDown = () => {
+ isMouseDownRef.current = true;
+ }
+
+ React.useEffect(() => {
+ document.addEventListener('mouseup', checkClientBounds);
+ document.addEventListener('visibilitychange', checkVisibility);
+ document.addEventListener('mousedown', setIsMouseDown);
+
+ return () => {
+ document.removeEventListener('mouseup', checkClientBounds);
+ document.removeEventListener('mousedown', setIsMouseDown);
+ document.removeEventListener('visibilitychange', checkVisibility);
+ const canvas = document.getElementById('canvas');
+ if (canvas) {
+ canvas.removeEventListener('wheel', handleWheelEvent);
+ }
+ };
+ }, [tldrawAPI]);
+
+ /* needed to prevent an issue with presentation images not loading correctly in Firefox
+ more info: https://github.com/bigbluebutton/bigbluebutton/issues/17969#issuecomment-1561758200 */
+ React.useEffect(() => {
+ if (bgShape && bgShape.parentElement && bgShape.parentElement.clientWidth > 0) {
+ bgShape.parentElement.style.width = `${bgShape.parentElement.clientWidth + .1}px`;
+ }
+ }, [bgShape]);
+
+ const doc = React.useMemo(() => {
+ const currentDoc = rDocument.current;
+
+ // update document if the number of pages has changed
+ if (currentDoc.id !== whiteboardId && currentDoc?.pages.length !== curPres?.pages.length) {
+ const currentPageShapes = currentDoc?.pages[curPageId]?.shapes;
+ currentDoc.id = whiteboardId;
+ currentDoc.pages = pages;
+ currentDoc.pages[curPageId].shapes = currentPageShapes;
+ currentDoc.pageStates = pageStates;
+ }
+
+ const next = { ...currentDoc };
+
+ let changed = false;
+
+ if (next.pageStates[curPageId] && !isEqual(prevShapes, shapes)) {
+ const editingShape = tldrawAPI?.getShape(tldrawAPI?.getPageState()?.editingId);
+
+ if (editingShape) {
+ shapes[editingShape?.id] = editingShape;
+ }
+
+ const removed = prevShapes && findRemoved(Object.keys(prevShapes), Object.keys((shapes)));
+ if (removed && removed.length > 0) {
+ const patchedShapes = Object.fromEntries(removed.map((id) => [id, undefined]));
+
+ try {
+ tldrawAPI?.patchState(
+ {
+ document: {
+ pageStates: {
+ [curPageId]: {
+ selectedIds:
+ tldrawAPI?.selectedIds?.filter((id) => !removed.includes(id)) || [],
+ },
+ },
+ pages: {
+ [curPageId]: {
+ shapes: patchedShapes,
+ },
+ },
+ },
+ },
+ );
+ } catch (error) {
+ logger.error({
+ logCode: 'whiteboard_shapes_remove_error',
+ extraInfo: { error },
+ }, 'Whiteboard catch error on removing shapes');
+ }
+ }
+
+ next.pages[curPageId].shapes = filterInvalidShapes(shapes, curPageId, tldrawAPI);
+ changed = true;
+ }
+
+ if (curPageId && (!next.assets[`slide-background-asset-${curPageId}`] || (svgUri && !isEqual(prevSvgUri, svgUri)))) {
+ next.assets[`slide-background-asset-${curPageId}`] = assets[`slide-background-asset-${curPageId}`];
+ tldrawAPI?.patchState(
+ {
+ document: {
+ assets,
+ },
+ },
+ );
+ changed = true;
+ }
+
+ if (changed && tldrawAPI) {
+ // merge patch manually (this improves performance and reduce side effects on fast updates)
+ const patch = {
+ document: {
+ pages: {
+ [curPageId]: { shapes: filterInvalidShapes(shapes, curPageId, tldrawAPI) },
+ },
+ },
+ };
+ const prevState = tldrawAPI._state;
+ const nextState = Utils.deepMerge(tldrawAPI._state, patch);
+ if (nextState.document.pages[curPageId].shapes) {
+ filterInvalidShapes(nextState.document.pages[curPageId].shapes, curPageId, tldrawAPI);
+ }
+ const final = tldrawAPI.cleanup(nextState, prevState, patch, '');
+ tldrawAPI._state = final;
+
+ try {
+ tldrawAPI?.forceUpdate();
+ } catch (error) {
+ logger.error({
+ logCode: 'whiteboard_shapes_update_error',
+ extraInfo: { error },
+ }, 'Whiteboard catch error on updating shapes');
+ }
+ }
+
+ // move poll result text to bottom right
+ if (next.pages[curPageId] && slidePosition) {
+ const pollResults = Object.entries(next.pages[curPageId].shapes)
+ .filter(([, shape]) => shape.name?.includes('poll-result'));
+ pollResults.forEach(([id, shape]) => {
+ if (isEqual(shape.point, [0, 0])) {
+ try {
+ const shapeBounds = tldrawAPI?.getShapeBounds(id);
+ if (shapeBounds) {
+ const editedShape = shape;
+ editedShape.point = [
+ slidePosition.width - shapeBounds.width,
+ slidePosition.height - shapeBounds.height,
+ ];
+ editedShape.size = [shapeBounds.width, shapeBounds.height];
+ if (isPresenter) persistShape(editedShape, whiteboardId, isModerator);
+ }
+ } catch (error) {
+ logger.error({
+ logCode: 'whiteboard_poll_results_error',
+ extraInfo: { error },
+ }, 'Whiteboard catch error on moving unpublished poll results');
+ }
+ }
+ });
+ }
+
+ return currentDoc;
+ }, [shapes, tldrawAPI, curPageId, slidePosition]);
+
+ // when presentationSizes change, update tldraw camera
+ React.useEffect(() => {
+ if (curPageId && slidePosition && tldrawAPI
+ && presentationWidth > 0 && presentationHeight > 0
+ ) {
+ if (prevFitToWidth !== null && fitToWidth !== prevFitToWidth) {
+ const newZoom = calculateZoom(slidePosition.width, slidePosition.height);
+ tldrawAPI?.setCamera([0, 0], newZoom);
+ const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
+ tldrawAPI?.viewport.height, slidePosition.height,
+ );
+ setZoom(HUNDRED_PERCENT);
+ zoomChanger(HUNDRED_PERCENT);
+ zoomSlide(parseInt(curPageId, 10), podId, HUNDRED_PERCENT, viewedRegionH, 0, 0);
+ } else {
+ const currentAspectRatio = Math.round((presentationWidth / presentationHeight) * 100) / 100;
+ const previousAspectRatio = Math.round(
+ (slidePosition.viewBoxWidth / slidePosition.viewBoxHeight) * 100,
+ ) / 100;
+ if (fitToWidth && currentAspectRatio !== previousAspectRatio) {
+ // we need this to ensure tldraw updates the viewport size after re-mounting
+ setTimeout(() => {
+ const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
+ tldrawAPI.setCamera([slidePosition.x, slidePosition.y], newZoom, 'zoomed');
+ }, 50);
+ } else {
+ const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom);
+ }
+ }
+ }
+ }, [presentationWidth, presentationHeight, curPageId, document?.documentElement?.dir]);
+
+ React.useEffect(() => {
+ if (presentationWidth > 0 && presentationHeight > 0 && slidePosition) {
+ const cameraZoom = tldrawAPI?.getPageState()?.camera?.zoom;
+ const newzoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
+ if (cameraZoom && cameraZoom === 1) {
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom);
+ } else if (isMounting) {
+ setIsMounting(false);
+ setTldrawIsMounting(false);
+ const currentAspectRatio = Math.round((presentationWidth / presentationHeight) * 100) / 100;
+ const previousAspectRatio = Math.round(
+ (slidePosition.viewBoxWidth / slidePosition.viewBoxHeight) * 100,
+ ) / 100;
+ // case where the presenter had fit-to-width enabled and he reloads the page
+ if (!fitToWidth && currentAspectRatio !== previousAspectRatio) {
+ // wee need this to ensure tldraw updates the viewport size after re-mounting
+ setTimeout(() => {
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom, 'zoomed');
+ }, 50);
+ } else {
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newzoom);
+ }
+ }
+ }
+ }, [tldrawAPI?.getPageState()?.camera, presentationWidth, presentationHeight]);
+
+ // change tldraw camera when slidePosition changes
+ React.useEffect(() => {
+ const camera = tldrawAPI?.getPageState()?.camera;
+ if (tldrawAPI && !isPresenter && curPageId && slidePosition) {
+ const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom, 'zoomed');
+ }
+
+ if (isPresenter && slidePosition && camera) {
+ const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height);
+ let zoomToolbar = Math.round(
+ ((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide) * 100,
+ ) / 100;
+ if ((zoom !== zoomToolbar) && (curPageId && curPageId !== prevPageId)) {
+ setZoom(zoomToolbar);
+ zoomChanger(zoomToolbar);
+ }
+ }
+ }, [curPageId, slidePosition]);
+
+ React.useEffect(() => {
+ if (isPresenter && slidePosition && !isMounting) {
+ const zoomFitSlide = calculateZoom(slidePosition?.width, slidePosition?.height);
+ const zoomCamera = (zoomFitSlide * zoomValue) / HUNDRED_PERCENT;
+ let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
+ tldrawAPI?.viewport?.width, slidePosition?.width,
+ );
+ let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
+ tldrawAPI?.viewport?.height, slidePosition?.height,
+ );
+
+ zoomSlide(
+ parseInt(curPageId, 10),
+ podId,
+ viewedRegionW,
+ viewedRegionH,
+ slidePosition?.x,
+ slidePosition?.y,
+ );
+ }
+ }, [curPageId]);
+
+ // update zoom according to toolbar
+ React.useEffect(() => {
+ if (tldrawAPI && isPresenter && curPageId && slidePosition && zoom !== zoomValue) {
+ const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height);
+ const zoomCamera = (zoomFitSlide * zoomValue) / HUNDRED_PERCENT;
+ setTimeout(() => {
+ tldrawAPI?.zoomTo(zoomCamera);
+ }, 50);
+ }
+ }, [zoomValue]);
+
+ // update zoom when presenter changes if the aspectRatio has changed
+ React.useEffect(() => {
+ if (tldrawAPI && isPresenter && curPageId && slidePosition && !isMounting) {
+ const currentAspectRatio = Math.round((presentationWidth / presentationHeight) * 100) / 100;
+ const previousAspectRatio = Math.round(
+ (slidePosition.viewBoxWidth / slidePosition.viewBoxHeight) * 100,
+ ) / 100;
+ if (previousAspectRatio !== currentAspectRatio) {
+ if (fitToWidth) {
+ const newZoom = calculateZoom(slidePosition.width, slidePosition.height);
+ tldrawAPI?.setCamera([0, 0], newZoom);
+ const viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
+ tldrawAPI?.viewport.height, slidePosition.height,
+ );
+ zoomSlide(parseInt(curPageId, 10), podId, HUNDRED_PERCENT, viewedRegionH, 0, 0);
+ setZoom(HUNDRED_PERCENT);
+ zoomChanger(HUNDRED_PERCENT);
+ } else if (!isMounting) {
+ let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
+ tldrawAPI?.viewport.width, slidePosition.width,
+ );
+ let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
+ tldrawAPI?.viewport.height, slidePosition.height,
+ );
+ const camera = tldrawAPI?.getPageState()?.camera;
+ const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height);
+ if (!fitToWidth && camera.zoom === zoomFitSlide) {
+ viewedRegionW = HUNDRED_PERCENT;
+ viewedRegionH = HUNDRED_PERCENT;
+ }
+ zoomSlide(
+ parseInt(curPageId, 10),
+ podId,
+ viewedRegionW,
+ viewedRegionH,
+ camera.point[0],
+ camera.point[1],
+ );
+ const zoomToolbar = Math.round(
+ ((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide) * 100,
+ ) / 100;
+ if (zoom !== zoomToolbar) {
+ setZoom(zoomToolbar);
+ zoomChanger(zoomToolbar);
+ }
+ }
+ }
+ }
+ }, [isPresenter]);
+
+ const hasWBAccess = hasMultiUserAccess(whiteboardId, currentUser.userId);
+
+ React.useEffect(() => {
+ if (tldrawAPI) {
+ tldrawAPI.isForcePanning = isPanning;
+ }
+ }, [isPanning]);
+
+ React.useEffect(() => {
+ tldrawAPI && setDockPosition(tldrawAPI?.setSetting);
+ }, [height, width]);
+
+ React.useEffect(() => {
+ tldrawAPI?.setSetting('language', language);
+ }, [language]);
+
+ // Reset zoom to default when current presentation changes.
+ React.useEffect(() => {
+ if (isPresenter && slidePosition && tldrawAPI) {
+ tldrawAPI?.zoomTo(0);
+ setHistory(null);
+ tldrawAPI?.resetHistory();
+ }
+ }, [curPres?.id]);
+
+ React.useEffect(() => {
+ const currentZoom = tldrawAPI?.getPageState()?.camera?.zoom;
+ if (currentZoom !== tldrawZoom) {
+ setTldrawZoom(currentZoom);
+ }
+ setBgShape(null);
+ }, [presentationAreaHeight, presentationAreaWidth]);
+
+ const fullscreenToggleHandler = () => {
+ const {
+ isFullscreen,
+ fullscreenAction,
+ handleToggleFullScreen,
+ } = props;
+
+ handleToggleFullScreen(fullscreenRef);
+ const newElement = isFullscreen ? '' : fullscreenElementId;
+
+ layoutContextDispatch({
+ type: fullscreenAction,
+ value: {
+ element: newElement,
+ group: '',
+ },
+ });
+ };
+
+ const nextSlideHandler = (event) => {
+ const { nextSlide, numberOfSlides } = props;
+
+ if (event) event.currentTarget.blur();
+ nextSlide(+curPageId, numberOfSlides, podId);
+ };
+
+ const previousSlideHandler = (event) => {
+ const { previousSlide } = props;
+
+ if (event) event.currentTarget.blur();
+ previousSlide(+curPageId, podId);
+ };
+
+ const handleOnKeyDown = (event) => {
+ const { which, ctrlKey } = event;
+
+ if (isMouseDownRef.current) {
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ switch (which) {
+ case KEY_CODES.ARROW_LEFT:
+ case KEY_CODES.PAGE_UP:
+ previousSlideHandler();
+ break;
+ case KEY_CODES.ARROW_RIGHT:
+ case KEY_CODES.PAGE_DOWN:
+ nextSlideHandler();
+ break;
+ case KEY_CODES.ENTER:
+ fullscreenToggleHandler();
+ break;
+ case KEY_CODES.A:
+ if (ctrlKey) {
+ event.preventDefault();
+ event.stopPropagation();
+ tldrawAPI?.selectAll();
+ }
+ break;
+ case KEY_CODES.ARROW_DOWN:
+ case KEY_CODES.ARROW_UP:
+ event.preventDefault();
+ event.stopPropagation();
+ break;
+ default:
+ }
+ };
+
+ const onMount = (app) => {
+ setDockPosition(app?.setSetting);
+ const menu = document.getElementById('TD-Styles')?.parentElement;
+ const canvas = document.getElementById('canvas');
+ if (canvas) {
+ canvas.addEventListener('wheel', handleWheelEvent, { capture: true });
+ }
+
+ if (menu) {
+ menu.style.position = 'relative';
+ menu.style.height = presentationMenuHeight;
+ menu.setAttribute('id', 'TD-Styles-Parent');
+
+ [...menu.children]
+ .sort((a, b) => (a?.id > b?.id ? -1 : 1))
+ .forEach((n) => menu.appendChild(n));
+ }
+
+ app.setSetting('language', language);
+ app?.setSetting('isDarkMode', false);
+
+ const textAlign = isRTL ? 'end' : 'start';
+
+ app?.patchState(
+ {
+ appState: {
+ currentStyle: {
+ textAlign: currentStyle?.textAlign || textAlign,
+ font: currentStyle?.font || fontFamily,
+ },
+ },
+ },
+ );
+
+ setSafeTLDrawAPI(app);
+ // disable for non presenter that doesn't have multi user access
+ if (!hasWBAccess && !isPresenter) {
+ const newApp = app;
+ newApp.onPan = () => { };
+ newApp.setSelectedIds = () => { };
+ newApp.setHoveredId = () => { };
+ }
+
+ if (history) {
+ app.replaceHistory(history);
+ }
+
+ if (curPageId) {
+ app.patchState(
+ {
+ appState: {
+ currentPageId: curPageId,
+ },
+ },
+ );
+ setIsMounting(true);
+ }
+
+ // needed to ensure the correct calculations for cursors on mount.
+ setTimeout(() => {
+ window.dispatchEvent(new Event('resize'));
+ }, MOUNTED_RESIZE_DELAY);
+ };
+
+ const onPatch = (e, t, reason) => {
+ if (!e?.pageState || !reason) return;
+
+ function updateTextShapeSize(shape, textUtil, fsm) {
+ // Create a new object for estimated shape
+ const estimatedShape = {
+ ...shape,
+ style: {
+ ...shape.style,
+ fontFamily: shape.style.fontFamily || 'Comic Sans MS, cursive, sans-serif',
+ fontSize: fsm[shape.style.size] || 16,
+ letterSpacing: LETTER_SPACING,
+ lineHeight: LINE_HEIGHT,
+ },
+ };
+ // Calculate and set size
+ const measuredBounds = textUtil.getBoundsEstimate(estimatedShape);
+ // Assign size to a new object to avoid modifying the original shape parameter
+ return { ...shape, size: [measuredBounds.width, measuredBounds.height] };
+ }
+
+ if (((isPanning || panSelected) && (reason === 'selected' || reason === 'set_hovered_id'))) {
+ e.patchState(
+ {
+ document: {
+ pageStates: {
+ [e.getPage()?.id]: {
+ selectedIds: [],
+ hoveredId: null,
+ },
+ },
+ },
+ },
+ );
+ return;
+ }
+
+ // don't allow select others shapes for editing if don't have permission
+ if (reason && reason.includes('set_editing_id')) {
+ if (!hasShapeAccess(e.pageState.editingId)) {
+ e.pageState.editingId = null;
+ }
+ }
+ // don't allow hover others shapes for editing if don't have permission
+ if (reason && reason.includes('set_hovered_id')) {
+ if (!hasShapeAccess(e.pageState.hoveredId)) {
+ e.pageState.hoveredId = null;
+ }
+ }
+ // don't allow select others shapes if don't have permission
+ if (reason && reason.includes('selected')) {
+ const validIds = [];
+ e.pageState.selectedIds.forEach((id) => hasShapeAccess(id) && validIds.push(id));
+ e.pageState.selectedIds = validIds;
+ e.patchState(
+ {
+ document: {
+ pageStates: {
+ [e.getPage()?.id]: {
+ selectedIds: validIds,
+ },
+ },
+ },
+ },
+ );
+ }
+ // don't allow selecting others shapes with ctrl (brush)
+ if (e?.session?.type === 'brush' && e?.session?.status === 'brushing') {
+ const validIds = [];
+ e.pageState.selectedIds.forEach((id) => hasShapeAccess(id) && validIds.push(id));
+ e.pageState.selectedIds = validIds;
+ if (!validIds.find((id) => id === e.pageState.hoveredId)) {
+ e.pageState.hoveredId = undefined;
+ }
+ }
+
+ // change cursor when moving shapes
+ if (e?.session?.type === 'translate' && e?.session?.status === 'translating') {
+ if (!isMoving) setIsMoving(true);
+ if (reason === 'set_status:idle') setIsMoving(false);
+ }
+
+ if (reason && isPresenter && slidePosition && (reason.includes('zoomed') || reason.includes('panned'))) {
+ const camera = tldrawAPI?.getPageState()?.camera;
+
+ // limit bounds
+ if (tldrawAPI?.viewport.maxX > slidePosition.width) {
+ camera.point[0] += (tldrawAPI?.viewport.maxX - slidePosition.width);
+ }
+ if (tldrawAPI?.viewport.maxY > slidePosition.height) {
+ camera.point[1] += (tldrawAPI?.viewport.maxY - slidePosition.height);
+ }
+ if (camera.point[0] > 0 || tldrawAPI?.viewport.minX < 0) {
+ camera.point[0] = 0;
+ }
+ if (camera.point[1] > 0 || tldrawAPI?.viewport.minY < 0) {
+ camera.point[1] = 0;
+ }
+
+ const zoomFitSlide = calculateZoom(slidePosition.width, slidePosition.height);
+ if (camera.zoom < zoomFitSlide) {
+ camera.zoom = zoomFitSlide;
+ }
+
+ const zoomToolbar = Math.round(((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide) * 100) / 100;
+ if (zoom !== zoomToolbar) {
+ setZoom(zoomToolbar);
+ if (isPresenter) zoomChanger(zoomToolbar);
+ }
+
+ let viewedRegionW = SlideCalcUtil.calcViewedRegionWidth(
+ tldrawAPI?.viewport.width, slidePosition.width,
+ );
+ let viewedRegionH = SlideCalcUtil.calcViewedRegionHeight(
+ tldrawAPI?.viewport.height, slidePosition.height,
+ );
+
+ if (!fitToWidth && camera.zoom === zoomFitSlide) {
+ viewedRegionW = HUNDRED_PERCENT;
+ viewedRegionH = HUNDRED_PERCENT;
+ camera.point = [0,0];
+ }
+
+ zoomSlide(
+ parseInt(curPageId, 10),
+ podId,
+ viewedRegionW,
+ viewedRegionH,
+ camera.point[0],
+ camera.point[1],
+ );
+ }
+ // don't allow non-presenters to pan&zoom
+ if (slidePosition && reason && !isPresenter && (reason.includes('zoomed') || reason.includes('panned'))) {
+ const newZoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
+ tldrawAPI?.setCamera([slidePosition.x, slidePosition.y], newZoom);
+ }
+ // disable select for non presenter that doesn't have multi user access
+ if (!hasWBAccess && !isPresenter) {
+ if (e?.getPageState()?.brush || e?.selectedIds?.length !== 0) {
+ e.patchState(
+ {
+ document: {
+ pageStates: {
+ [e?.currentPageId]: {
+ selectedIds: [],
+ brush: null,
+ },
+ },
+ },
+ },
+ );
+ }
+ }
+
+ if (reason && reason === 'patched_shapes' && e?.session?.type === 'edit') {
+ const patchedShape = e?.getShape(e?.getPageState()?.editingId);
+
+ if (e?.session?.initialShape?.type === 'sticky' && patchedShape?.text?.length > maxStickyNoteLength) {
+ patchedShape.text = patchedShape.text.substring(0, maxStickyNoteLength);
+ }
+
+ if (e?.session?.initialShape?.type === 'text' && !shapes[patchedShape.id]) {
+ // Check for maxShapes
+ const currentShapes = e?.document?.pages[e?.currentPageId]?.shapes;
+ const shapeNumberExceeded = Object.keys(currentShapes).length - 1 > maxNumberOfAnnotations;
+ if (shapeNumberExceeded) {
+ notifyShapeNumberExceeded(intl, maxNumberOfAnnotations);
+ e?.cancelSession?.();
+ } else {
+ const updatedShape = { ...patchedShape, userId: currentUser?.userId };
+ const newShapeWithSize = updateTextShapeSize(updatedShape, TextUtil, fontSizeMap);
+ persistShape(newShapeWithSize, whiteboardId, isModerator);
+ }
+ } else {
+ const diff = {
+ id: patchedShape.id,
+ point: patchedShape.point,
+ text: patchedShape.text,
+ };
+
+ if (patchedShape.type === 'text') {
+ const updatedShape = updateTextShapeSize(patchedShape, TextUtil, fontSizeMap);
+ diff.size = updatedShape.size;
+ }
+ persistShape(diff, whiteboardId, isModerator);
+ }
+ }
+
+ if (reason && reason.includes('selected_tool')) {
+ const tool = reason.split(':')[1];
+ setCurrentTool(tool);
+ setPanSelected(false);
+ setIsPanning(false);
+ }
+
+ if (reason && reason.includes('ui:toggled_is_loading')) {
+ e?.patchState(
+ {
+ appState: {
+ currentStyle,
+ },
+ },
+ );
+ }
+
+ e?.patchState(
+ {
+ appState: {
+ isToolLocked,
+ },
+ },
+ );
+
+ if ((panSelected || isPanning)) {
+ e.isForcePanning = isPanning;
+ }
+ };
+
+ const onUndo = (app) => {
+ if (app.currentPageId !== curPageId) {
+ if (isPresenter) {
+ // change slide for others
+ skipToSlide(Number.parseInt(app.currentPageId, 10), podId);
+ } else {
+ // ignore, stay on same page
+ app.changePage(curPageId);
+ }
+ return;
+ }
+ const lastCommand = app.stack[app.pointer + 1];
+ const changedShapes = lastCommand?.before?.document?.pages[app.currentPageId]?.shapes;
+ if (changedShapes) {
+ sendShapeChanges(
+ app, changedShapes, shapes, prevShapes, hasShapeAccess,
+ whiteboardId, currentUser, intl, true,
+ );
+ }
+ };
+
+ const onRedo = (app) => {
+ if (app.currentPageId !== curPageId) {
+ if (isPresenter) {
+ // change slide for others
+ skipToSlide(Number.parseInt(app.currentPageId, 10), podId);
+ } else {
+ // ignore, stay on same page
+ app.changePage(curPageId);
+ }
+ return;
+ }
+ const lastCommand = app.stack[app.pointer];
+ const changedShapes = lastCommand?.after?.document?.pages[app.currentPageId]?.shapes;
+ if (changedShapes) {
+ sendShapeChanges(
+ app, changedShapes, shapes, prevShapes, hasShapeAccess, whiteboardId, currentUser, intl,
+ );
+ }
+ };
+
+ const onCommand = (app, command) => {
+ const isFirstCommand = command.id === "change_page" && command.before?.appState.currentPageId === "0";
+ if (!isFirstCommand){
+ setHistory(app.history);
+ }
+
+ if (whiteboardToolbarAutoHide && command && command.id === "change_page") {
+ toggleToolsAnimations('fade-in', 'fade-out', '0s');
+ }
+
+ if (command?.id?.includes('style')) {
+ setCurrentStyle({ ...currentStyle, ...command?.after?.appState?.currentStyle });
+ }
+
+ const changedShapes = command.after?.document?.pages[app.currentPageId]?.shapes;
+ if (!isMounting && app.currentPageId !== curPageId) {
+ // can happen then the "move to page action" is called, or using undo after changing a page
+ const currentPage = curPres.pages.find(
+ (page) => page.num === Number.parseInt(app.currentPageId, 10),
+ );
+ if (!currentPage) return;
+ const newWhiteboardId = currentPage.id;
+ // remove from previous page and persist on new
+ if (changedShapes) {
+ removeShapes(Object.keys(changedShapes), whiteboardId);
+ Object.entries(changedShapes)
+ .forEach(([id, shape]) => {
+ const shapeBounds = app.getShapeBounds(id);
+ const editedShape = shape;
+ editedShape.size = [shapeBounds.width, shapeBounds.height];
+ persistShape(editedShape, newWhiteboardId, isModerator);
+ });
+ }
+ if (isPresenter) {
+ // change slide for others
+ skipToSlide(Number.parseInt(app.currentPageId, 10), podId);
+ } else {
+ // ignore, stay on same page
+ app.changePage(curPageId);
+ }
+ } else if (changedShapes) {
+ sendShapeChanges(
+ app, changedShapes, shapes, prevShapes, hasShapeAccess, whiteboardId, currentUser, intl,
+ );
+ }
+ };
+
+ const webcams = document.getElementById('cameraDock');
+ const dockPos = webcams?.getAttribute('data-position');
+ const backgroundShape = document.getElementById('slide-background-shape_image');
+
+ if (currentTool && !isPanning && !tldrawAPI?.isForcePanning) tldrawAPI?.selectTool(currentTool);
+
+ if (backgroundShape && backgroundShape.src // if there is a background image
+ && backgroundShape.complete // and it's fully downloaded
+ && backgroundShape.src !== bgShape?.src // and if it's a different image
+ && backgroundShape.parentElement?.clientWidth > 0 // and if the whiteboard area is visible
+ ) {
+ setBgShape(backgroundShape);
+ }
+ const editableWB = (
+
+
+
+ );
+
+ const readOnlyWB = (
+
+ );
+
+ const size = ((height < SMALL_HEIGHT) || (width < SMALL_WIDTH))
+ ? TOOLBAR_SMALL : TOOLBAR_LARGE;
+
+ const menuOffsetValues = {
+ true: {
+ true: `${styleMenuOffsetSmall}`,
+ false: `${styleMenuOffset}`,
+ },
+ false: {
+ true: `-${styleMenuOffsetSmall}`,
+ false: `-${styleMenuOffset}`,
+ },
+ };
+
+ const menuOffset = menuOffsetValues[isRTL][isIphone];
+
+ return (
+
+
+ {(hasWBAccess || isPresenter) ? editableWB : readOnlyWB}
+
+
+ {isPresenter && (
+
+ )}
+
+ );
+}
+
+Whiteboard.propTypes = {
+ isPresenter: PropTypes.bool.isRequired,
+ isIphone: PropTypes.bool.isRequired,
+ removeShapes: PropTypes.func.isRequired,
+ initDefaultPages: PropTypes.func.isRequired,
+ persistShape: PropTypes.func.isRequired,
+ notifyNotAllowedChange: PropTypes.func.isRequired,
+ shapes: PropTypes.objectOf(PropTypes.shape).isRequired,
+ assets: PropTypes.objectOf(PropTypes.shape).isRequired,
+ currentUser: PropTypes.shape({
+ userId: PropTypes.string.isRequired,
+ }).isRequired,
+ curPres: PropTypes.shape({
+ pages: PropTypes.arrayOf(PropTypes.shape({})),
+ id: PropTypes.string.isRequired,
+ }),
+ whiteboardId: PropTypes.string,
+ podId: PropTypes.string.isRequired,
+ zoomSlide: PropTypes.func.isRequired,
+ skipToSlide: PropTypes.func.isRequired,
+ slidePosition: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ viewBoxWidth: PropTypes.number.isRequired,
+ viewBoxHeight: PropTypes.number.isRequired,
+ }),
+ curPageId: PropTypes.string.isRequired,
+ presentationWidth: PropTypes.number.isRequired,
+ presentationHeight: PropTypes.number.isRequired,
+ isViewersCursorLocked: PropTypes.bool.isRequired,
+ zoomChanger: PropTypes.func.isRequired,
+ isMultiUserActive: PropTypes.func.isRequired,
+ isRTL: PropTypes.bool.isRequired,
+ fitToWidth: PropTypes.bool.isRequired,
+ zoomValue: PropTypes.number.isRequired,
+ intl: PropTypes.shape({
+ formatMessage: PropTypes.func.isRequired,
+ }).isRequired,
+ svgUri: PropTypes.string,
+ maxStickyNoteLength: PropTypes.number.isRequired,
+ fontFamily: PropTypes.string.isRequired,
+ hasShapeAccess: PropTypes.func.isRequired,
+ presentationAreaHeight: PropTypes.number.isRequired,
+ presentationAreaWidth: PropTypes.number.isRequired,
+ maxNumberOfAnnotations: PropTypes.number.isRequired,
+ notifyShapeNumberExceeded: PropTypes.func.isRequired,
+ darkTheme: PropTypes.bool.isRequired,
+ isPanning: PropTypes.bool.isRequired,
+ setTldrawIsMounting: PropTypes.func.isRequired,
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ hasMultiUserAccess: PropTypes.func.isRequired,
+ fullscreenElementId: PropTypes.string.isRequired,
+ isFullscreen: PropTypes.bool.isRequired,
+ layoutContextDispatch: PropTypes.func.isRequired,
+ fullscreenAction: PropTypes.string.isRequired,
+ fullscreenRef: PropTypes.instanceOf(Element),
+ handleToggleFullScreen: PropTypes.func.isRequired,
+ nextSlide: PropTypes.func.isRequired,
+ numberOfSlides: PropTypes.number.isRequired,
+ previousSlide: PropTypes.func.isRequired,
+ sidebarNavigationWidth: PropTypes.number,
+};
+
+Whiteboard.defaultProps = {
+ curPres: undefined,
+ fullscreenRef: undefined,
+ slidePosition: undefined,
+ svgUri: undefined,
+ whiteboardId: undefined,
+ sidebarNavigationWidth: 0,
+};
diff --git a/src/2.7.12/imports/ui/components/whiteboard/container.jsx b/src/2.7.12/imports/ui/components/whiteboard/container.jsx
new file mode 100644
index 00000000..8d282c8d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/container.jsx
@@ -0,0 +1,170 @@
+import { withTracker } from 'meteor/react-meteor-data';
+import PropTypes from 'prop-types';
+import React, { useContext } from 'react';
+import { ColorStyle, DashStyle, SizeStyle, TDShapeType } from '@tldraw/tldraw';
+import SettingsService from '/imports/ui/services/settings';
+import {
+ getShapes,
+ getCurrentPres,
+ initDefaultPages,
+ persistShape,
+ removeShapes,
+ isMultiUserActive,
+ hasMultiUserAccess,
+ changeCurrentSlide,
+ notifyNotAllowedChange,
+ notifyShapeNumberExceeded,
+ toggleToolsAnimations,
+} from './service';
+import Whiteboard from './component';
+import { UsersContext } from '../components-data/users-context/context';
+import Auth from '/imports/ui/services/auth';
+import PresentationToolbarService from '../presentation/presentation-toolbar/service';
+import {
+ layoutSelect,
+ layoutDispatch,
+} from '/imports/ui/components/layout/context';
+import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
+import deviceInfo from '/imports/utils/deviceInfo';
+
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard;
+
+const WhiteboardContainer = (props) => {
+ const usingUsersContext = useContext(UsersContext);
+ const isRTL = layoutSelect((i) => i.isRTL);
+ const width = layoutSelect((i) => i?.output?.presentation?.width);
+ const height = layoutSelect((i) => i?.output?.presentation?.height);
+ const sidebarNavigationWidth = layoutSelect(
+ (i) => i?.output?.sidebarNavigation?.width
+ );
+ const { users } = usingUsersContext;
+ const currentUser = users[Auth.meetingID][Auth.userID];
+ const isPresenter = currentUser.presenter;
+ const isModerator = currentUser.role === ROLE_MODERATOR;
+ const { maxStickyNoteLength, maxNumberOfAnnotations } = WHITEBOARD_CONFIG;
+ const fontFamily = WHITEBOARD_CONFIG.styles.text.family;
+ const handleToggleFullScreen = (ref) =>
+ FullscreenService.toggleFullScreen(ref);
+ const layoutContextDispatch = layoutDispatch();
+
+ const { shapes } = props;
+ const hasShapeAccess = (id) => {
+ const owner = shapes[id]?.userId;
+ const isBackgroundShape = id?.includes('slide-background');
+ const isPollsResult = shapes[id]?.name?.includes('poll-result');
+ const hasAccess =
+ (!isBackgroundShape && !isPollsResult) ||
+ (isPresenter &&
+ ((owner && owner === currentUser?.userId) ||
+ !owner ||
+ isPresenter ||
+ isModerator));
+
+ return hasAccess;
+ };
+ // set shapes as locked for those who aren't allowed to edit it
+ Object.entries(shapes).forEach(([shapeId, shape]) => {
+ if (!shape.isLocked && !hasShapeAccess(shapeId) && !shape.name?.includes('poll-result')) {
+ const modShape = shape;
+ modShape.isLocked = true;
+ }
+ });
+
+ return (
+
+ );
+};
+
+export default withTracker(
+ ({
+ whiteboardId,
+ curPageId,
+ intl,
+ slidePosition,
+ svgUri,
+ podId,
+ presentationId,
+ darkTheme,
+ isViewersAnnotationsLocked,
+ }) => {
+ const shapes = getShapes(whiteboardId, curPageId, intl, isViewersAnnotationsLocked);
+ const curPres = getCurrentPres();
+ const { isIphone } = deviceInfo;
+
+ shapes['slide-background-shape'] = {
+ assetId: `slide-background-asset-${curPageId}`,
+ childIndex: -1,
+ id: 'slide-background-shape',
+ name: 'Image',
+ type: TDShapeType.Image,
+ parentId: `${curPageId}`,
+ point: [0, 0],
+ isLocked: true,
+ size: [slidePosition?.width || 0, slidePosition?.height || 0],
+ style: {
+ dash: DashStyle.Draw,
+ size: SizeStyle.Medium,
+ color: ColorStyle.Blue,
+ },
+ };
+
+ const assets = {};
+ assets[`slide-background-asset-${curPageId}`] = {
+ id: `slide-background-asset-${curPageId}`,
+ size: [slidePosition?.width || 0, slidePosition?.height || 0],
+ src: svgUri,
+ type: 'image',
+ };
+
+ return {
+ initDefaultPages,
+ persistShape,
+ isMultiUserActive,
+ hasMultiUserAccess,
+ changeCurrentSlide,
+ shapes,
+ assets,
+ curPres,
+ removeShapes,
+ zoomSlide: PresentationToolbarService.zoomSlide,
+ skipToSlide: PresentationToolbarService.skipToSlide,
+ nextSlide: PresentationToolbarService.nextSlide,
+ previousSlide: PresentationToolbarService.previousSlide,
+ numberOfSlides: PresentationToolbarService.getNumberOfSlides(
+ podId,
+ presentationId
+ ),
+ notifyNotAllowedChange,
+ notifyShapeNumberExceeded,
+ darkTheme,
+ whiteboardToolbarAutoHide:
+ SettingsService?.application?.whiteboardToolbarAutoHide,
+ animations: SettingsService?.application?.animations,
+ toggleToolsAnimations,
+ isIphone,
+ };
+ }
+)(WhiteboardContainer);
+
+WhiteboardContainer.propTypes = {
+ shapes: PropTypes.objectOf(PropTypes.shape).isRequired,
+};
diff --git a/src/2.7.12/imports/ui/components/whiteboard/cursors/component.jsx b/src/2.7.12/imports/ui/components/whiteboard/cursors/component.jsx
new file mode 100644
index 00000000..6433b939
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/cursors/component.jsx
@@ -0,0 +1,349 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { Meteor } from 'meteor/meteor';
+import Cursor from './cursor/component';
+import PositionLabel from './position-label/component';
+
+const XS_OFFSET = 8;
+const SMALL_OFFSET = 18;
+const XL_OFFSET = 85;
+const BOTTOM_CAM_HANDLE_HEIGHT = 10;
+const PRES_TOOLBAR_HEIGHT = 35;
+
+const baseName = Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename;
+const makeCursorUrl = (filename) => `${baseName}/resources/images/whiteboard-cursor/${filename}`;
+
+const TOOL_CURSORS = {
+ select: 'default',
+ erase: 'crosshair',
+ arrow: 'crosshair',
+ draw: `url('${makeCursorUrl('pencil.png')}') 2 22, default`,
+ rectangle: `url('${makeCursorUrl('square.png')}'), default`,
+ ellipse: `url('${makeCursorUrl('ellipse.png')}'), default`,
+ triangle: `url('${makeCursorUrl('triangle.png')}'), default`,
+ line: `url('${makeCursorUrl('line.png')}'), default`,
+ text: `url('${makeCursorUrl('text.png')}'), default`,
+ sticky: `url('${makeCursorUrl('square.png')}'), default`,
+ pan: 'grab',
+ grabbing: 'grabbing',
+ moving: 'move',
+};
+const Cursors = (props) => {
+ const cursorWrapper = React.useRef();
+ const [active, setActive] = React.useState(false);
+ const [pos, setPos] = React.useState({ x: 0, y: 0 });
+ const {
+ whiteboardId,
+ otherCursors,
+ currentUser,
+ currentPoint,
+ tldrawCamera,
+ publishCursorUpdate,
+ children,
+ isViewersCursorLocked,
+ hasMultiUserAccess,
+ isMultiUserActive,
+ isPanning,
+ isMoving,
+ currentTool,
+ toggleToolsAnimations,
+ whiteboardToolbarAutoHide,
+ application,
+ } = props;
+
+ const [panGrabbing, setPanGrabbing] = React.useState(false);
+
+ const start = (event) => {
+ const targetElement = event?.target;
+ const className = targetElement instanceof SVGElement
+ ? targetElement?.className?.baseVal
+ : targetElement?.className;
+ const hasTlPartial = className?.includes('tl-');
+ if (hasTlPartial) {
+ event?.preventDefault();
+ }
+ if (whiteboardToolbarAutoHide) toggleToolsAnimations('fade-out', 'fade-in', application?.animations ? '.3s' : '0s');
+ setActive(true);
+ };
+ const handleGrabbing = () => setPanGrabbing(true);
+ const handleReleaseGrab = () => setPanGrabbing(false);
+
+ const multiUserAccess = hasMultiUserAccess(whiteboardId, currentUser?.userId);
+ const end = () => {
+ if (whiteboardId && (multiUserAccess || currentUser?.presenter)) {
+ publishCursorUpdate({
+ xPercent: -1.0,
+ yPercent: -1.0,
+ whiteboardId,
+ });
+ }
+ if (whiteboardToolbarAutoHide) toggleToolsAnimations('fade-in', 'fade-out', application?.animations ? '3s' : '0s');
+ setActive(false);
+ };
+
+ const moved = (event) => {
+ const { type, x, y } = event;
+ const nav = document.getElementById('Navbar');
+ const getSibling = (el) => {
+ if (el?.previousSibling && !el?.previousSibling?.hasAttribute('data-test')) {
+ return el?.previousSibling;
+ }
+ return null;
+ };
+ const panel = getSibling(nav);
+ const webcams = document.getElementById('cameraDock');
+ const subPanel = panel && getSibling(panel);
+ const camPosition = document.getElementById('layout')?.getAttribute('data-cam-position') || null;
+ const sl = document.getElementById('layout')?.getAttribute('data-layout');
+ const presentationContainer = document.querySelector('[data-test="presentationContainer"]');
+ const presentation = document.getElementById('currentSlideText')?.parentElement;
+ const banners = document.querySelectorAll('[data-test="notificationBannerBar"]');
+ let yOffset = 0;
+ let xOffset = 0;
+ const calcPresOffset = () => {
+ yOffset
+ += (parseFloat(presentationContainer?.style?.height)
+ - (parseFloat(presentation?.style?.height)
+ + (currentUser.presenter ? PRES_TOOLBAR_HEIGHT : 0))
+ ) / 2;
+ xOffset
+ += (parseFloat(presentationContainer?.style?.width)
+ - parseFloat(presentation?.style?.width)
+ ) / 2;
+ };
+ // If the presentation container is the full screen element we don't
+ // need any offsets
+ const { webkitFullscreenElement, fullscreenElement } = document;
+ const fsEl = webkitFullscreenElement || fullscreenElement;
+ if (fsEl?.getAttribute('data-test') === 'presentationContainer') {
+ calcPresOffset();
+ return setPos({ x: x - xOffset, y: y - yOffset });
+ }
+ if (nav) yOffset += parseFloat(nav?.style?.height);
+ if (panel) xOffset += parseFloat(panel?.style?.width);
+ if (subPanel) xOffset += parseFloat(subPanel?.style?.width);
+
+ // offset native tldraw eraser animation container
+ const overlay = document.getElementsByClassName('tl-overlay')[0];
+ if (overlay) overlay.style.left = '0px';
+
+ if (type === 'touchmove') {
+ calcPresOffset();
+ if (!active) {
+ setActive(true);
+ }
+ const newX = event?.changedTouches[0]?.clientX - xOffset;
+ const newY = event?.changedTouches[0]?.clientY - yOffset;
+ return setPos({ x: newX, y: newY });
+ }
+
+ if (document?.documentElement?.dir === 'rtl') {
+ xOffset = 0;
+ if (presentationContainer && presentation) {
+ calcPresOffset();
+ }
+ if (sl.includes('custom')) {
+ if (webcams) {
+ if (camPosition === 'contentTop' || !camPosition) {
+ yOffset += (parseFloat(webcams?.style?.height || 0) + BOTTOM_CAM_HANDLE_HEIGHT);
+ }
+ if (camPosition === 'contentBottom') {
+ yOffset -= BOTTOM_CAM_HANDLE_HEIGHT;
+ }
+ if (camPosition === 'contentRight') {
+ xOffset += (parseFloat(webcams?.style?.width || 0) + SMALL_OFFSET);
+ }
+ }
+ }
+ if (sl?.includes('smart')) {
+ if (panel || subPanel) {
+ const dockPos = webcams?.getAttribute('data-position');
+ if (dockPos === 'contentTop') {
+ yOffset += (parseFloat(webcams?.style?.height || 0) + SMALL_OFFSET);
+ }
+ }
+ }
+ if (webcams && sl?.includes('videoFocus')) {
+ xOffset += parseFloat(nav?.style?.width);
+ yOffset += (parseFloat(panel?.style?.height || 0) - XL_OFFSET);
+ }
+ } else {
+ if (sl.includes('custom')) {
+ if (webcams) {
+ if (camPosition === 'contentTop' || !camPosition) {
+ yOffset += (parseFloat(webcams?.style?.height) || 0) + XS_OFFSET;
+ }
+ if (camPosition === 'contentBottom') {
+ yOffset -= BOTTOM_CAM_HANDLE_HEIGHT;
+ }
+ if (camPosition === 'contentLeft') {
+ xOffset += (parseFloat(webcams?.style?.width) || 0) + SMALL_OFFSET;
+ }
+ }
+ }
+
+ if (sl.includes('smart')) {
+ if (panel || subPanel) {
+ const dockPos = webcams?.getAttribute('data-position');
+ if (dockPos === 'contentLeft') {
+ xOffset += (parseFloat(webcams?.style?.width || 0) + SMALL_OFFSET);
+ }
+ if (dockPos === 'contentTop') {
+ yOffset += (parseFloat(webcams?.style?.height || 0) + SMALL_OFFSET);
+ }
+ }
+ if (!panel && !subPanel) {
+ if (webcams) {
+ xOffset = parseFloat(webcams?.style?.width || 0) + SMALL_OFFSET;
+ }
+ }
+ }
+ if (sl?.includes('videoFocus')) {
+ if (webcams) {
+ xOffset = parseFloat(subPanel?.style?.width);
+ yOffset = parseFloat(panel?.style?.height);
+ }
+ }
+ if (presentationContainer && presentation) {
+ calcPresOffset();
+ }
+ }
+
+ if (banners) {
+ banners.forEach((el) => {
+ yOffset += parseFloat(window.getComputedStyle(el).height);
+ });
+ }
+
+ return setPos({ x: event.x - xOffset, y: event.y - yOffset });
+ };
+
+ React.useEffect(() => {
+ const currentCursor = cursorWrapper?.current;
+ currentCursor?.addEventListener('mouseenter', start);
+ currentCursor?.addEventListener('touchstart', start);
+ currentCursor?.addEventListener('mouseleave', end);
+ currentCursor?.addEventListener('mousedown', handleGrabbing);
+ currentCursor?.addEventListener('mouseup', handleReleaseGrab);
+ currentCursor?.addEventListener('touchend', end);
+ currentCursor?.addEventListener('mousemove', moved);
+ currentCursor?.addEventListener('touchmove', moved);
+
+ return () => {
+ currentCursor?.removeEventListener('mouseenter', start);
+ currentCursor?.addEventListener('touchstart', start);
+ currentCursor?.removeEventListener('mouseleave', end);
+ currentCursor?.removeEventListener('mousedown', handleGrabbing);
+ currentCursor?.removeEventListener('mouseup', handleReleaseGrab);
+ currentCursor?.removeEventListener('touchend', end);
+ currentCursor?.removeEventListener('mousemove', moved);
+ currentCursor?.removeEventListener('touchmove', moved);
+ };
+ }, [cursorWrapper, whiteboardId, currentUser.presenter, whiteboardToolbarAutoHide]);
+
+ let cursorType = multiUserAccess || currentUser?.presenter ? TOOL_CURSORS[currentTool] : 'default';
+
+ if (isPanning) {
+ if (panGrabbing) {
+ cursorType = TOOL_CURSORS.grabbing;
+ } else {
+ cursorType = TOOL_CURSORS.pan;
+ }
+ }
+ if (isMoving) cursorType = TOOL_CURSORS.moving;
+ return (
+
+
+ {((active && multiUserAccess) || (active && currentUser?.presenter)) && (
+
+ )}
+ {children}
+
+ {otherCursors
+ .filter((c) => c?.xPercent && c.xPercent !== -1.0 && c?.yPercent && c.yPercent !== -1.0)
+ .filter((c) => {
+ if ((isViewersCursorLocked && c?.role !== 'VIEWER')
+ || !isViewersCursorLocked
+ || currentUser?.presenter
+ ) {
+ return c;
+ }
+ return null;
+ })
+ .map((c) => {
+ if (c && currentUser.userId !== c?.userId) {
+ if (c.presenter) {
+ return (
+
+ );
+ }
+
+ return hasMultiUserAccess(whiteboardId, c?.userId)
+ && (
+
+ );
+ }
+ return null;
+ })}
+
+ );
+};
+
+Cursors.propTypes = {
+ whiteboardId: PropTypes.string,
+ otherCursors: PropTypes.arrayOf(PropTypes.shape).isRequired,
+ currentUser: PropTypes.shape({
+ userId: PropTypes.string.isRequired,
+ presenter: PropTypes.bool.isRequired,
+ }).isRequired,
+ currentPoint: PropTypes.arrayOf(PropTypes.number),
+ tldrawCamera: PropTypes.shape({
+ point: PropTypes.arrayOf(PropTypes.number).isRequired,
+ zoom: PropTypes.number.isRequired,
+ }),
+ publishCursorUpdate: PropTypes.func.isRequired,
+ children: PropTypes.arrayOf(PropTypes.element).isRequired,
+ isViewersCursorLocked: PropTypes.bool.isRequired,
+ hasMultiUserAccess: PropTypes.func.isRequired,
+ isMultiUserActive: PropTypes.func.isRequired,
+ isPanning: PropTypes.bool.isRequired,
+ isMoving: PropTypes.bool.isRequired,
+ currentTool: PropTypes.string,
+ toggleToolsAnimations: PropTypes.func.isRequired,
+};
+
+Cursors.defaultProps = {
+ whiteboardId: undefined,
+ currentPoint: undefined,
+ tldrawCamera: undefined,
+ currentTool: null,
+};
+
+export default Cursors;
diff --git a/src/2.7.12/imports/ui/components/whiteboard/cursors/container.jsx b/src/2.7.12/imports/ui/components/whiteboard/cursors/container.jsx
new file mode 100644
index 00000000..3cfe2dcf
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/cursors/container.jsx
@@ -0,0 +1,22 @@
+import { withTracker } from 'meteor/react-meteor-data';
+import React from 'react';
+import SettingsService from '/imports/ui/services/settings';
+import Cursors from './component';
+import Service from './service';
+import { omit } from 'radash';
+
+const CursorsContainer = (props) => ;
+
+export default withTracker((params) => (
+ {
+ application: SettingsService?.application,
+ currentUser: params.currentUser,
+ publishCursorUpdate: Service.publishCursorUpdate,
+ otherCursors: Service.getCurrentCursors(params.whiteboardId),
+ currentPoint: params.tldrawAPI?.currentPoint,
+ tldrawCamera: params.tldrawAPI?.getPageState().camera,
+ isViewersCursorLocked: params.isViewersCursorLocked,
+ hasMultiUserAccess: params.hasMultiUserAccess,
+ isMultiUserActive: params.isMultiUserActive,
+ }
+))(CursorsContainer);
diff --git a/src/2.7.12/imports/ui/components/whiteboard/cursors/cursor/component.jsx b/src/2.7.12/imports/ui/components/whiteboard/cursors/cursor/component.jsx
new file mode 100644
index 00000000..7db9283d
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/cursors/cursor/component.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Meteor } from 'meteor/meteor';
+
+const { pointerDiameter } = Meteor.settings.public.whiteboard;
+
+const Cursor = (props) => {
+ const {
+ name,
+ color,
+ x,
+ y,
+ currentPoint,
+ tldrawCamera,
+ isMultiUserActive,
+ owner = false,
+ } = props;
+
+ const z = !owner ? 2 : 1;
+ let _x = null;
+ let _y = null;
+
+ if (!currentPoint) {
+ _x = (x + tldrawCamera?.point[0]) * tldrawCamera?.zoom;
+ _y = (y + tldrawCamera?.point[1]) * tldrawCamera?.zoom;
+ }
+
+ const transitionStyle = owner ? { transition: 'left 0.3s ease-out, top 0.3s ease-out' } : {};
+
+ return (
+ <>
+
+
+ {isMultiUserActive && (
+
+ {name}
+
+ )}
+ >
+ );
+};
+
+Cursor.propTypes = {
+ name: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ currentPoint: PropTypes.arrayOf(PropTypes.number),
+ tldrawCamera: PropTypes.shape({
+ point: PropTypes.arrayOf(PropTypes.number).isRequired,
+ zoom: PropTypes.number.isRequired,
+ }),
+ isMultiUserActive: PropTypes.bool.isRequired,
+ owner: PropTypes.bool,
+};
+Cursor.defaultProps = {
+ owner: false,
+ currentPoint: undefined,
+ tldrawCamera: undefined,
+};
+
+export default Cursor;
diff --git a/src/2.7.12/imports/ui/components/whiteboard/cursors/position-label/component.jsx b/src/2.7.12/imports/ui/components/whiteboard/cursors/position-label/component.jsx
new file mode 100644
index 00000000..64982238
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/cursors/position-label/component.jsx
@@ -0,0 +1,93 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import logger from '/imports/startup/client/logger';
+import Cursor from '../cursor/component';
+
+const PositionLabel = (props) => {
+ const {
+ currentUser,
+ currentPoint,
+ tldrawCamera,
+ publishCursorUpdate,
+ whiteboardId,
+ pos,
+ isMultiUserActive,
+ } = props;
+
+ const { name, color, userId } = currentUser;
+ const { x, y } = pos;
+ const { zoom, point: tldrawPoint } = tldrawCamera;
+
+ React.useEffect(() => {
+ try {
+ const point = [x, y];
+ publishCursorUpdate({
+ xPercent:
+ point[0] / zoom - tldrawPoint[0],
+ yPercent:
+ point[1] / zoom - tldrawPoint[1],
+ whiteboardId,
+ });
+ } catch (error) {
+ logger.error({
+ logCode: 'cursor_update__error',
+ extraInfo: { error },
+ }, 'Whiteboard catch error on cursor update');
+ }
+ }, [x, y, zoom, tldrawPoint]);
+
+ // eslint-disable-next-line arrow-body-style
+ React.useEffect(() => {
+ return () => {
+ // Disable cursor on unmount
+ publishCursorUpdate({
+ xPercent: -1.0,
+ yPercent: -1.0,
+ whiteboardId,
+ });
+ };
+ }, []);
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+PositionLabel.propTypes = {
+ currentUser: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ color: PropTypes.string.isRequired,
+ userId: PropTypes.string.isRequired,
+ }).isRequired,
+ currentPoint: PropTypes.arrayOf(PropTypes.number).isRequired,
+ tldrawCamera: PropTypes.shape({
+ point: PropTypes.arrayOf(PropTypes.number).isRequired,
+ zoom: PropTypes.number.isRequired,
+ }).isRequired,
+ publishCursorUpdate: PropTypes.func.isRequired,
+ whiteboardId: PropTypes.string,
+ pos: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ }).isRequired,
+ isMultiUserActive: PropTypes.func.isRequired,
+};
+
+PositionLabel.defaultProps = {
+ whiteboardId: undefined,
+};
+
+export default PositionLabel;
diff --git a/src/2.7.12/imports/ui/components/whiteboard/cursors/service.js b/src/2.7.12/imports/ui/components/whiteboard/cursors/service.js
new file mode 100644
index 00000000..a96267bf
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/cursors/service.js
@@ -0,0 +1,108 @@
+import Auth from '/imports/ui/services/auth';
+import Users from '/imports/api/users';
+import { throttle } from '/imports/utils/throttle';
+import logger from '/imports/startup/client/logger';
+
+const { cursorInterval: CURSOR_INTERVAL } = Meteor.settings.public.whiteboard;
+
+const Cursor = new Mongo.Collection(null);
+let cursorStreamListener = null;
+
+export const clearCursors = () => {
+ Cursor.remove({});
+};
+
+const updateCursor = (userId, payload) => {
+ const selector = {
+ userId,
+ whiteboardId: payload.whiteboardId,
+ };
+
+ const modifier = {
+ $set: {
+ userId,
+ ...payload,
+ },
+ };
+
+ return Cursor.upsert(selector, modifier);
+};
+
+const publishCursorUpdate = throttle((payload) => {
+ if (cursorStreamListener) {
+ cursorStreamListener.emit.bind(cursorStreamListener)('publish', payload);
+ }
+
+ return updateCursor(Auth.userID, payload);
+}, CURSOR_INTERVAL);
+
+export const initCursorStreamListener = () => {
+ logger.info({
+ logCode: 'init_cursor_stream_listener',
+ extraInfo: { meetingId: Auth.meetingID, userId: Auth.userID },
+ }, 'initCursorStreamListener called');
+
+ /**
+ * We create a promise to add the handlers after a ddp subscription stop.
+ * The problem was caused because we add handlers to stream before the onStop event happens,
+ * which set the handlers to undefined.
+ */
+ cursorStreamListener = new Meteor.Streamer(`cursor-${Auth.meetingID}`, { retransmit: false });
+
+ const startStreamHandlersPromise = new Promise((resolve) => {
+ const checkStreamHandlersInterval = setInterval(() => {
+ const streamHandlersSize = Object.values(Meteor.StreamerCentral.instances[`cursor-${Auth.meetingID}`].handlers)
+ .filter((el) => el !== undefined)
+ .length;
+
+ if (!streamHandlersSize) {
+ resolve(clearInterval(checkStreamHandlersInterval));
+ }
+ }, 250);
+ });
+
+ startStreamHandlersPromise.then(() => {
+ logger.debug({ logCode: 'cursor_stream_handler_attach' }, 'Attaching handlers for cursor stream');
+
+ cursorStreamListener.on('message', ({ cursors }) => {
+ Object.keys(cursors).forEach((cursorId) => {
+ const cursor = cursors[cursorId];
+ const { userId } = cursor;
+ delete cursor.userId;
+ if (Auth.userID === userId) return;
+ updateCursor(userId, cursor);
+ });
+ });
+ });
+};
+
+const getCurrentCursors = (whiteboardId) => {
+ const selector = { whiteboardId };
+ const filter = {};
+ const cursors = Cursor.find(selector, filter).fetch();
+ return cursors.reduce((result, cursor) => {
+ const { userId } = cursor;
+ const user = Users.findOne(
+ { userId },
+ {
+ fields: {
+ name: 1, presenter: 1, userId: 1, role: 1,
+ },
+ },
+ );
+ if (user) {
+ const newCursor = cursor;
+ newCursor.userName = user.name;
+ newCursor.userId = user.userId;
+ newCursor.role = user.role;
+ newCursor.presenter = user.presenter;
+ result.push(newCursor);
+ }
+ return result;
+ }, []);
+};
+
+export default {
+ getCurrentCursors,
+ publishCursorUpdate,
+};
diff --git a/src/2.7.12/imports/ui/components/whiteboard/pan-tool-injector/component.jsx b/src/2.7.12/imports/ui/components/whiteboard/pan-tool-injector/component.jsx
new file mode 100644
index 00000000..dbd66d17
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/pan-tool-injector/component.jsx
@@ -0,0 +1,143 @@
+import * as React from 'react';
+import { createRoot } from 'react-dom/client';
+import PropTypes from 'prop-types';
+import { HUNDRED_PERCENT } from '/imports/utils/slideCalcUtils';
+import Styled from '../styles';
+
+const DEFAULT_TOOL_COUNT = 9;
+
+class PanToolInjector extends React.Component {
+ componentDidMount() {
+ this.addPanTool();
+ }
+
+ componentDidUpdate(prevProps) {
+ const {
+ zoomValue,
+ fitToWidth,
+ isPanning,
+ setIsPanning,
+ tldrawAPI,
+ panSelected,
+ setPanSelected,
+ } = this.props;
+ if (prevProps.zoomValue !== zoomValue
+ || prevProps.fitToWidth !== fitToWidth
+ || prevProps.isPanning !== isPanning
+ || prevProps.tldrawAPI !== tldrawAPI
+ || prevProps.panSelected !== panSelected
+ ) {
+ this.addPanTool();
+ if (panSelected) {
+ setIsPanning(true);
+ setPanSelected(true);
+ } else {
+ setIsPanning(false);
+ setPanSelected(false);
+ }
+ }
+ }
+
+ addPanTool() {
+ const {
+ zoomValue,
+ fitToWidth,
+ setIsPanning,
+ formatMessage,
+ tldrawAPI,
+ panSelected,
+ setPanSelected,
+ } = this.props;
+
+ if (panSelected) {
+ tldrawAPI?.selectTool('select');
+ }
+
+ const tools = document.querySelectorAll('[id*="TD-PrimaryTools-"]');
+ tools.forEach((tool) => {
+ const { classList } = tool.firstElementChild;
+ if (panSelected) {
+ classList.add('overrideSelect');
+ } else {
+ classList.remove('overrideSelect');
+ }
+ });
+
+ const parentElement = document.getElementById('TD-PrimaryTools');
+ if (!parentElement) return;
+
+ if (parentElement.childElementCount === DEFAULT_TOOL_COUNT) {
+ parentElement.removeChild(parentElement.children[1]);
+ }
+
+ if (parentElement.childElementCount < DEFAULT_TOOL_COUNT) {
+ const label = formatMessage({
+ id: 'app.whiteboard.toolbar.tools.hand',
+ description: 'presentation toolbar pan label',
+ });
+ const container = document.createElement('span');
+ parentElement.appendChild(container);
+
+ const root = createRoot(container);
+
+ root.render(
+ {
+ setPanSelected(true);
+ setIsPanning(true);
+ if (!(zoomValue <= HUNDRED_PERCENT && !fitToWidth)) {
+ const panButton = document.querySelector('[data-test="panButton"]');
+ if (panButton) {
+ panButton.classList.remove('selectOverride');
+ panButton.classList.add('select');
+ }
+ }
+ }}
+ hideLabel
+ {...{
+ panSelected,
+ }}
+ />,
+ );
+
+ const { lastChild } = parentElement;
+ const secondChild = parentElement.children[1];
+ parentElement.insertBefore(lastChild, secondChild);
+ }
+ }
+
+ render() {
+ return null;
+ }
+}
+
+PanToolInjector.propTypes = {
+ fitToWidth: PropTypes.bool.isRequired,
+ zoomValue: PropTypes.number.isRequired,
+ formatMessage: PropTypes.func.isRequired,
+ isPanning: PropTypes.bool.isRequired,
+ setIsPanning: PropTypes.func.isRequired,
+ tldrawAPI: PropTypes.shape({
+ selectTool: PropTypes.func.isRequired,
+ }),
+ panSelected: PropTypes.bool.isRequired,
+ setPanSelected: PropTypes.func.isRequired,
+};
+
+PanToolInjector.defaultProps = {
+ tldrawAPI: null,
+};
+
+export default PanToolInjector;
diff --git a/src/2.7.12/imports/ui/components/whiteboard/service.js b/src/2.7.12/imports/ui/components/whiteboard/service.js
new file mode 100644
index 00000000..461f4720
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/service.js
@@ -0,0 +1,429 @@
+import Users from '/imports/api/users';
+import Auth from '/imports/ui/services/auth';
+import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user';
+import addAnnotationQuery from '/imports/api/annotations/addAnnotation';
+import { Slides } from '/imports/api/slides';
+import { makeCall } from '/imports/ui/services/api';
+import PresentationService from '/imports/ui/components/presentation/service';
+import PollService from '/imports/ui/components/poll/service';
+import logger from '/imports/startup/client/logger';
+import { defineMessages } from 'react-intl';
+import { notify } from '/imports/ui/services/notification';
+import caseInsensitiveReducer from '/imports/utils/caseInsensitiveReducer';
+import { getTextSize } from './utils';
+
+const Annotations = new Mongo.Collection(null);
+
+const intlMessages = defineMessages({
+ notifyNotAllowedChange: {
+ id: 'app.whiteboard.annotations.notAllowed',
+ description: 'Label shown in toast when the user make a change on a shape he doesnt have permission',
+ },
+ shapeNumberExceeded: {
+ id: 'app.whiteboard.annotations.numberExceeded',
+ description: 'Label shown in toast when the user tries to add more shapes than the limit',
+ },
+});
+
+let annotationsStreamListener = null;
+
+async function handleAddedAnnotation({
+ meetingId,
+ whiteboardId,
+ userId,
+ annotation,
+}) {
+ const query = await addAnnotationQuery(meetingId, whiteboardId, userId, annotation, Annotations);
+
+ Annotations.upsert(query.selector, query.modifier);
+}
+
+function handleRemovedAnnotation({
+ meetingId, whiteboardId, userId, shapeId,
+}) {
+ const query = { meetingId, whiteboardId };
+
+ if (userId) {
+ query.userId = userId;
+ }
+
+ if (shapeId) {
+ query.id = shapeId;
+ }
+ Annotations.remove(query);
+}
+
+export function initAnnotationsStreamListener() {
+ logger.info(
+ { logCode: 'init_annotations_stream_listener' },
+ 'initAnnotationsStreamListener called',
+ );
+ /**
+ * We create a promise to add the handlers after a ddp subscription stop.
+ * The problem was caused because we add handlers to stream before the onStop event happens,
+ * which set the handlers to undefined.
+ */
+ annotationsStreamListener = new Meteor.Streamer(
+ `annotations-${Auth.meetingID}`,
+ { retransmit: false },
+ );
+
+ const startStreamHandlersPromise = new Promise((resolve) => {
+ const checkStreamHandlersInterval = setInterval(() => {
+ const streamHandlersSize = Object.values(
+ Meteor.StreamerCentral.instances[`annotations-${Auth.meetingID}`]
+ .handlers,
+ ).filter((el) => el !== undefined).length;
+
+ if (!streamHandlersSize) {
+ resolve(clearInterval(checkStreamHandlersInterval));
+ }
+ }, 250);
+ });
+
+ startStreamHandlersPromise.then(() => {
+ logger.debug(
+ { logCode: 'annotations_stream_handler_attach' },
+ 'Attaching handlers for annotations stream',
+ );
+
+ annotationsStreamListener.on('removed', handleRemovedAnnotation);
+
+ annotationsStreamListener.on('added', async ({ annotations }) => {
+ await Promise.all(annotations.map(async (annotation) => {
+ const addedHandeler = await handleAddedAnnotation(annotation);
+ return addedHandeler;
+ }));
+ });
+ });
+}
+
+const annotationsQueue = [];
+// How many packets we need to have to use annotationsBufferTimeMax
+const annotationsMaxDelayQueueSize = 60;
+// Minimum bufferTime
+const annotationsBufferTimeMin = 30;
+// Maximum bufferTime
+const annotationsBufferTimeMax = 200;
+// Time before running 'sendBulkAnnotations' again if user is offline
+const annotationsRetryDelay = 1000;
+
+let annotationsSenderIsRunning = false;
+
+const proccessAnnotationsQueue = async () => {
+ annotationsSenderIsRunning = true;
+ const queueSize = annotationsQueue.length;
+
+ if (!queueSize) {
+ annotationsSenderIsRunning = false;
+ return;
+ }
+
+ const annotations = annotationsQueue.splice(0, queueSize);
+
+ const isAnnotationSent = await makeCall('sendBulkAnnotations', annotations);
+
+ if (!isAnnotationSent) {
+ // undo splice
+ annotationsQueue.splice(0, 0, ...annotations);
+ setTimeout(proccessAnnotationsQueue, annotationsRetryDelay);
+ } else {
+ // ask tiago
+ const delayPerc = Math.min(
+ annotationsMaxDelayQueueSize, queueSize,
+ ) / annotationsMaxDelayQueueSize;
+ const delayDelta = annotationsBufferTimeMax - annotationsBufferTimeMin;
+ const delayTime = annotationsBufferTimeMin + delayDelta * delayPerc;
+ setTimeout(proccessAnnotationsQueue, delayTime);
+ }
+};
+
+const sendAnnotation = (annotation) => {
+ // Prevent sending annotations while disconnected
+ // TODO: Change this to add the annotation, but delay the send until we're
+ // reconnected. With this it will miss things
+ if (!Meteor.status().connected) return;
+
+ const index = annotationsQueue.findIndex((ann) => ann.id === annotation.id);
+ if (index !== -1) {
+ annotationsQueue[index] = annotation;
+ } else {
+ annotationsQueue.push(annotation);
+ }
+ if (!annotationsSenderIsRunning) setTimeout(proccessAnnotationsQueue, annotationsBufferTimeMin);
+};
+
+const getMultiUser = (whiteboardId) => {
+ const data = WhiteboardMultiUser.findOne(
+ {
+ meetingId: Auth.meetingID,
+ whiteboardId,
+ },
+ { fields: { multiUser: 1 } },
+ );
+
+ if (!data || !data.multiUser || !Array.isArray(data.multiUser)) return [];
+
+ return data.multiUser;
+};
+
+const getMultiUserSize = (whiteboardId) => {
+ const multiUser = getMultiUser(whiteboardId);
+
+ if (multiUser.length === 0) return 0;
+
+ // Individual whiteboard access is controlled by an array of userIds.
+ // When an user leaves the meeting or the presenter role moves from an
+ // user to another we applying a filter at the whiteboard collection.
+ // Ideally this should change to something more cohese but this would
+ // require extra changes at multiple backend modules.
+ const multiUserSize = Users.find(
+ {
+ meetingId: Auth.meetingID,
+ $or: [
+ {
+ userId: { $in: multiUser },
+ presenter: false,
+ },
+ { presenter: true },
+ ],
+ },
+ { fields: { userId: 1 } },
+ ).fetch();
+
+ return multiUserSize.length;
+};
+
+const getCurrentWhiteboardId = () => {
+ const podId = 'DEFAULT_PRESENTATION_POD';
+ const currentPresentation = PresentationService.getCurrentPresentation(podId);
+
+ if (!currentPresentation) return null;
+
+ const currentSlide = Slides.findOne(
+ {
+ podId,
+ presentationId: currentPresentation.id,
+ current: true,
+ },
+ { fields: { id: 1 } },
+ );
+
+ return currentSlide && currentSlide.id;
+};
+
+const hasAnnotations = (presentationId) => {
+ const ann = Annotations.findOne(
+ { whiteboardId: { $regex: `^${presentationId}` } },
+ );
+ return ann !== undefined;
+};
+
+const isMultiUserActive = (whiteboardId) => {
+ const multiUser = getMultiUser(whiteboardId);
+
+ return multiUser.length !== 0;
+};
+
+const hasMultiUserAccess = (whiteboardId, userId) => {
+ const multiUser = getMultiUser(whiteboardId);
+
+ return multiUser.includes(userId);
+};
+
+const addGlobalAccess = (whiteboardId) => {
+ makeCall('addGlobalAccess', whiteboardId);
+};
+
+const addIndividualAccess = (whiteboardId, userId) => {
+ makeCall('addIndividualAccess', whiteboardId, userId);
+};
+
+const removeGlobalAccess = (whiteboardId) => {
+ makeCall('removeGlobalAccess', whiteboardId);
+};
+
+const removeIndividualAccess = (whiteboardId, userId) => {
+ makeCall('removeIndividualAccess', whiteboardId, userId);
+};
+
+const changeWhiteboardAccess = (userId, access) => {
+ const whiteboardId = getCurrentWhiteboardId();
+
+ if (!whiteboardId) return;
+
+ if (access) {
+ addIndividualAccess(whiteboardId, userId);
+ } else {
+ removeIndividualAccess(whiteboardId, userId);
+ }
+};
+
+const persistShape = (shape, whiteboardId, isModerator) => {
+ const annotation = {
+ id: shape.id,
+ annotationInfo: { ...shape, isModerator },
+ wbId: whiteboardId,
+ userId: Auth.userID,
+ };
+
+ sendAnnotation(annotation);
+};
+
+const removeShapes = (shapes, whiteboardId) => makeCall('deleteAnnotations', shapes, whiteboardId);
+
+const changeCurrentSlide = (s) => {
+ makeCall('changeCurrentSlide', s);
+};
+
+const getShapes = (whiteboardId, curPageId, intl, isLocked) => {
+ const unlockedSelector = { whiteboardId };
+ const lockedSelector = {
+ whiteboardId,
+ $or: [
+ { 'annotationInfo.isModerator': true },
+ { 'annotationInfo.userId': Auth.userID },
+ ],
+ };
+
+ const annotations = Annotations.find(
+ isLocked ? lockedSelector : unlockedSelector,
+ {
+ fields: { annotationInfo: 1, userId: 1 },
+ },
+ ).fetch();
+
+ const result = {};
+
+ annotations.forEach((annotation) => {
+ if (annotation.annotationInfo.questionType) {
+ const modAnnotation = annotation;
+ // poll result, convert it to text and create tldraw shape
+ modAnnotation.annotationInfo.answers = annotation.annotationInfo.answers.reduce(
+ caseInsensitiveReducer, [],
+ );
+ let pollResult = PollService.getPollResultString(annotation.annotationInfo, intl)
+ .split(' ').join('\n').replace(/(<([^>]+)>)/ig, '');
+
+ const lines = pollResult.split('\n');
+ const longestLine = lines.reduce((a, b) => a.length > b.length ? a : b, '').length;
+
+ // add empty spaces before first | in each of the lines to make them all the same length
+ pollResult = lines.map((line) => {
+ if (!line.includes('|') || line.length === longestLine) return line;
+
+ const splitLine = line.split(' |');
+ const spaces = ' '.repeat(longestLine - line.length);
+ return `${splitLine[0]} ${spaces}|${splitLine[1]}`;
+ }).join('\n');
+
+ const style = {
+ color: 'white',
+ dash: 'solid',
+ font: 'mono',
+ isFilled: true,
+ size: 'small',
+ scale: 1,
+ };
+
+ const textSize = getTextSize(pollResult, style, padding = 20);
+
+ modAnnotation.annotationInfo = {
+ childIndex: 0,
+ id: annotation.annotationInfo.id,
+ name: `poll-result-${annotation.annotationInfo.id}`,
+ type: 'rectangle',
+ label: pollResult,
+ labelPoint: [0.5, 0.5],
+ parentId: `${curPageId}`,
+ point: [0, 0],
+ size: textSize,
+ style,
+ };
+ modAnnotation.annotationInfo.questionType = false;
+ }
+ result[annotation.annotationInfo.id] = annotation.annotationInfo;
+ });
+ return result;
+};
+
+const getCurrentPres = () => {
+ const podId = 'DEFAULT_PRESENTATION_POD';
+ return PresentationService.getCurrentPresentation(podId);
+};
+
+const initDefaultPages = (count = 1) => {
+ const pages = {};
+ const pageStates = {};
+ let i = 0;
+ while (i < count + 1) {
+ pages[`${i}`] = {
+ id: `${i}`,
+ name: `Slide ${i}`,
+ shapes: {},
+ bindings: {},
+ };
+ pageStates[`${i}`] = {
+ id: `${i}`,
+ selectedIds: [],
+ camera: {
+ point: [0, 0],
+ zoom: 1,
+ },
+ };
+ i += 1;
+ }
+ return { pages, pageStates };
+};
+
+const notifyNotAllowedChange = (intl) => {
+ if (intl) notify(intl.formatMessage(intlMessages.notifyNotAllowedChange), 'warning', 'whiteboard');
+};
+
+const notifyShapeNumberExceeded = (intl, limit) => {
+ if (intl) notify(intl.formatMessage(intlMessages.shapeNumberExceeded, { 0: limit }), 'warning', 'whiteboard');
+};
+
+const toggleToolsAnimations = (activeAnim, anim, time) => {
+ const tdTools = document.querySelector('#TD-Tools');
+ const topToolbar = document.getElementById('TD-Styles')?.parentElement;
+ const optionsDropdown = document.getElementById('WhiteboardOptionButton');
+ if (tdTools && topToolbar) {
+ tdTools.classList.remove(activeAnim);
+ topToolbar.classList.remove(activeAnim);
+ topToolbar.style.transition = `opacity ${time} ease-in-out`;
+ tdTools.style.transition = `opacity ${time} ease-in-out`;
+ tdTools?.classList?.add(anim);
+ topToolbar?.classList?.add(anim);
+ }
+ if (optionsDropdown) {
+ optionsDropdown.classList.remove(activeAnim);
+ optionsDropdown.style.transition = `opacity ${time} ease-in-out`;
+ optionsDropdown?.classList?.add(anim);
+ }
+}
+
+export {
+ initDefaultPages,
+ Annotations,
+ sendAnnotation,
+ getMultiUser,
+ getMultiUserSize,
+ getCurrentWhiteboardId,
+ isMultiUserActive,
+ hasMultiUserAccess,
+ changeWhiteboardAccess,
+ addGlobalAccess,
+ addIndividualAccess,
+ removeGlobalAccess,
+ removeIndividualAccess,
+ persistShape,
+ getShapes,
+ getCurrentPres,
+ removeShapes,
+ changeCurrentSlide,
+ notifyNotAllowedChange,
+ notifyShapeNumberExceeded,
+ hasAnnotations,
+ toggleToolsAnimations,
+};
diff --git a/src/2.7.12/imports/ui/components/whiteboard/styles.js b/src/2.7.12/imports/ui/components/whiteboard/styles.js
new file mode 100644
index 00000000..a0043ec4
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/styles.js
@@ -0,0 +1,151 @@
+import styled, { createGlobalStyle } from 'styled-components';
+import { borderSize, borderSizeLarge } from '/imports/ui/stylesheets/styled-components/general';
+import { toolbarButtonColor, colorWhite, colorBlack } from '/imports/ui/stylesheets/styled-components/palette';
+import {
+ fontSizeLarger,
+} from '/imports/ui/stylesheets/styled-components/typography';
+import Button from '/imports/ui/components/common/button/component';
+
+const TldrawGlobalStyle = createGlobalStyle`
+ ${({ hideContextMenu }) => hideContextMenu && `
+ #TD-ContextMenu {
+ display: none;
+ }
+ `}
+ ${({ menuOffset }) => `
+ #TD-StylesMenu {
+ position: relative;
+ right: ${menuOffset};
+ }
+ `}
+ #TD-PrimaryTools-Image {
+ display: none;
+ }
+ #slide-background-shape div {
+ pointer-events: none;
+ user-select: none;
+ }
+ div[dir*="ltr"]:has(button[aria-expanded*="false"][aria-controls*="radix-"]) {
+ pointer-events: none;
+ }
+ [aria-expanded*="false"][aria-controls*="radix-"] {
+ display: none;
+ }
+ [class$="-side-right"] {
+ top: -1px;
+ }
+ ${({ hasWBAccess, isPresenter, size }) => (hasWBAccess || isPresenter) && `
+ #TD-Tools-Dots {
+ height: ${size}px;
+ width: ${size}px;
+ }
+ #TD-Delete {
+ & button {
+ height: ${size}px;
+ width: ${size}px;
+ }
+ }
+ #TD-PrimaryTools button {
+ height: ${size}px;
+ width: ${size}px;
+ }
+ #TD-Styles {
+ border-width: ${borderSize};
+ }
+ #TD-TopPanel-Undo,
+ #TD-TopPanel-Redo,
+ #TD-Styles {
+ height: 92%;
+ border-radius: 7px;
+
+ &:hover {
+ border: solid ${borderSize} #ECECEC;
+ background-color: #ECECEC;
+ }
+ &:focus {
+ border: solid ${borderSize} ${colorBlack};
+ }
+ }
+ #TD-Styles,
+ #TD-TopPanel-Undo,
+ #TD-TopPanel-Redo {
+ margin: ${borderSize} ${borderSizeLarge} 0px ${borderSizeLarge};
+ }
+ `}
+ ${({ hasWBAccess, isPresenter, panSelected }) => (hasWBAccess || isPresenter) && panSelected && `
+ [id^="TD-PrimaryTools-"] {
+ &:hover > div,
+ &:focus > div {
+ background-color: var(--colors-hover) !important;
+ }
+ }
+ `}
+ ${({ darkTheme }) => darkTheme && `
+ #TD-TopPanel-Undo,
+ #TD-TopPanel-Redo,
+ #TD-Styles {
+ &:focus {
+ border: solid ${borderSize} ${colorWhite} !important;
+ }
+ }
+ [id="TD-Styles-Color-Container"],
+ [id="TD-StylesMenu"] {
+ filter: invert(0%) hue-rotate(180deg) contrast(100%) !important;
+ }
+ `}
+ ${({ isPresenter, hasWBAccess }) => (!isPresenter && !hasWBAccess) && `
+ #presentationInnerWrapper div{
+ cursor: default !important;
+ }
+ `}
+
+ ${({ isToolbarVisible }) => (!isToolbarVisible) && `
+ #TD-Tools {
+ visibility: hidden;
+ }
+ #TD-Styles-Parent {
+ visibility: hidden;
+ }
+ #WhiteboardOptionButton {
+ opacity: 0.2;
+ }
+ `}
+`;
+
+const EditableWBWrapper = styled.div`
+ &, & > :first-child {
+ cursor: inherit !important;
+ }
+`;
+
+const PanTool = styled(Button)`
+ border: none !important;
+ padding: 0;
+ margin: 0;
+ border-radius: 7px;
+ background-color: ${colorWhite};
+ color: ${toolbarButtonColor};
+
+ & > i {
+ font-size: ${fontSizeLarger} !important;
+ [dir="rtl"] & {
+ -webkit-transform: scale(-1, 1);
+ -moz-transform: scale(-1, 1);
+ -ms-transform: scale(-1, 1);
+ -o-transform: scale(-1, 1);
+ transform: scale(-1, 1);
+ }
+ }
+ ${({ panSelected }) => !panSelected && `
+ &:hover,
+ &:focus {
+ background-color: var(--colors-hover) !important;
+ }
+ `}
+`;
+
+export default {
+ TldrawGlobalStyle,
+ EditableWBWrapper,
+ PanTool,
+};
diff --git a/src/2.7.12/imports/ui/components/whiteboard/utils.js b/src/2.7.12/imports/ui/components/whiteboard/utils.js
new file mode 100644
index 00000000..5b21d7ba
--- /dev/null
+++ b/src/2.7.12/imports/ui/components/whiteboard/utils.js
@@ -0,0 +1,388 @@
+import React from 'react';
+import { isEqual } from 'radash';
+import {
+ persistShape,
+ removeShapes,
+ notifyNotAllowedChange,
+ notifyShapeNumberExceeded,
+} from './service';
+
+const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard;
+const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
+
+const usePrevious = (value) => {
+ const ref = React.useRef();
+ React.useEffect(() => {
+ ref.current = value;
+ }, [value]);
+ return ref.current;
+};
+
+const findRemoved = (A, B) => A.filter((a) => !B.includes(a));
+
+const filterInvalidShapes = (shapes, curPageId, tldrawAPI) => {
+ const retShapes = shapes;
+ const keys = Object.keys(shapes);
+ const removedChildren = [];
+ const removedParents = [];
+
+ keys.forEach((shape) => {
+ if (shapes[shape].parentId !== curPageId) {
+ if (!keys.includes(shapes[shape].parentId)) {
+ delete retShapes[shape];
+ }
+ } else if (shapes[shape].type === 'group') {
+ const groupChildren = shapes[shape].children;
+
+ groupChildren.forEach((child) => {
+ if (!keys.includes(child)) {
+ removedChildren.push(child);
+ }
+ });
+ retShapes[shape].children = groupChildren.filter((child) => !removedChildren.includes(child));
+
+ if (shapes[shape].children.length < 2) {
+ removedParents.push(shape);
+ delete retShapes[shape];
+ }
+ }
+ });
+ // remove orphaned children
+ Object.keys(shapes).forEach((shape) => {
+ if (shapes[shape] && shapes[shape].parentId !== curPageId) {
+ if (removedParents.includes(shapes[shape].parentId)) {
+ delete retShapes[shape];
+ }
+ }
+
+ // remove orphaned bindings
+ if (shapes[shape] && shapes[shape].type === 'arrow'
+ && (shapes[shape].handles.start.bindingId || shapes[shape].handles.end.bindingId)) {
+ const startBinding = shapes[shape].handles.start.bindingId;
+ const endBinding = shapes[shape].handles.end.bindingId;
+
+ const startBindingData = tldrawAPI?.getBinding(startBinding);
+ const endBindingData = tldrawAPI?.getBinding(endBinding);
+
+ if (startBinding && (!startBindingData && (
+ removedParents.includes(startBindingData?.fromId)
+ || removedParents.includes(startBindingData?.toId)
+ || !keys.includes(startBindingData?.fromId)
+ || !keys.includes(startBindingData?.toId)
+ ))) {
+ delete retShapes[shape].handles.start.bindingId;
+ }
+ if (endBinding && (!endBindingData && (
+ removedParents.includes(endBindingData?.fromId)
+ || removedParents.includes(endBindingData?.toId)
+ || !keys.includes(endBindingData?.fromId)
+ || !keys.includes(endBindingData?.toId)
+ ))) {
+ delete retShapes[shape].handles.end.bindingId;
+ }
+ }
+ });
+ return retShapes;
+};
+
+const isValidShapeType = (shape) => {
+ const invalidTypes = ['image', 'video'];
+ return !invalidTypes.includes(shape?.type);
+};
+
+const sendShapeChanges = (
+ app,
+ changedShapes,
+ shapes,
+ prevShapes,
+ hasShapeAccess,
+ whiteboardId,
+ currentUser,
+ intl,
+ redo = false,
+) => {
+ let isModerator = currentUser?.role === ROLE_MODERATOR;
+
+ const invalidChange = Object.keys(changedShapes)
+ .find((id) => !hasShapeAccess(id));
+
+ const invalidShapeType = Object.keys(changedShapes)
+ .find((id) => !isValidShapeType(changedShapes[id]));
+
+ const currentShapes = app?.document?.pages[app?.currentPageId]?.shapes;
+ const { maxNumberOfAnnotations } = WHITEBOARD_CONFIG;
+ // -1 for background shape
+ const shapeNumberExceeded = Object.keys(currentShapes).length - 1 > maxNumberOfAnnotations;
+
+ const isInserting = Object.keys(changedShapes)
+ .filter(
+ (shape) => typeof changedShapes[shape] === 'object'
+ && changedShapes[shape].type
+ && !prevShapes[shape],
+ ).length !== 0;
+
+ if (invalidChange || invalidShapeType || (shapeNumberExceeded && isInserting)) {
+ if (shapeNumberExceeded) {
+ notifyShapeNumberExceeded(intl, maxNumberOfAnnotations);
+ } else {
+ notifyNotAllowedChange(intl);
+ }
+ const modApp = app;
+ // undo last command without persisting to not generate the onUndo/onRedo callback
+ if (!redo) {
+ const command = app.stack[app.pointer];
+ modApp.pointer -= 1;
+ app.applyPatch(command.before, 'undo');
+ return;
+ // eslint-disable-next-line no-else-return
+ } else {
+ modApp.pointer += 1;
+ const command = app.stack[app.pointer];
+ app.applyPatch(command.after, 'redo');
+ return;
+ }
+ }
+ const deletedShapes = [];
+ Object.entries(changedShapes)
+ .forEach(([id, shape]) => {
+ if (!shape) deletedShapes.push(id);
+ else {
+ // checks to find any bindings assosiated with the changed shapes.
+ // If any, they may need to be updated as well.
+ const pageBindings = app.page.bindings;
+ if (pageBindings) {
+ Object.entries(pageBindings).forEach(([, b]) => {
+ if (b.toId.includes(id)) {
+ const boundShape = app.getShape(b.fromId);
+ if (shapes[b.fromId] && !isEqual(boundShape, shapes[b.fromId])) {
+ const shapeBounds = app.getShapeBounds(b.fromId);
+ boundShape.size = [shapeBounds.width, shapeBounds.height];
+ persistShape(boundShape, whiteboardId, isModerator);
+ }
+ }
+ });
+ }
+ let modShape = shape;
+ if (!shape.id) {
+ // check it already exists (otherwise we need the full shape)
+ if (!shapes[id]) {
+ modShape = app.getShape(id);
+ }
+ modShape.id = id;
+ }
+ const shapeBounds = app.getShapeBounds(id);
+ const size = [shapeBounds.width, shapeBounds.height];
+ if (!shapes[id] || (shapes[id] && !isEqual(shapes[id].size, size))) {
+ modShape.size = size;
+ }
+ if (!shapes[id] || (shapes[id] && !shapes[id].userId)) {
+ modShape.userId = currentUser?.userId;
+ }
+ // do not change moderator status for existing shapes
+ if (shapes[id]) {
+ isModerator = shapes[id].isModerator;
+ }
+ persistShape(modShape, whiteboardId, isModerator);
+ }
+ });
+
+ // order the ids of shapes being deleted to prevent crash
+ // when removing a group shape before its children
+ const orderedDeletedShapes = [];
+ deletedShapes.forEach((eid) => {
+ if (shapes[eid]?.type !== 'group') {
+ orderedDeletedShapes.unshift(eid);
+ } else {
+ orderedDeletedShapes.push(eid);
+ }
+ });
+
+ if (orderedDeletedShapes.length > 0) {
+ removeShapes(orderedDeletedShapes, whiteboardId);
+ }
+};
+
+// map different localeCodes from bbb to tldraw
+const mapLanguage = (language) => {
+ // bbb has xx-xx but in tldraw it's only xx
+ if (['es', 'fa', 'it', 'pl', 'sv', 'uk'].some((lang) => language.startsWith(lang))) {
+ return language.substring(0, 2);
+ }
+ // exceptions
+ switch (language) {
+ case 'nb-no':
+ return 'no';
+ case 'zh-cn':
+ return 'zh-ch';
+ default:
+ return language;
+ }
+};
+
+/* getFontStyle adapted from tldraw source code
+ https://github.com/tldraw/tldraw/blob/55a8831a6b036faae0dfd77d6733a8f585f5ae23/packages/tldraw/src/state/shapes/shared/shape-styles.ts#L123 */
+const getFontStyle = (style) => {
+ const fontSizes = {
+ small: 28,
+ medium: 48,
+ large: 96,
+ auto: 'auto',
+ };
+
+ const fontFaces = {
+ script: '"Caveat Brush"',
+ sans: '"Source Sans Pro"',
+ serif: '"Crimson Pro"',
+ mono: '"Source Code Pro"',
+ }
+
+ const fontSize = fontSizes[style.size];
+ const fontFace = fontFaces[style.font];
+ const { scale = 1 } = style;
+
+ return `${fontSize * scale}px/1 ${fontFace}`;
+}
+
+/* getMeasurementDiv and getTextSize adapted from tldraw source code
+ https://github.com/tldraw/tldraw/blob/55a8831a6b036faae0dfd77d6733a8f585f5ae23/packages/tldraw/src/state/shapes/shared/getTextSize.ts */
+const getMeasurementDiv = (font) => {
+ // A div used for measurement
+ const pre = document.getElementById('text-measure');
+ pre.style.font = font;
+
+ return pre;
+}
+
+const getTextSize = (text, style, padding) => {
+ const font = getFontStyle(style);
+
+ if (!text) {
+ return [16, 32];
+ }
+
+ const melm = getMeasurementDiv(font);
+ melm.textContent = text;
+
+ if (!melm) {
+ // We're in SSR
+ return [10, 10];
+ }
+
+ // In tests, offsetWidth and offsetHeight will be 0
+ const width = melm.offsetWidth || 1;
+ const height = melm.offsetHeight || 1;
+
+ return [width + padding, height + padding];
+};
+
+
+// getBoundsEstimate here is a function inspired by implementations in tldraw-v1:
+// TextUtil: https://github.com/tldraw/tldraw-v1/blob/f786c38ac0fdce337c4405c11cbfa9223d2ee6dd/packages/tldraw/src/state/shapes/TextUtil/TextUtil.tsx#L24
+// getTextSize: https://github.com/tldraw/tldraw-v1/blob/f786c38ac0fdce337c4405c11cbfa9223d2ee6dd/packages/tldraw/src/state/shapes/shared/getTextSize.ts#L6
+// It calculates the estimated bounds of a text shape based on its content and style.
+const TextUtil = {
+ getBoundsEstimate(shape) {
+ const { text, style } = shape;
+ const div = document.createElement('div');
+ div.style.position = 'absolute';
+ div.style.visibility = 'hidden';
+ div.style.whiteSpace = 'pre';
+ div.style.fontSize = this.getFontSize(style.size);
+ div.style.fontFamily = this.getFontFamily(style.font);
+ div.style.letterSpacing = style.letterSpacing || 'normal';
+ div.style.lineHeight = style.lineHeight || 'normal';
+ div.textContent = text;
+ document.body.appendChild(div);
+
+ // Calculate the number of lines
+ const numberOfLines = text.split('\n').length;
+
+ // Get the bounding box dimensions
+ const { width } = div.getBoundingClientRect();
+ const lineHeight = parseFloat(getComputedStyle(div).lineHeight);
+ const height = lineHeight * numberOfLines;
+ document.body.removeChild(div);
+
+ // Dynamic padding adjustment based on font size
+ const paddingAdjustment = parseInt(div.style.fontSize, 10) * 0.3;
+ let finalWidth = width + paddingAdjustment;
+ let finalHeight = height + paddingAdjustment;
+
+ // Apply width and height multipliers based on font size and style
+ finalWidth *= this.getWidthMultiplier(style.size, style.font);
+ finalHeight *= this.getHeightMultiplier(style.size, style.font, numberOfLines);
+
+ return {
+ width: finalWidth,
+ height: finalHeight,
+ };
+ },
+
+ getFontSize(size) {
+ switch (size) {
+ case 'small': return '12px';
+ case 'medium': return '16px';
+ case 'large': return '20px';
+ default: return '16px';
+ }
+ },
+
+ getFontFamily(font) {
+ switch (font) {
+ case 'mono': return 'monospace';
+ case 'script': return 'Comic Sans MS, cursive, sans-serif';
+ case 'sans': return 'Arial, sans-serif';
+ case 'serif': return 'Times New Roman, serif';
+ default: return 'sans-serif';
+ }
+ },
+
+ getWidthMultiplier(size, font) {
+ const fontMultiplier = {
+ small: { mono: 3, default: 2 },
+ medium: { mono: 4, default: 3 },
+ large: { mono: 7, default: 4 },
+ };
+
+ if (font === 'mono') {
+ return fontMultiplier[size].mono;
+ } else if (['script', 'sans', 'serif'].includes(font)) {
+ return fontMultiplier[size].default;
+ }
+
+ return 1;
+ },
+
+ getHeightMultiplier(size, font, numberOfLines) {
+ const baseMultiplier = 1.5;
+
+ if (font === 'mono') {
+ return baseMultiplier + (numberOfLines > 1 ? 0.5 : 0);
+ } else if (['script', 'sans', 'serif'].includes(font)) {
+ return baseMultiplier + (numberOfLines > 1 ? 0.3 : 0);
+ }
+
+ return baseMultiplier;
+ }
+};
+
+const Utils = {
+ usePrevious,
+ findRemoved,
+ filterInvalidShapes,
+ mapLanguage,
+ sendShapeChanges,
+ getTextSize,
+ TextUtil
+};
+
+export default Utils;
+export {
+ usePrevious,
+ findRemoved,
+ filterInvalidShapes,
+ mapLanguage,
+ sendShapeChanges,
+ getTextSize,
+ TextUtil
+};
diff --git a/src/2.7.12/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker.js b/src/2.7.12/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker.js
new file mode 100644
index 00000000..89f028eb
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker.js
@@ -0,0 +1,93 @@
+/*
+This class allows other parts of the code to get called when an event (insert/update/delete) occurs in a server-side published cursor.
+
+In implementation time it was created for the publisher ( meteor live data hooks ) notify the listener ( LocalCollectionSynchronizer ) about the events.
+
+*/
+class CollectionEventsBroker {
+ static getKey(msg, updates) {
+ // the msg.msg has the collection event,
+ // see the collection hooks event object for more information
+ return `/${msg.collection}/${msg.msg}/`;
+ // TODO: also process the updates object
+ }
+
+ constructor() {
+ this.callbacks = {};
+ }
+
+ addListener(collection, event, callback) {
+ try {
+ const index = CollectionEventsBroker.getKey({ collection, msg: event });
+ const TheresCallback = this.callbacks[index];
+ if (TheresCallback) {
+ console.log('There is already an associated callback for this event');
+ return true;
+ }
+ this.callbacks[index] = callback;
+ return true;
+ } catch (err) {
+ console.error(err);
+ return false;
+ }
+ }
+
+ dispatchEvent(msg, updates) {
+ try {
+ const msgIndex = CollectionEventsBroker.getKey(msg, updates);
+ const { fields } = msg;
+ const callback = this.callbacks[msgIndex];
+ if (callback) {
+ callback({ ...fields, referenceId: msg.id });
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ // TODO: also process the updates object
+ }
+}
+
+const collectionEventsBroker = new CollectionEventsBroker();
+
+export const liveDataEventBrokerInitializer = () => {
+ Meteor.connection._processOneDataMessage = function (msg, updates) {
+ try {
+ const messageType = msg.msg;
+ let col = null;
+ if (msg.collection && msg.collection.indexOf('stream-cursor') === -1) {
+ col = Meteor.connection._stores[msg.collection]?._getCollection();
+ }
+ collectionEventsBroker.dispatchEvent(msg, updates);
+ // msg is one of ['added', 'changed', 'removed', 'ready', 'updated']
+ if (messageType === 'added') {
+ if (!col || !col.onAdded || col.onAdded(msg, updates)) {
+ this._process_added(msg, updates);
+ }
+ } else if (messageType === 'changed') {
+ if (!col || !col.onChanged || col.onChanged(msg, updates)) {
+ this._process_changed(msg, updates);
+ }
+ } else if (messageType === 'removed') {
+ if (!col || !col.onRemoved || col.onRemoved(msg, updates)) {
+ this._process_removed(msg, updates);
+ }
+ } else if (messageType === 'ready') {
+ if (!col || !col.onReady || col.onReady(msg, updates)) {
+ this._process_ready(msg, updates);
+ }
+ } else if (messageType === 'updated') {
+ if (!col || !col.onUpdated || col.onUpdated(msg, updates)) {
+ this._process_updated(msg, updates);
+ }
+ } else if (messageType === 'nosub') {
+ // ignore this
+ } else {
+ Meteor._debug('discarding unknown livedata data message type', msg);
+ }
+ } catch (err) {
+ console.error('Error when calling hooks', err);
+ }
+ };
+};
+
+export default collectionEventsBroker;
diff --git a/src/2.7.12/imports/ui/services/LocalCollectionSynchronizer/LocalCollectionSynchronizer.js b/src/2.7.12/imports/ui/services/LocalCollectionSynchronizer/LocalCollectionSynchronizer.js
new file mode 100644
index 00000000..92684d0f
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/LocalCollectionSynchronizer/LocalCollectionSynchronizer.js
@@ -0,0 +1,147 @@
+import SubscriptionRegistry from '/imports/ui/services/subscription-registry/subscriptionRegistry';
+import CollectionEventsBroker from '/imports/ui/services/LiveDataEventBroker/LiveDataEventBroker';
+
+/*
+This class connects a local collection with the LiveDataEventBroker, propagating the changes of a server-side published cursor to a local collection.
+
+It also guarantee that in case of a reconnection or a re-subscription, the data is only removed after subscription is ready, avoiding the situation of missing data during re-synchronization.
+*/
+
+class LocalCollectionSynchronizer {
+ constructor(serverCollection, localCollection, options = {}) {
+ this.serverCollection = serverCollection;
+ this.localCollection = localCollection;
+
+ this.lastSubscriptionId = '';
+ this.options = options;
+ this.ignoreDeletes = false;
+ this.checkForStaleData = this.checkForStaleData.bind(this);
+ }
+
+ /*
+ This method allow to enable/disable the ignoreDeletes feature.
+ When enabled, system will skip the received deletes ( not apply on local collection ).
+ Don't panic: these deletes will be processed when the subscription gets ready - see removeOldSubscriptionData method.
+ */
+ setIgnoreDeletes(value) {
+ this.ignoreDeletes = value;
+ }
+
+ // Replicates remote collection to local collection ( avoiding cleanup during the forced resync )
+ setupListeners() {
+ const self = this;
+
+ const addedCallback = function (item) {
+ if (item.id === 'publication-stop-marker' && item.stopped) {
+ self.ignoreDeletes = true;
+ return;
+ }
+
+ self.checkForStaleData();
+ const selector = { referenceId: item.referenceId };
+ const itemExistInCollection = self.localCollection.findOne(selector);
+
+ const { _id, ...restItem } = item;
+ const payload = {
+ subscriptionId: self.lastSubscriptionId,
+ ...restItem,
+ };
+
+ if (itemExistInCollection) {
+ self.localCollection.update(selector, payload);
+ } else {
+ self.localCollection.insert(payload);
+ }
+ };
+
+ const changedCallback = function (item) {
+ const {
+ _id,
+ ...restFields
+ } = item;
+ const selector = { referenceId: item.referenceId };
+ const payload = {
+ $set: {
+ ...restFields,
+ subscriptionId: self.lastSubscriptionId,
+ },
+ };
+ self.localCollection.update(selector, payload);
+ };
+
+ const removedCallback = function (item) {
+ const globalIgnoreDeletes = Session.get('globalIgnoreDeletes');
+ if (self.ignoreDeletes || globalIgnoreDeletes) {
+ return;
+ }
+ const selector = { referenceId: item.referenceId };
+
+ self.localCollection.remove(selector);
+ };
+
+ CollectionEventsBroker.addListener(this.serverCollection._name, 'added', addedCallback);
+ CollectionEventsBroker.addListener(this.serverCollection._name, 'changed', changedCallback);
+ CollectionEventsBroker.addListener(this.serverCollection._name, 'removed', removedCallback);
+ }
+
+ /*
+ This method calls the function received as parameter when the subscription gets ready.
+*/
+ callWhenSubscriptionReady(func) {
+ const temp = (res) => {
+ setTimeout(() => {
+ const subscription = SubscriptionRegistry.getSubscription(this.serverCollection._name);
+ if (subscription.ready()) {
+ func();
+ return res();
+ }
+ return temp(res);
+ }, 100);
+ };
+ const tempPromise = new Promise((res) => {
+ temp(res);
+ });
+ return tempPromise;
+ }
+
+ checkForStaleData() {
+ const subscription = SubscriptionRegistry.getSubscription(this.serverCollection._name);
+
+ // If the subscriptionId changes means the subscriptions was redone
+ // or theres more than one subscription per collection
+ if (subscription && (this.lastSubscriptionId !== subscription.subscriptionId)) {
+ const wasEmpty = this.lastSubscriptionId === '';
+ this.lastSubscriptionId = subscription.subscriptionId;
+ if (!wasEmpty) {
+ this.callWhenSubscriptionReady(() => {
+ this.ignoreDeletes = false;
+ Session.set('globalIgnoreDeletes', false);
+ this.removeOldSubscriptionData();
+ });
+ }
+ }
+ }
+
+ /*
+ This method removes data from previous subscriptions after the current one is ready.
+ */
+ removeOldSubscriptionData() {
+ const subscription = SubscriptionRegistry.getSubscription(this.serverCollection._name);
+
+ const selector = {
+ subscriptionId: {
+ $nin: [subscription.subscriptionId],
+ },
+ };
+
+ const oldSubscriptionItems = this.localCollection.find(selector).fetch();
+ oldSubscriptionItems.forEach((item) => {
+ this.localCollection.remove({
+ meetingId: item.meetingId,
+ ...selector,
+ });
+ });
+ }
+}
+
+export default LocalCollectionSynchronizer;
diff --git a/src/2.7.12/imports/ui/services/api/index.js b/src/2.7.12/imports/ui/services/api/index.js
new file mode 100644
index 00000000..ee510daf
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/api/index.js
@@ -0,0 +1,40 @@
+import Auth from '/imports/ui/services/auth';
+import { check } from 'meteor/check';
+import logger from '/imports/startup/client/logger';
+
+/**
+ * Send the request to the server via Meteor.call and don't treat errors.
+ *
+ * @param {string} name
+ * @param {any} args
+ * @see https://docs.meteor.com/api/methods.html#Meteor-call
+ * @return {Promise}
+ */
+export function makeCall(name, ...args) {
+ check(name, String);
+
+ // const { credentials } = Auth;
+
+ return new Promise(async (resolve, reject) => {
+ if (Meteor.status().connected) {
+ const result = await Meteor.callAsync(name, ...args);
+ // all tested cases it returnd 0, empty array or undefined
+ resolve(result);
+ } else {
+ const failureString = `Call to ${name} failed because Meteor is not connected`;
+ // We don't want to send a log message if the call that failed was a log message.
+ // Without this you can get into an endless loop of failed logging.
+ if (name !== 'logClient') {
+ logger.warn({
+ logCode: 'servicesapiindex_makeCall',
+ extraInfo: {
+ attemptForUserInfo: Auth.fullInfo,
+ name,
+ ...args,
+ },
+ }, failureString);
+ }
+ reject(failureString);
+ }
+ });
+}
diff --git a/src/2.7.12/imports/ui/services/audio-manager/error-codes.js b/src/2.7.12/imports/ui/services/audio-manager/error-codes.js
new file mode 100644
index 00000000..1c4bdbad
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/audio-manager/error-codes.js
@@ -0,0 +1,9 @@
+const MIC_ERROR = {
+ UNKNOWN: 0,
+ NO_SSL: 9,
+ MAC_OS_BLOCK: 8,
+ NO_PERMISSION: 7,
+ DEVICE_NOT_FOUND: 6,
+};
+
+export default { MIC_ERROR };
diff --git a/src/2.7.12/imports/ui/services/audio-manager/index.js b/src/2.7.12/imports/ui/services/audio-manager/index.js
new file mode 100644
index 00000000..16d9f337
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/audio-manager/index.js
@@ -0,0 +1,1174 @@
+import { Tracker } from 'meteor/tracker';
+
+import Auth from '/imports/ui/services/auth';
+import VoiceUsers from '/imports/api/voice-users';
+import SIPBridge from '/imports/api/audio/client/bridge/sip';
+import SFUAudioBridge from '/imports/api/audio/client/bridge/sfu-audio-bridge';
+import logger from '/imports/startup/client/logger';
+import { notify } from '/imports/ui/services/notification';
+import playAndRetry from '/imports/utils/mediaElementPlayRetry';
+import iosWebviewAudioPolyfills from '/imports/utils/ios-webview-audio-polyfills';
+import { monitorAudioConnection } from '/imports/utils/stats';
+import AudioErrors from './error-codes';
+import { Meteor } from 'meteor/meteor';
+import browserInfo from '/imports/utils/browserInfo';
+import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
+import getFromUserSettings from '/imports/ui/services/users-settings';
+import {
+ DEFAULT_INPUT_DEVICE_ID,
+ reloadAudioElement,
+ getCurrentAudioSinkId,
+ getStoredAudioInputDeviceId,
+ storeAudioInputDeviceId,
+ getStoredAudioOutputDeviceId,
+ storeAudioOutputDeviceId,
+} from '/imports/api/audio/client/bridge/service';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+
+const STATS = Meteor.settings.public.stats;
+const MEDIA = Meteor.settings.public.media;
+const MEDIA_TAG = MEDIA.mediaTag;
+const ECHO_TEST_NUMBER = MEDIA.echoTestNumber;
+const EXPERIMENTAL_USE_KMS_TRICKLE_ICE_FOR_MICROPHONE =
+ Meteor.settings.public.app.experimentalUseKmsTrickleIceForMicrophone;
+
+const DEFAULT_AUDIO_BRIDGES_PATH = '/imports/api/audio/client/';
+const CALL_STATES = {
+ STARTED: 'started',
+ ENDED: 'ended',
+ FAILED: 'failed',
+ RECONNECTING: 'reconnecting',
+ AUTOPLAY_BLOCKED: 'autoplayBlocked',
+};
+
+const BREAKOUT_AUDIO_TRANSFER_STATES = {
+ CONNECTED: 'connected',
+ DISCONNECTED: 'disconnected',
+ RETURNING: 'returning',
+};
+
+/**
+ * Audio status to be filtered in getStats()
+ */
+const FILTER_AUDIO_STATS = [
+ 'outbound-rtp',
+ 'inbound-rtp',
+ 'candidate-pair',
+ 'local-candidate',
+ 'transport',
+];
+
+class AudioManager {
+ constructor() {
+ this._inputDevice = {
+ value: DEFAULT_INPUT_DEVICE_ID,
+ tracker: new Tracker.Dependency(),
+ };
+
+ this._breakoutAudioTransferStatus = {
+ status: BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ breakoutMeetingId: null,
+ };
+
+ this.defineProperties({
+ isMuted: false,
+ isConnected: false,
+ isConnecting: false,
+ isHangingUp: false,
+ isListenOnly: false,
+ isEchoTest: false,
+ isTalking: false,
+ isWaitingPermissions: false,
+ error: null,
+ muteHandle: null,
+ autoplayBlocked: false,
+ isReconnecting: false,
+ });
+
+ this.failedMediaElements = [];
+ this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
+ this.monitor = this.monitor.bind(this);
+
+ this._inputStream = null;
+ this._inputStreamTracker = new Tracker.Dependency();
+ this._inputDeviceId = {
+ value: getStoredAudioInputDeviceId() || DEFAULT_INPUT_DEVICE_ID,
+ tracker: new Tracker.Dependency(),
+ };
+ this._outputDeviceId = {
+ value: getCurrentAudioSinkId(),
+ tracker: new Tracker.Dependency(),
+ };
+
+ this.BREAKOUT_AUDIO_TRANSFER_STATES = BREAKOUT_AUDIO_TRANSFER_STATES;
+ this._applyCachedOutputDeviceId();
+ }
+
+ _applyCachedOutputDeviceId() {
+ const cachedId = getStoredAudioOutputDeviceId();
+
+ if (typeof cachedId === 'string') {
+ this.changeOutputDevice(cachedId, false).then(() => {
+ this.outputDeviceId = cachedId;
+ }).catch((error) => {
+ logger.warn({
+ logCode: 'audiomanager_output_device_storage_failed',
+ extraInfo: {
+ deviceId: cachedId,
+ errorMessage: error.message,
+ },
+ }, `Failed to apply output audio device from storage: ${error.message}`);
+ });
+ }
+ }
+
+ set inputDeviceId(value) {
+ if (this._inputDeviceId.value !== value) {
+ this._inputDeviceId.value = value;
+ this._inputDeviceId.tracker.changed();
+ }
+
+ if (this.fullAudioBridge) {
+ this.fullAudioBridge.inputDeviceId = this._inputDeviceId.value;
+ }
+ }
+
+ get inputDeviceId() {
+ this._inputDeviceId.tracker.depend();
+ return this._inputDeviceId.value;
+ }
+
+ set outputDeviceId(value) {
+ if (this._outputDeviceId.value !== value) {
+ this._outputDeviceId.value = value;
+ this._outputDeviceId.tracker.changed();
+ }
+
+ if (this.fullAudioBridge) {
+ this.fullAudioBridge.outputDeviceId = this._outputDeviceId.value;
+ }
+
+ if (this.listenOnlyBridge) {
+ this.listenOnlyBridge.outputDeviceId = this._outputDeviceId.value;
+ }
+ }
+
+ get outputDeviceId() {
+ this._outputDeviceId.tracker.depend();
+ return this._outputDeviceId.value;
+ }
+
+ async init(userData, audioEventHandler) {
+ this.loadBridges(userData);
+ this.userData = userData;
+ this.initialized = true;
+ this.audioEventHandler = audioEventHandler;
+ await this.loadBridges(userData);
+ }
+
+ /**
+ * Load audio bridges modules to be used the manager.
+ *
+ * Bridges can be configured in settings.yml file.
+ * @param {Object} userData The Object representing user data to be passed to
+ * the bridge.
+ */
+ async loadBridges(userData) {
+ let FullAudioBridge = SIPBridge;
+ let ListenOnlyBridge = SFUAudioBridge;
+
+ if (MEDIA.audio) {
+ const { bridges, defaultFullAudioBridge, defaultListenOnlyBridge } = MEDIA.audio;
+
+ const _fullAudioBridge = getFromUserSettings(
+ 'bbb_fullaudio_bridge',
+ getFromMeetingSettings('fullaudio-bridge', defaultFullAudioBridge),
+ );
+
+ this.bridges = {};
+
+ await Promise.all(
+ Object.values(bridges).map(async (bridge) => {
+ // eslint-disable-next-line import/no-dynamic-require, global-require
+ this.bridges[bridge.name] = (
+ (await import(DEFAULT_AUDIO_BRIDGES_PATH + bridge.path)) || {}
+ ).default;
+ })
+ );
+
+ if (_fullAudioBridge && this.bridges[_fullAudioBridge]) {
+ FullAudioBridge = this.bridges[_fullAudioBridge];
+ }
+
+ if (defaultListenOnlyBridge && this.bridges[defaultListenOnlyBridge]) {
+ ListenOnlyBridge = this.bridges[defaultListenOnlyBridge];
+ }
+ }
+
+ this.fullAudioBridge = new FullAudioBridge(userData);
+ this.listenOnlyBridge = new ListenOnlyBridge(userData);
+ // Initialize device IDs in configured bridges
+ this.fullAudioBridge.inputDeviceId = this.inputDeviceId;
+ this.fullAudioBridge.outputDeviceId = this.outputDeviceId;
+ this.listenOnlyBridge.outputDeviceId = this.outputDeviceId;
+ }
+
+ setAudioMessages(messages, intl) {
+ this.messages = messages;
+ this.intl = intl;
+ }
+
+ defineProperties(obj) {
+ Object.keys(obj).forEach((key) => {
+ const privateKey = `_${key}`;
+ this[privateKey] = {
+ value: obj[key],
+ tracker: new Tracker.Dependency(),
+ };
+
+ Object.defineProperty(this, key, {
+ set: (value) => {
+ this[privateKey].value = value;
+ this[privateKey].tracker.changed();
+ },
+ get: () => {
+ this[privateKey].tracker.depend();
+ return this[privateKey].value;
+ },
+ });
+ });
+ }
+
+ async trickleIce() {
+ const { isFirefox, isIe, isSafari } = browserInfo;
+
+ if (!this.listenOnlyBridge
+ || typeof this.listenOnlyBridge.trickleIce !== 'function'
+ || isFirefox
+ || isIe
+ || isSafari) {
+ return [];
+ }
+
+ if (this.validIceCandidates && this.validIceCandidates.length) {
+ logger.info(
+ { logCode: 'audiomanager_trickle_ice_reuse_candidate' },
+ 'Reusing trickle ICE information before activating microphone',
+ );
+ return this.validIceCandidates;
+ }
+
+ logger.info(
+ { logCode: 'audiomanager_trickle_ice_get_local_candidate' },
+ 'Performing trickle ICE before activating microphone',
+ );
+
+ try {
+ this.validIceCandidates = await this.listenOnlyBridge.trickleIce();
+ return this.validIceCandidates;
+ } catch (error) {
+ logger.error({
+ logCode: 'audiomanager_trickle_ice_failed',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, `Trickle ICE before activating microphone failed: ${error.message}`);
+ return [];
+ }
+ }
+
+ joinMicrophone() {
+ this.audioJoinStartTime = new Date();
+ this.logAudioJoinTime = false;
+ this.isListenOnly = false;
+ this.isEchoTest = false;
+
+ return this.onAudioJoining
+ .bind(this)()
+ .then(() => {
+ const callOptions = {
+ isListenOnly: false,
+ extension: null,
+ inputStream: this.inputStream,
+ };
+ return this.joinAudio(callOptions, this.callStateCallback.bind(this));
+ });
+ }
+
+ joinEchoTest() {
+ this.audioJoinStartTime = new Date();
+ this.logAudioJoinTime = false;
+ this.isListenOnly = false;
+ this.isEchoTest = true;
+
+ return this.onAudioJoining
+ .bind(this)()
+ .then(async () => {
+ let validIceCandidates = [];
+ if (EXPERIMENTAL_USE_KMS_TRICKLE_ICE_FOR_MICROPHONE) {
+ validIceCandidates = await this.trickleIce();
+ }
+
+ const callOptions = {
+ isListenOnly: false,
+ extension: ECHO_TEST_NUMBER,
+ inputStream: this.inputStream,
+ validIceCandidates,
+ };
+ logger.info(
+ {
+ logCode: 'audiomanager_join_echotest',
+ extraInfo: { logType: 'user_action' },
+ },
+ 'User requested to join audio conference with mic'
+ );
+ return this.joinAudio(callOptions, this.callStateCallback.bind(this));
+ });
+ }
+
+ joinAudio(callOptions, callStateCallback) {
+ return this.bridge
+ .joinAudio(callOptions, callStateCallback.bind(this))
+ .catch((error) => {
+ const { name, message } = error;
+ const errorPayload = {
+ type: 'MEDIA_ERROR',
+ errMessage: message || 'MEDIA_ERROR',
+ errCode: AudioErrors.MIC_ERROR.UNKNOWN,
+ };
+
+ switch (name) {
+ case 'NotAllowedError':
+ errorPayload.errCode = AudioErrors.MIC_ERROR.NO_PERMISSION;
+ logger.error(
+ {
+ logCode: 'audiomanager_error_getting_device',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ },
+ `Error getting microphone - {${error.name}: ${error.message}}`
+ );
+ break;
+ case 'NotFoundError':
+ errorPayload.errCode = AudioErrors.MIC_ERROR.DEVICE_NOT_FOUND;
+ logger.error(
+ {
+ logCode: 'audiomanager_error_device_not_found',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ },
+ `Error getting microphone - {${error.name}: ${error.message}}`
+ );
+ break;
+ default:
+ logger.error({
+ logCode: 'audiomanager_error_unknown',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ },
+ }, `Error enabling audio - {${name}: ${message}}`);
+ break;
+ }
+
+ this.isConnecting = false;
+ this.isWaitingPermissions = false;
+
+ throw errorPayload;
+ });
+ }
+
+ async joinListenOnly() {
+ this.audioJoinStartTime = new Date();
+ this.logAudioJoinTime = false;
+ this.isListenOnly = true;
+ this.isEchoTest = false;
+
+ // Call polyfills for webrtc client if navigator is "iOS Webview"
+ const userAgent = window.navigator.userAgent.toLocaleLowerCase();
+ if (
+ (userAgent.indexOf('iphone') > -1 || userAgent.indexOf('ipad') > -1) &&
+ userAgent.indexOf('safari') === -1
+ ) {
+ iosWebviewAudioPolyfills();
+ }
+
+ logger.info({
+ logCode: 'audiomanager_join_listenonly',
+ extraInfo: { logType: 'user_action' },
+ }, 'user requested to connect to audio conference as listen only');
+
+ window.addEventListener('audioPlayFailed', this.handlePlayElementFailed);
+
+ return this.onAudioJoining.bind(this)()
+ .then(() => {
+ const callOptions = {
+ isListenOnly: true,
+ extension: null,
+ };
+ return this.joinAudio(callOptions, this.callStateCallback.bind(this));
+ });
+ }
+
+ onAudioJoining() {
+ this.isConnecting = true;
+ this.isMuted = false;
+ this.error = false;
+
+ return Promise.resolve();
+ }
+
+ exitAudio() {
+ if (!this.isConnected) return Promise.resolve();
+
+ this.isHangingUp = true;
+
+ return this.bridge.exitAudio();
+ }
+
+ forceExitAudio() {
+ this.notifyAudioExit();
+ this.isConnected = false;
+ this.isConnecting = false;
+ this.isHangingUp = false;
+
+ if (this.inputStream) {
+ this.inputStream.getTracks().forEach((track) => track.stop());
+ this.inputStream = null;
+ }
+
+ window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed);
+
+ return this.bridge.exitAudio();
+ }
+
+ transferCall() {
+ this.onTransferStart();
+ return this.bridge.transferCall(this.onAudioJoin.bind(this));
+ }
+
+ onVoiceUserChanges(fields) {
+ if (fields.muted !== undefined && fields.muted !== this.isMuted) {
+ let muteState;
+ this.isMuted = fields.muted;
+
+ if (this.isMuted) {
+ muteState = 'selfMuted';
+ this.mute();
+ } else {
+ muteState = 'selfUnmuted';
+ this.unmute();
+ }
+ }
+
+ if (fields.talking !== undefined && fields.talking !== this.isTalking) {
+ this.isTalking = fields.talking;
+ }
+
+ if (this.isMuted) {
+ this.isTalking = false;
+ }
+ }
+
+ onAudioJoin() {
+ this.isConnecting = false;
+ this.isConnected = true;
+
+ // listen to the VoiceUsers changes and update the flag
+ if (!this.muteHandle) {
+ const query = VoiceUsers.find(
+ { intId: Auth.userID },
+ { fields: { muted: 1, talking: 1 } }
+ );
+ this.muteHandle = query.observeChanges({
+ added: (id, fields) => this.onVoiceUserChanges(fields),
+ changed: (id, fields) => this.onVoiceUserChanges(fields),
+ });
+ }
+ const secondsToActivateAudio =
+ (new Date() - this.audioJoinStartTime) / 1000;
+
+ if (!this.logAudioJoinTime) {
+ this.logAudioJoinTime = true;
+ logger.info(
+ {
+ logCode: 'audio_mic_join_time',
+ extraInfo: {
+ secondsToActivateAudio,
+ },
+ },
+ `Time needed to connect audio (seconds): ${secondsToActivateAudio}`
+ );
+ }
+
+ try {
+ this.inputStream = this.bridge ? this.bridge.inputStream : null;
+ // Enforce correct output device on audio join
+ this.changeOutputDevice(this.outputDeviceId, true);
+ storeAudioOutputDeviceId(this.outputDeviceId);
+ // Extract the deviceId again from the stream to guarantee consistency
+ // between stream DID vs chosen DID. That's necessary in scenarios where,
+ // eg, there's no default/pre-set deviceId ('') and the browser's
+ // default device has been altered by the user (browser default != system's
+ // default).
+ if (this.inputStream) {
+ const extractedDeviceId = MediaStreamUtils.extractDeviceIdFromStream(this.inputStream, 'audio');
+ if (extractedDeviceId && extractedDeviceId !== this.inputDeviceId) {
+ this.changeInputDevice(extractedDeviceId);
+ }
+ }
+ // Audio joined successfully - add device IDs to session storage so they
+ // can be re-used on refreshes/other sessions
+ storeAudioInputDeviceId(this.inputDeviceId);
+ } catch (error) {
+ logger.warn({
+ logCode: 'audiomanager_device_enforce_failed',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ inputDeviceId: this.inputDeviceId,
+ outputDeviceId: this.outputDeviceId,
+ },
+ }, `Failed to enforce input/output devices: ${error.message}`);
+ }
+
+ if (!this.isEchoTest) {
+ this.notify(this.intl.formatMessage(this.messages.info.JOINED_AUDIO));
+ logger.info({
+ logCode: 'audio_joined',
+ extraInfo: {
+ secondsToActivateAudio,
+ inputDeviceId: this.inputDeviceId,
+ outputDeviceId: this.outputDeviceId,
+ isListenOnly: this.isListenOnly,
+ },
+ }, 'Audio Joined');
+ if (STATS.enabled) this.monitor();
+ this.audioEventHandler({
+ name: 'started',
+ isListenOnly: this.isListenOnly,
+ });
+ }
+ Session.set('audioModalIsOpen', false);
+ }
+
+ onTransferStart() {
+ this.isEchoTest = false;
+ this.isConnecting = true;
+ }
+
+ // Must be called before the call is actually torn down (this.isConnected = true)
+ notifyAudioExit() {
+ try {
+ if (!this.error && (this.isConnected && !this.isEchoTest)) {
+ this.notify(
+ this.intl.formatMessage(this.messages.info.LEFT_AUDIO),
+ false,
+ 'no_audio',
+ );
+ }
+ } catch {}
+ }
+
+ onAudioExit() {
+ this.notifyAudioExit();
+ this.isConnected = false;
+ this.isConnecting = false;
+ this.isHangingUp = false;
+ this.autoplayBlocked = false;
+ this.failedMediaElements = [];
+
+ if (this.inputStream) {
+ this.inputStream.getTracks().forEach((track) => track.stop());
+ this.inputStream = null;
+ }
+
+ if (!this.isEchoTest) {
+ this.playHangUpSound();
+ }
+
+ window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed);
+ }
+
+ callStateCallback(response) {
+ return new Promise((resolve) => {
+ const { STARTED, ENDED, FAILED, RECONNECTING, AUTOPLAY_BLOCKED } =
+ CALL_STATES;
+
+ const { status, error, bridgeError, silenceNotifications, bridge } =
+ response;
+
+ if (status === STARTED) {
+ this.isReconnecting = false;
+ this.onAudioJoin();
+ resolve(STARTED);
+ } else if (status === ENDED) {
+ this.isReconnecting = false;
+ this.setBreakoutAudioTransferStatus({
+ breakoutMeetingId: '',
+ status: BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ });
+ logger.info({ logCode: 'audio_ended' }, 'Audio ended without issue');
+ this.onAudioExit();
+ } else if (status === FAILED) {
+ this.isReconnecting = false;
+ this.setBreakoutAudioTransferStatus({
+ breakoutMeetingId: '',
+ status: BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ });
+ const errorKey =
+ this.messages.error[error] || this.messages.error.GENERIC_ERROR;
+ const errorMsg = this.intl.formatMessage(errorKey, { 0: bridgeError });
+ this.error = !!error;
+ logger.error(
+ {
+ logCode: 'audio_failure',
+ extraInfo: {
+ errorCode: error,
+ cause: bridgeError,
+ bridge,
+ inputDeviceId: this.inputDeviceId,
+ outputDeviceId: this.outputDeviceId,
+ isListenOnly: this.isListenOnly,
+ },
+ },
+ `Audio error - errorCode=${error}, cause=${bridgeError}`
+ );
+ if (silenceNotifications !== true) {
+ this.notify(errorMsg, true);
+ this.exitAudio();
+ this.onAudioExit();
+ }
+ } else if (status === RECONNECTING) {
+ this.isReconnecting = true;
+ this.setBreakoutAudioTransferStatus({
+ breakoutMeetingId: '',
+ status: BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ });
+ logger.info(
+ { logCode: 'audio_reconnecting' },
+ 'Attempting to reconnect audio'
+ );
+ this.notify(
+ this.intl.formatMessage(this.messages.info.RECONNECTING_AUDIO),
+ true
+ );
+ this.playHangUpSound();
+ } else if (status === AUTOPLAY_BLOCKED) {
+ this.setBreakoutAudioTransferStatus({
+ breakoutMeetingId: '',
+ status: BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
+ });
+ this.isReconnecting = false;
+ this.autoplayBlocked = true;
+ this.onAudioJoin();
+ resolve(AUTOPLAY_BLOCKED);
+ }
+ });
+ }
+
+ isUsingAudio() {
+ return (
+ this.isConnected ||
+ this.isConnecting ||
+ this.isHangingUp ||
+ this.isEchoTest
+ );
+ }
+
+ changeInputDevice(deviceId) {
+ if (typeof deviceId !== 'string') throw new TypeError('Invalid inputDeviceId');
+
+ if (deviceId === this.inputDeviceId) return this.inputDeviceId;
+
+ const currentDeviceId = this.inputDeviceId ?? 'none';
+ this.inputDeviceId = deviceId;
+ logger.debug({
+ logCode: 'audiomanager_input_device_change',
+ extraInfo: {
+ deviceId: currentDeviceId,
+ newDeviceId: deviceId,
+ },
+ }, `Microphone input device changed: from ${currentDeviceId} to ${deviceId}`);
+
+ return this.inputDeviceId;
+ }
+
+ liveChangeInputDevice(deviceId) {
+ const currentDeviceId = this.inputDeviceId ?? 'none';
+ // we force stream to be null, so MutedAlert will deallocate it and
+ // a new one will be created for the new stream
+ this.inputStream = null;
+ return this.bridge.liveChangeInputDevice(deviceId).then((stream) => {
+ this.inputStream = stream;
+ const extractedDeviceId = MediaStreamUtils.extractDeviceIdFromStream(this.inputStream, 'audio');
+ if (extractedDeviceId && extractedDeviceId !== this.inputDeviceId) {
+ this.changeInputDevice(extractedDeviceId);
+ }
+ // Live input device change - add device ID to session storage so it
+ // can be re-used on refreshes/other sessions
+ storeAudioInputDeviceId(extractedDeviceId);
+ this.setSenderTrackEnabled(!this.isMuted);
+ }).catch((error) => {
+ logger.error({
+ logCode: 'audiomanager_input_live_device_change_failure',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ deviceId: currentDeviceId,
+ newDeviceId: deviceId,
+ },
+ }, `Input device live change failed - {${error.name}: ${error.message}}`);
+
+ throw error;
+ });
+ }
+
+ async changeOutputDevice(deviceId, isLive) {
+ const targetDeviceId = deviceId;
+ const currentDeviceId = this.outputDeviceId ?? getCurrentAudioSinkId();
+ const audioElement = document.querySelector(MEDIA_TAG);
+ const sinkIdSupported = audioElement && typeof audioElement.setSinkId === 'function';
+
+ if (typeof deviceId === 'string' && sinkIdSupported && currentDeviceId !== targetDeviceId) {
+ try {
+ if (!isLive) audioElement.srcObject = null;
+
+ await audioElement.setSinkId(deviceId);
+ reloadAudioElement(audioElement);
+ logger.debug({
+ logCode: 'audiomanager_output_device_change',
+ extraInfo: {
+ deviceId: currentDeviceId,
+ newDeviceId: deviceId,
+ },
+ }, `Audio output device changed: from ${currentDeviceId || 'default'} to ${deviceId || 'default'}`);
+ this.outputDeviceId = deviceId;
+
+ // Live output device change - add device ID to session storage so it
+ // can be re-used on refreshes/other sessions
+ if (isLive) storeAudioOutputDeviceId(deviceId);
+
+ return this.outputDeviceId;
+ } catch (error) {
+ logger.error({
+ logCode: 'audiomanager_output_device_change_failure',
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ deviceId: currentDeviceId,
+ newDeviceId: targetDeviceId,
+ },
+ }, `Error changing output device - {${error.name}: ${error.message}}`);
+
+ // Rollback/enforce current sinkId (if possible)
+ if (sinkIdSupported) {
+ this.outputDeviceId = getCurrentAudioSinkId();
+ } else {
+ this.outputDeviceId = currentDeviceId;
+ }
+
+ throw error;
+ }
+ }
+
+ return this.outputDeviceId;
+ }
+
+ get inputStream() {
+ this._inputStreamTracker.depend();
+ return this._inputStream;
+ }
+
+ get bridge() {
+ return this.isListenOnly ? this.listenOnlyBridge : this.fullAudioBridge;
+ }
+
+ set inputStream(stream) {
+ // We store reactive information about input stream
+ // because mutedalert component needs to track when it changes
+ // and then update hark with the new value for inputStream
+ if (this._inputStream !== stream) {
+ this._inputStreamTracker.changed();
+ }
+
+ this._inputStream = stream;
+ }
+
+ /**
+ * Sets the current status for breakout audio transfer
+ * @param {Object} newStatus The status Object to be set for
+ * audio transfer.
+ * @param {string} newStatus.breakoutMeetingId The meeting id of the current
+ * breakout audio transfer.
+ * @param {string} newStatus.status The status of the current audio
+ * transfer. Valid values are
+ * 'connected', 'disconnected' and
+ * 'returning'.
+ */
+ setBreakoutAudioTransferStatus(newStatus) {
+ const currentStatus = this._breakoutAudioTransferStatus;
+ const { breakoutMeetingId, status } = newStatus;
+
+ if (typeof breakoutMeetingId === 'string') {
+ currentStatus.breakoutMeetingId = breakoutMeetingId;
+ } else {
+ currentStatus.breakoutMeetingId = null;
+ }
+
+ if (typeof status === 'string') {
+ currentStatus.status = status;
+
+ if (this.bridge && !this.isListenOnly) {
+ if (status !== BREAKOUT_AUDIO_TRANSFER_STATES.CONNECTED) {
+ this.bridge.ignoreCallState = false;
+ } else {
+ this.bridge.ignoreCallState = true;
+ }
+ }
+ }
+ }
+
+ getBreakoutAudioTransferStatus() {
+ return this._breakoutAudioTransferStatus;
+ }
+
+ set userData(value) {
+ this._userData = value;
+ }
+
+ get userData() {
+ return this._userData;
+ }
+
+ playHangUpSound() {
+ this.playAlertSound(
+ `${
+ Meteor.settings.public.app.cdn +
+ Meteor.settings.public.app.basename +
+ Meteor.settings.public.app.instanceId
+ }` + '/resources/sounds/LeftCall.mp3'
+ );
+ }
+
+ notify(message, error = false, icon = 'unmute') {
+ const audioIcon = this.isListenOnly ? 'listen' : icon;
+
+ notify(message, error ? 'error' : 'info', audioIcon);
+ }
+
+ monitor() {
+ const peer = this.bridge.getPeerConnection();
+ monitorAudioConnection(peer);
+ }
+
+ handleAllowAutoplay() {
+ window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed);
+
+ logger.info(
+ {
+ logCode: 'audiomanager_autoplay_allowed',
+ },
+ 'Listen only autoplay allowed by the user'
+ );
+
+ while (this.failedMediaElements.length) {
+ const mediaElement = this.failedMediaElements.shift();
+ if (mediaElement) {
+ playAndRetry(mediaElement).then((played) => {
+ if (!played) {
+ logger.error(
+ {
+ logCode: 'audiomanager_autoplay_handling_failed',
+ },
+ 'Listen only autoplay handling failed to play media'
+ );
+ } else {
+ // logCode is listenonly_* to make it consistent with the other tag play log
+ logger.info(
+ {
+ logCode: 'listenonly_media_play_success',
+ },
+ 'Listen only media played successfully'
+ );
+ }
+ });
+ }
+ }
+ this.autoplayBlocked = false;
+ }
+
+ handlePlayElementFailed(e) {
+ const { mediaElement } = e.detail;
+
+ e.stopPropagation();
+ this.failedMediaElements.push(mediaElement);
+ if (!this.autoplayBlocked) {
+ logger.info(
+ {
+ logCode: 'audiomanager_autoplay_prompt',
+ },
+ 'Prompting user for action to play listen only media'
+ );
+ this.autoplayBlocked = true;
+ }
+ }
+
+ setSenderTrackEnabled(shouldEnable) {
+ // If the bridge is set to listen only mode, nothing to do here. This method
+ // is solely for muting outbound tracks.
+ if (this.isListenOnly) return;
+
+ // Bridge -> SIP.js bridge, the only full audio capable one right now
+ const peer = this.bridge.getPeerConnection();
+
+ if (!peer) {
+ return;
+ }
+
+ peer.getSenders().forEach((sender) => {
+ const { track } = sender;
+ if (track && track.kind === 'audio') {
+ track.enabled = shouldEnable;
+ }
+ });
+ }
+
+ mute() {
+ this.setSenderTrackEnabled(false);
+ }
+
+ unmute() {
+ this.setSenderTrackEnabled(true);
+ }
+
+ playAlertSound(url) {
+ if (!url || !this.bridge) {
+ return Promise.resolve();
+ }
+
+ const audioAlert = new Audio(url);
+
+ audioAlert.addEventListener('ended', () => {
+ audioAlert.src = null;
+ });
+
+ const { outputDeviceId } = this.bridge;
+
+ if (outputDeviceId && typeof audioAlert.setSinkId === 'function') {
+ return audioAlert.setSinkId(outputDeviceId).then(() => audioAlert.play());
+ }
+
+ return audioAlert.play();
+ }
+
+ async updateAudioConstraints(constraints) {
+ await this.bridge.updateAudioConstraints(constraints);
+ }
+
+ /**
+ * Get the info about candidate-pair that is being used by the current peer.
+ * For firefox, or any other browser that doesn't support iceTransport
+ * property of RTCDtlsTransport, we retrieve the selected local candidate
+ * by looking into stats returned from getStats() api. For other browsers,
+ * we should use getSelectedCandidatePairFromPeer instead, because it has
+ * relatedAddress and relatedPort information about local candidate.
+ *
+ * @param {Object} stats object returned by getStats() api
+ * @returns An Object of type RTCIceCandidatePairStats containing information
+ * about the candidate-pair being used by the peer.
+ *
+ * For firefox, we can use the 'selected' flag to find the candidate pair
+ * being used, while in chrome we can retrieved the selected pair
+ * by looking for the corresponding transport of the active peer.
+ * For more information see:
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatepairstats
+ * and
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidatePairStats/selected#value
+ */
+ static getSelectedCandidatePairFromStats(stats) {
+ if (!stats || typeof stats !== 'object') return null;
+
+ const transport =
+ Object.values(stats).find((stat) => stat.type === 'transport') || {};
+
+ return Object.values(stats).find(
+ (stat) =>
+ stat.type === 'candidate-pair' &&
+ stat.nominated &&
+ (stat.selected || stat.id === transport.selectedCandidatePairId)
+ );
+ }
+
+ /**
+ * Get the info about candidate-pair that is being used by the current peer.
+ * This function's return value (RTCIceCandidatePair object ) is different
+ * from getSelectedCandidatePairFromStats (RTCIceCandidatePairStats object).
+ * The information returned here contains the relatedAddress and relatedPort
+ * fields (only for candidates that are derived from another candidate, for
+ * host candidates, these fields are null). These field can be helpful for
+ * debugging network issues. For all the browsers that support iceTransport
+ * field of RTCDtlsTransport, we use this function as default to retrieve
+ * information about current selected-pair. For other browsers we retrieve it
+ * from getSelectedCandidatePairFromStats
+ *
+ * @returns {Object} An RTCIceCandidatePair represented the selected
+ * candidate-pair of the active peer.
+ *
+ * For more info see:
+ * https://www.w3.org/TR/webrtc/#dom-rtcicecandidatepair
+ * and
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidatePair
+ * and
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCDtlsTransport
+ */
+ getSelectedCandidatePairFromPeer() {
+ if (!this.bridge) return null;
+
+ const peer = this.bridge.getPeerConnection();
+
+ if (!peer) return null;
+
+ let selectedPair = null;
+
+ const receivers = peer.getReceivers();
+ if (
+ receivers &&
+ receivers[0] &&
+ receivers[0].transport &&
+ receivers[0].transport.iceTransport &&
+ typeof receivers[0].transport.iceTransport.getSelectedCandidatePair === 'function'
+ ) {
+ selectedPair = receivers[0].transport.iceTransport.getSelectedCandidatePair();
+ }
+
+ return selectedPair;
+ }
+
+ /**
+ * Gets the selected local-candidate information. For browsers that support
+ * iceTransport property (see getSelectedCandidatePairFromPeer) we get this
+ * info from peer, otherwise we retrieve this information from getStats() api
+ *
+ * @param {Object} [stats] The status object returned from getStats() api
+ * @returns {Object} An Object containing the information about the
+ * local-candidate. For browsers that support iceTransport
+ * property, the object's type is RCIceCandidate. A
+ * RTCIceCandidateStats is returned, otherwise.
+ *
+ * For more info see:
+ * https://www.w3.org/TR/webrtc/#dom-rtcicecandidate
+ * and
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats
+ *
+ */
+ getSelectedLocalCandidate(stats) {
+ let selectedPair = this.getSelectedCandidatePairFromPeer();
+
+ if (selectedPair) return selectedPair.local;
+
+ if (!stats) return null;
+
+ selectedPair = AudioManager.getSelectedCandidatePairFromStats(stats);
+
+ if (selectedPair) return stats[selectedPair.localCandidateId];
+
+ return null;
+ }
+
+ /**
+ * Gets the information about private/public ip address from peer
+ * stats. The information retrieved from selected pair from the current
+ * RTCIceTransport and returned in a new Object with format:
+ * {
+ * address: String,
+ * relatedAddress: String,
+ * port: Number,
+ * relatedPort: Number,
+ * candidateType: String,
+ * selectedLocalCandidate: Object,
+ * }
+ *
+ * If users isn't behind NAT, relatedAddress and relatedPort may be null.
+ *
+ * @returns An Object containing the information about private/public IP
+ * addresses and ports.
+ *
+ * For more information see:
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatepairstats
+ * and
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcicecandidatestats
+ * and
+ * https://www.w3.org/TR/webrtc/#rtcicecandidatetype-enum
+ */
+ async getInternalExternalIpAddresses(stats) {
+ let transports = {};
+
+ if (stats) {
+ const selectedLocalCandidate = this.getSelectedLocalCandidate(stats);
+
+ if (!selectedLocalCandidate) return transports;
+
+ const candidateType =
+ selectedLocalCandidate.candidateType || selectedLocalCandidate.type;
+
+ transports = {
+ isUsingTurn: candidateType === 'relay',
+ address: selectedLocalCandidate.address,
+ relatedAddress: selectedLocalCandidate.relatedAddress,
+ port: selectedLocalCandidate.port,
+ relatedPort: selectedLocalCandidate.relatedPort,
+ candidateType,
+ selectedLocalCandidate,
+ };
+ }
+
+ return transports;
+ }
+
+ /**
+ * Get stats about active audio peer.
+ * We filter the status based on FILTER_AUDIO_STATS constant.
+ * We also append to the returned object the information about peer's
+ * transport. This transport information is retrieved by
+ * getInternalExternalIpAddressesFromPeer().
+ *
+ * @returns An Object containing the status about the active audio peer.
+ *
+ * For more information see:
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats
+ * and
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport
+ */
+ async getStats() {
+ if (!this.bridge) return null;
+
+ const peer = this.bridge.getPeerConnection();
+
+ if (!peer) return null;
+
+ const peerStats = await peer.getStats();
+
+ const audioStats = {};
+
+ peerStats.forEach((stat) => {
+ if (FILTER_AUDIO_STATS.includes(stat.type)) {
+ audioStats[stat.id] = stat;
+ }
+ });
+
+ const transportStats = await this.getInternalExternalIpAddresses(
+ audioStats
+ );
+
+ return { transportStats, ...audioStats };
+ }
+}
+
+const audioManager = new AudioManager();
+export default audioManager;
diff --git a/src/2.7.12/imports/ui/services/auth/index.js b/src/2.7.12/imports/ui/services/auth/index.js
new file mode 100644
index 00000000..6a5521db
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/auth/index.js
@@ -0,0 +1,280 @@
+/* eslint prefer-promise-reject-errors: 0 */
+import { Tracker } from 'meteor/tracker';
+
+import Storage from '/imports/ui/services/storage/session';
+
+import { initAnnotationsStreamListener } from '/imports/ui/components/whiteboard/service';
+import allowRedirectToLogoutURL from '/imports/ui/components/meeting-ended/service';
+import { initCursorStreamListener } from '/imports/ui/components/whiteboard/cursors/service';
+import SubscriptionRegistry from '/imports/ui/services/subscription-registry/subscriptionRegistry';
+import { ValidationStates } from '/imports/api/auth-token-validation';
+import logger from '/imports/startup/client/logger';
+
+const CONNECTION_TIMEOUT = Meteor.settings.public.app.connectionTimeout;
+
+class Auth {
+ constructor() {
+ this._loggedIn = {
+ value: false,
+ tracker: new Tracker.Dependency(),
+ };
+
+ const queryParams = new URLSearchParams(document.location.search);
+ if (queryParams.has('sessionToken')
+ && queryParams.get('sessionToken') !== Session.get('sessionToken')) {
+ return;
+ }
+
+ this._meetingID = Storage.getItem('meetingID');
+ this._userID = Storage.getItem('userID');
+ this._authToken = Storage.getItem('authToken');
+ this._sessionToken = Storage.getItem('sessionToken');
+ this._logoutURL = Storage.getItem('logoutURL');
+ this._confname = Storage.getItem('confname');
+ this._externUserID = Storage.getItem('externUserID');
+ this._fullname = Storage.getItem('fullname');
+ this._connectionID = Storage.getItem('connectionID');
+ }
+
+ get meetingID() {
+ return this._meetingID;
+ }
+
+ set meetingID(meetingID) {
+ this._meetingID = meetingID;
+ Storage.setItem('meetingID', this._meetingID);
+ }
+
+ set _connectionID(connectionId) {
+ this._connectionID = connectionId;
+ Storage.setItem('sessionToken', this._connectionID);
+ }
+
+ get sessionToken() {
+ return this._sessionToken;
+ }
+
+ set sessionToken(sessionToken) {
+ this._sessionToken = sessionToken;
+ Storage.setItem('sessionToken', this._sessionToken);
+ }
+
+ get userID() {
+ return this._userID;
+ }
+
+ set userID(userID) {
+ this._userID = userID;
+ Storage.setItem('userID', this._userID);
+ }
+
+ get token() {
+ return this._authToken;
+ }
+
+ set token(authToken) {
+ this._authToken = authToken;
+ Storage.setItem('authToken', this._authToken);
+ }
+
+ set logoutURL(logoutURL) {
+ this._logoutURL = logoutURL;
+ Storage.setItem('logoutURL', this._logoutURL);
+ }
+
+ get logoutURL() {
+ return this._logoutURL;
+ }
+
+ set confname(confname) {
+ this._confname = confname;
+ Storage.setItem('confname', this._confname);
+ }
+
+ get confname() {
+ return this._confname;
+ }
+
+ set externUserID(externUserID) {
+ this._externUserID = externUserID;
+ Storage.setItem('externUserID', this._externUserID);
+ }
+
+ get externUserID() {
+ return this._externUserID;
+ }
+
+ set fullname(fullname) {
+ this._fullname = fullname;
+ Storage.setItem('fullname', this._fullname);
+ }
+
+ get fullname() {
+ return this._fullname;
+ }
+
+ get loggedIn() {
+ this._loggedIn.tracker.depend();
+ return this._loggedIn.value;
+ }
+
+ set loggedIn(value) {
+ this._loggedIn.value = value;
+ this._loggedIn.tracker.changed();
+ }
+
+ get credentials() {
+ return {
+ meetingId: this.meetingID,
+ requesterUserId: this.userID,
+ requesterToken: this.token,
+ logoutURL: this.logoutURL,
+ sessionToken: this.sessionToken,
+ fullname: this.fullname,
+ externUserID: this.externUserID,
+ confname: this.confname,
+ };
+ }
+
+ get fullInfo() {
+ return {
+ sessionToken: this.sessionToken,
+ meetingId: this.meetingID,
+ requesterUserId: this.userID,
+ fullname: this.fullname,
+ confname: this.confname,
+ externUserID: this.externUserID,
+ uniqueClientSession: this.uniqueClientSession,
+ };
+ }
+
+ set(
+ meetingId,
+ requesterUserId,
+ requesterToken,
+ logoutURL,
+ sessionToken,
+ fullname,
+ externUserID,
+ confname,
+ ) {
+ this.meetingID = meetingId;
+ this.userID = requesterUserId;
+ this.token = requesterToken;
+ this.logoutURL = logoutURL;
+ this.sessionToken = sessionToken;
+ this.fullname = fullname;
+ this.externUserID = externUserID;
+ this.confname = confname;
+ }
+
+ clearCredentials(...args) {
+ this.meetingID = null;
+ this.userID = null;
+ this.token = null;
+ this.loggedIn = false;
+ this.logoutURL = null;
+ this.sessionToken = null;
+ this.fullname = null;
+ this.externUserID = null;
+ this.confname = null;
+ this.uniqueClientSession = null;
+ return Promise.resolve(...args);
+ }
+
+ logout() {
+ if (!this.loggedIn) {
+ if (allowRedirectToLogoutURL()) {
+ return Promise.resolve(this._logoutURL);
+ }
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve) => {
+ if (allowRedirectToLogoutURL()) {
+ resolve(this._logoutURL);
+ }
+
+ // do not redirect
+ resolve();
+ });
+ }
+
+ authenticate(force) {
+ if (this.loggedIn && !force) {
+ return Promise.resolve();
+ }
+
+ if (!(this.meetingID && this.userID && this.token)) {
+ return Promise.reject({
+ error: 401,
+ description: Session.get('errorMessageDescription') ? Session.get('errorMessageDescription') : 'Authentication failed due to missing credentials',
+ });
+ }
+
+ this.loggedIn = false;
+ this.isAuthenticating = true;
+
+ return this.validateAuthToken()
+ .then(() => {
+ this.loggedIn = true;
+ this.uniqueClientSession = `${this.sessionToken}-${Math.random().toString(36).substring(6)}`;
+ })
+ .catch((err) => {
+ logger.error(`Failed to validate token: ${err.description}`);
+ Session.set('codeError', err.error);
+ Session.set('errorMessageDescription', err.description);
+ })
+ .finally(() => {
+ this.isAuthenticating = false;
+ });
+ }
+
+ validateAuthToken() {
+ return new Promise((resolve, reject) => {
+ SubscriptionRegistry.createSubscription('current-user');
+ const validationTimeout = setTimeout(() => {
+ reject({
+ error: 408,
+ description: 'Authentication timeout',
+ });
+ }, CONNECTION_TIMEOUT);
+ Meteor.callAsync('validateAuthToken', this.meetingID, this.userID, this.token, this.externUserID)
+ .then((result) => {
+ const authenticationTokenValidation = result;
+ if (!authenticationTokenValidation) return;
+
+ switch (authenticationTokenValidation.validationStatus) {
+ case ValidationStates.INVALID:
+ reject({ error: 403, description: authenticationTokenValidation.reason });
+ break;
+ case ValidationStates.VALIDATED:
+ initCursorStreamListener();
+ initAnnotationsStreamListener();
+ clearTimeout(validationTimeout);
+ this.connectionID = authenticationTokenValidation.connectionId;
+ this.connectionAuthTime = new Date().getTime();
+ Session.set('userWillAuth', false);
+ setTimeout(() => resolve(true), 100);
+ break;
+ default:
+ }
+ });
+ });
+ }
+
+ authenticateURL(url) {
+ let authURL = url;
+ if (authURL.indexOf('sessionToken=') === -1) {
+ if (authURL.indexOf('?') !== -1) {
+ authURL = `${authURL}&sessionToken=${this.sessionToken}`;
+ } else {
+ authURL = `${authURL}?sessionToken=${this.sessionToken}`;
+ }
+ }
+ return authURL;
+ }
+}
+
+const AuthSingleton = new Auth();
+export default AuthSingleton;
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/audio-broker.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/audio-broker.js
new file mode 100644
index 00000000..fcfb576e
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/audio-broker.js
@@ -0,0 +1,260 @@
+import logger from '/imports/startup/client/logger';
+import BaseBroker from '/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker';
+import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
+
+const ON_ICE_CANDIDATE_MSG = 'iceCandidate';
+const SUBSCRIBER_ANSWER = 'subscriberAnswer';
+const DTMF = 'dtmf';
+
+const SFU_COMPONENT_NAME = 'audio';
+
+class AudioBroker extends BaseBroker {
+ constructor(
+ wsUrl,
+ role,
+ options = {},
+ ) {
+ super(SFU_COMPONENT_NAME, wsUrl);
+ this.role = role;
+ this.offering = true;
+
+ // Optional parameters are:
+ // clientSessionNumber
+ // iceServers,
+ // offering,
+ // mediaServer,
+ // extension,
+ // constraints,
+ // stream,
+ // signalCandidates
+ // traceLogs
+ // networkPriority
+ // gatheringTimeout
+ // transparentListenOnly
+ Object.assign(this, options);
+ }
+
+ getLocalStream() {
+ if (this.webRtcPeer && typeof this.webRtcPeer.getLocalStream === 'function') {
+ return this.webRtcPeer.getLocalStream();
+ }
+
+ return null;
+ }
+
+ setLocalStream(stream) {
+ if (this.webRtcPeer == null || this.webRtcPeer.peerConnection == null) {
+ throw new Error('Missing peer connection');
+ }
+
+ const { peerConnection } = this.webRtcPeer;
+ const newTracks = stream.getAudioTracks();
+ const localStream = this.getLocalStream();
+ const oldTracks = localStream ? localStream.getAudioTracks() : [];
+
+ peerConnection.getSenders().forEach((sender, index) => {
+ if (sender.track && sender.track.kind === 'audio') {
+ const newTrack = newTracks[index];
+ if (newTrack == null) return;
+
+ // Cleanup old tracks in the local MediaStream
+ const oldTrack = oldTracks[index];
+ sender.replaceTrack(newTrack);
+ if (oldTrack) {
+ oldTrack.stop();
+ localStream.removeTrack(oldTrack);
+ }
+ localStream.addTrack(newTrack);
+ }
+ });
+
+ return Promise.resolve();
+ }
+
+ _join() {
+ return new Promise((resolve, reject) => {
+ try {
+ const options = {
+ audioStream: this.stream,
+ mediaConstraints: {
+ audio: this.constraints ? this.constraints : true,
+ video: false,
+ },
+ configuration: this.populatePeerConfiguration(),
+ onicecandidate: !this.signalCandidates ? null : (candidate) => {
+ this.onIceCandidate(candidate, this.role);
+ },
+ trace: this.traceLogs,
+ networkPriorities: this.networkPriority ? { audio: this.networkPriority } : undefined,
+ mediaStreamFactory: this.mediaStreamFactory,
+ gatheringTimeout: this.gatheringTimeout,
+ };
+
+ const peerRole = this.role === 'sendrecv' ? this.role : 'recvonly';
+ this.webRtcPeer = new WebRtcPeer(peerRole, options);
+ this.webRtcPeer.iceQueue = [];
+ this.webRtcPeer.start();
+ this.webRtcPeer.peerConnection.onconnectionstatechange = this.handleConnectionStateChange.bind(this);
+
+ if (this.offering) {
+ // We are the offerer
+ this.webRtcPeer.generateOffer()
+ .then(this.sendStartReq.bind(this))
+ .catch(this._handleOfferGenerationFailure.bind(this));
+ } else if (peerRole === 'recvonly') {
+ // We are the answerer and we are only listening, so we don't need
+ // to acquire local media
+ this.sendStartReq();
+ } else {
+ // We are the answerer and we are sending audio, so we need to acquire
+ // local media before sending the start request
+ this.webRtcPeer.mediaStreamFactory()
+ .then(() => { this.sendStartReq(); })
+ .catch(this._handleOfferGenerationFailure.bind(this));
+ }
+
+ resolve();
+ } catch (error) {
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ const normalizedError = BaseBroker.assembleError(1305);
+ logger.error({
+ logCode: `${this.logCodePrefix}_peer_creation_failed`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ errorCode: normalizedError.errorCode,
+ sfuComponent: this.sfuComponent,
+ started: this.started,
+ },
+ }, 'Audio peer creation failed');
+ this.onerror(normalizedError);
+ reject(normalizedError);
+ }
+ });
+ }
+
+ joinAudio() {
+ return this.openWSConnection()
+ .then(this._join.bind(this));
+ }
+
+ onWSMessage(message) {
+ const parsedMessage = JSON.parse(message.data);
+
+ switch (parsedMessage.id) {
+ case 'startResponse':
+ this.onRemoteDescriptionReceived(parsedMessage);
+ break;
+ case 'iceCandidate':
+ this.handleIceCandidate(parsedMessage.candidate);
+ break;
+ case 'webRTCAudioSuccess':
+ this.onstart(parsedMessage.success);
+ this.started = true;
+ break;
+ case 'webRTCAudioError':
+ case 'error':
+ this.handleSFUError(parsedMessage);
+ break;
+ case 'pong':
+ break;
+ default:
+ logger.debug({
+ logCode: `${this.logCodePrefix}_invalid_req`,
+ extraInfo: { messageId: parsedMessage.id || 'Unknown', sfuComponent: this.sfuComponent },
+ }, 'Discarded invalid SFU message');
+ }
+ }
+
+ handleSFUError(sfuResponse) {
+ const { code, reason, role } = sfuResponse;
+ const error = BaseBroker.assembleError(code, reason);
+
+ logger.error({
+ logCode: `${this.logCodePrefix}_sfu_error`,
+ extraInfo: {
+ errorCode: code,
+ errorMessage: error.errorMessage,
+ role,
+ sfuComponent: this.sfuComponent,
+ started: this.started,
+ },
+ }, 'Audio failed in SFU');
+ this.onerror(error);
+ }
+
+ sendLocalDescription(localDescription) {
+ const message = {
+ id: SUBSCRIBER_ANSWER,
+ type: this.sfuComponent,
+ role: this.role,
+ sdpOffer: localDescription,
+ };
+
+ this.sendMessage(message);
+ }
+
+ onRemoteDescriptionReceived(sfuResponse) {
+ if (this.offering) {
+ return this.processAnswer(sfuResponse);
+ }
+
+ return this.processOffer(sfuResponse);
+ }
+
+ sendStartReq(offer) {
+ const message = {
+ id: 'start',
+ type: this.sfuComponent,
+ role: this.role,
+ clientSessionNumber: this.clientSessionNumber,
+ sdpOffer: offer,
+ mediaServer: this.mediaServer,
+ extension: this.extension,
+ transparentListenOnly: this.transparentListenOnly,
+ };
+
+ logger.debug({
+ logCode: `${this.logCodePrefix}_offer_generated`,
+ extraInfo: { sfuComponent: this.sfuComponent, role: this.role },
+ }, 'SFU audio offer generated');
+
+ this.sendMessage(message);
+ }
+
+ _handleOfferGenerationFailure(error) {
+ if (error) {
+ logger.error({
+ logCode: `${this.logCodePrefix}_offer_failure`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ sfuComponent: this.sfuComponent,
+ },
+ }, 'Audio offer generation failed');
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ this.onerror(error);
+ }
+ }
+
+ dtmf(tones) {
+ const message = {
+ id: DTMF,
+ type: this.sfuComponent,
+ tones,
+ };
+
+ this.sendMessage(message);
+ }
+
+ onIceCandidate(candidate, role) {
+ const message = {
+ id: ON_ICE_CANDIDATE_MSG,
+ role,
+ type: this.sfuComponent,
+ candidate,
+ };
+
+ this.sendMessage(message);
+ }
+}
+
+export default AudioBroker;
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/broker-base-errors.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/broker-base-errors.js
new file mode 100644
index 00000000..5eebb902
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/broker-base-errors.js
@@ -0,0 +1,37 @@
+const SFU_CLIENT_SIDE_ERRORS = {
+ // 13xx errors are client-side bbb-webrtc-sfu's base broker errors
+ 1301: "WEBSOCKET_DISCONNECTED",
+ 1302: "WEBSOCKET_CONNECTION_FAILED",
+ 1305: "PEER_NEGOTIATION_FAILED",
+ 1307: "ICE_STATE_FAILED",
+ 1310: "ENDED_WHILE_STARTING",
+};
+
+const SFU_SERVER_SIDE_ERRORS = {
+ // 2xxx codes are server-side bbb-webrtc-sfu errors
+ 2000: "MEDIA_SERVER_CONNECTION_ERROR",
+ 2001: "MEDIA_SERVER_OFFLINE",
+ 2002: "MEDIA_SERVER_NO_RESOURCES",
+ 2003: "MEDIA_SERVER_REQUEST_TIMEOUT",
+ 2004: "MEDIA_SERVER_GENERIC_ERROR",
+ 2020: "ICE_ADD_CANDIDATE_FAILED",
+ 2021: "ICE_GATHERING_FAILED",
+ 2022: "ICE_STATE_FAILED",
+ 2200: "MEDIA_GENERIC_ERROR",
+ 2201: "MEDIA_NOT_FOUND",
+ 2202: "MEDIA_INVALID_SDP",
+ 2203: "MEDIA_NO_AVAILABLE_CODEC",
+ 2208: "MEDIA_GENERIC_PROCESS_ERROR",
+ 2209: "MEDIA_ADAPTER_OBJECT_NOT_FOUND",
+ 2210: "MEDIA_CONNECT_ERROR",
+ 2211: "MEDIA_NOT_FLOWING",
+ 2300: "SFU_INVALID_REQUEST",
+};
+
+const SFU_BROKER_ERRORS = { ...SFU_SERVER_SIDE_ERRORS, ...SFU_CLIENT_SIDE_ERRORS };
+
+export {
+ SFU_CLIENT_SIDE_ERRORS,
+ SFU_SERVER_SIDE_ERRORS,
+ SFU_BROKER_ERRORS,
+};
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/load-play.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/load-play.js
new file mode 100644
index 00000000..1f9b02ae
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/load-play.js
@@ -0,0 +1,32 @@
+import playAndRetry from '/imports/utils/mediaElementPlayRetry';
+
+const playMediaElement = (mediaElement) => {
+ return new Promise((resolve, reject) => {
+ if (mediaElement.paused) {
+ // Tag isn't playing yet. Play it.
+ mediaElement.play()
+ .then(resolve)
+ .catch((error) => {
+ if (error.name === 'NotAllowedError') return reject(error);
+ // Tag failed for reasons other than autoplay. Log the error and
+ // try playing again a few times until it works or fails for good
+ const played = playAndRetry(mediaElement);
+ if (!played) return reject(error);
+ return resolve();
+ });
+ } else {
+ // Media tag is already playing, so log a success. This is really a
+ // logging fallback for a case that shouldn't happen. But if it does
+ // (ie someone re-enables the autoPlay prop in the mediaElement), then it
+ // means the mediaStream is playing properly and it'll be logged.
+ return resolve();
+ }
+ });
+}
+
+export default function loadAndPlayMediaStream (mediaStream, mediaElement, muted = true) {
+ mediaElement.muted = muted;
+ mediaElement.pause();
+ mediaElement.srcObject = mediaStream;
+ return playMediaElement(mediaElement);
+}
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/screenshare-broker.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/screenshare-broker.js
new file mode 100644
index 00000000..e6aaa38b
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/screenshare-broker.js
@@ -0,0 +1,296 @@
+import logger from '/imports/startup/client/logger';
+import BaseBroker from '/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker';
+import WebRtcPeer from '/imports/ui/services/webrtc-base/peer';
+
+const ON_ICE_CANDIDATE_MSG = 'iceCandidate';
+const SUBSCRIBER_ANSWER = 'subscriberAnswer';
+const SFU_COMPONENT_NAME = 'screenshare';
+
+class ScreenshareBroker extends BaseBroker {
+ constructor(
+ wsUrl,
+ voiceBridge,
+ userId,
+ internalMeetingId,
+ role,
+ options = {},
+ ) {
+ super(SFU_COMPONENT_NAME, wsUrl);
+ this.voiceBridge = voiceBridge;
+ this.userId = userId;
+ this.internalMeetingId = internalMeetingId;
+ this.role = role;
+ this.ws = null;
+ this.webRtcPeer = null;
+ this.hasAudio = false;
+ this.contentType = "camera";
+ this.offering = true;
+ this.signalCandidates = true;
+ this.ending = false;
+
+ // Optional parameters are:
+ // userName,
+ // caleeName,
+ // iceServers,
+ // hasAudio,
+ // contentType,
+ // bitrate,
+ // offering,
+ // mediaServer,
+ // signalCandidates,
+ // traceLogs
+ // networkPriority
+ // gatheringTimeout
+ Object.assign(this, options);
+ }
+
+ _onstreamended() {
+ // Flag the broker as ending; we want to abort processing start responses
+ this.ending = true;
+ this.onstreamended();
+ }
+
+ onstreamended() {
+ // To be implemented by instantiators
+ }
+
+ async share () {
+ return new Promise((resolve, reject) => {
+ if (this.stream == null) {
+ logger.error({
+ logCode: `${this.logCodePrefix}_missing_stream`,
+ extraInfo: { role: this.role, sfuComponent: this.sfuComponent },
+ }, 'Screenshare broker start failed: missing stream');
+ return reject(BaseBroker.assembleError(1305));
+ }
+
+ return this.openWSConnection()
+ .then(this.startScreensharing.bind(this))
+ .then(resolve)
+ .catch(reject);
+ });
+ }
+
+ view () {
+ return this.openWSConnection()
+ .then(this.subscribeToScreenStream.bind(this));
+ }
+
+ onWSMessage (message) {
+ const parsedMessage = JSON.parse(message.data);
+
+ switch (parsedMessage.id) {
+ case 'startResponse':
+ if (!this.ending && !this.started) {
+ this.onRemoteDescriptionReceived(parsedMessage);
+ }
+ break;
+ case 'playStart':
+ if (!this.ending && !this.started) {
+ this.onstart();
+ this.started = true;
+ }
+ break;
+ case 'stopSharing':
+ this.stop();
+ break;
+ case 'iceCandidate':
+ this.handleIceCandidate(parsedMessage.candidate);
+ break;
+ case 'error':
+ this.handleSFUError(parsedMessage);
+ break;
+ case 'pong':
+ break;
+ default:
+ logger.debug({
+ logCode: `${this.logCodePrefix}_invalid_req`,
+ extraInfo: {
+ messageId: parsedMessage.id || 'Unknown',
+ sfuComponent: this.sfuComponent,
+ role: this.role,
+ }
+ }, `Discarded invalid SFU message`);
+ }
+ }
+
+ handleSFUError (sfuResponse) {
+ const { code, reason } = sfuResponse;
+ const error = BaseBroker.assembleError(code, reason);
+
+ logger.error({
+ logCode: `${this.logCodePrefix}_sfu_error`,
+ extraInfo: {
+ errorCode: code,
+ errorMessage: error.errorMessage,
+ role: this.role,
+ sfuComponent: this.sfuComponent,
+ started: this.started,
+ },
+ }, `Screen sharing failed in SFU`);
+ this.onerror(error);
+ }
+
+ sendLocalDescription (localDescription) {
+ const message = {
+ id: SUBSCRIBER_ANSWER,
+ type: this.sfuComponent,
+ role: this.role,
+ voiceBridge: this.voiceBridge,
+ callerName: this.userId,
+ answer: localDescription,
+ };
+
+ this.sendMessage(message);
+ }
+
+ onRemoteDescriptionReceived (sfuResponse) {
+ if (this.offering) {
+ return this.processAnswer(sfuResponse);
+ }
+
+ return this.processOffer(sfuResponse);
+ }
+
+ sendStartReq(offer) {
+ const message = {
+ id: 'start',
+ type: this.sfuComponent,
+ role: this.role,
+ internalMeetingId: this.internalMeetingId,
+ voiceBridge: this.voiceBridge,
+ userName: this.userName,
+ callerName: this.userId,
+ sdpOffer: offer,
+ hasAudio: !!this.hasAudio,
+ contentType: this.contentType,
+ bitrate: this.bitrate,
+ mediaServer: this.mediaServer,
+ };
+
+ this.sendMessage(message);
+ }
+
+ _handleOfferGenerationFailure(error) {
+ logger.error({
+ logCode: `${this.logCodePrefix}_offer_failure`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ role: this.role,
+ sfuComponent: this.sfuComponent,
+ },
+ }, 'Screenshare offer generation failed');
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ return this.onerror(error);
+ }
+
+ startScreensharing() {
+ return new Promise((resolve, reject) => {
+ try {
+ const options = {
+ onicecandidate: this.signalCandidates ? this.onIceCandidate.bind(this) : null,
+ videoStream: this.stream,
+ configuration: this.populatePeerConfiguration(),
+ trace: this.traceLogs,
+ networkPriorities: this.networkPriority ? { video: this.networkPriority } : undefined,
+ gatheringTimeout: this.gatheringTimeout,
+ };
+ this.webRtcPeer = new WebRtcPeer('sendonly', options);
+ this.webRtcPeer.iceQueue = [];
+ this.webRtcPeer.start();
+ this.webRtcPeer.peerConnection.onconnectionstatechange = () => {
+ this.handleConnectionStateChange('screenshare');
+ };
+
+ if (this.offering) {
+ this.webRtcPeer.generateOffer()
+ .then(this.sendStartReq.bind(this))
+ .catch(this._handleOfferGenerationFailure.bind(this));
+ } else {
+ this.sendStartReq();
+ }
+
+ resolve();
+ } catch (error) {
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ const normalizedError = BaseBroker.assembleError(1305);
+ logger.error({
+ logCode: `${this.logCodePrefix}_peer_creation_failed`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ errorCode: normalizedError.errorCode,
+ role: this.role,
+ sfuComponent: this.sfuComponent,
+ started: this.started,
+ },
+ }, 'Screenshare peer creation failed');
+ this.onerror(normalizedError);
+ reject(normalizedError);
+ }
+ });
+ }
+
+ onIceCandidate (candidate) {
+ const message = {
+ id: ON_ICE_CANDIDATE_MSG,
+ role: this.role,
+ type: this.sfuComponent,
+ voiceBridge: this.voiceBridge,
+ candidate,
+ callerName: this.userId,
+ };
+
+ this.sendMessage(message);
+ }
+
+ subscribeToScreenStream() {
+ return new Promise((resolve, reject) => {
+ try {
+ const options = {
+ mediaConstraints: {
+ audio: !!this.hasAudio,
+ },
+ onicecandidate: this.signalCandidates ? this.onIceCandidate.bind(this) : null,
+ configuration: this.populatePeerConfiguration(),
+ trace: this.traceLogs,
+ gatheringTimeout: this.gatheringTimeout,
+ };
+
+ this.webRtcPeer = new WebRtcPeer('recvonly', options);
+ this.webRtcPeer.iceQueue = [];
+ this.webRtcPeer.start();
+ this.webRtcPeer.peerConnection.onconnectionstatechange = () => {
+ this.handleConnectionStateChange('screenshare');
+ };
+
+ if (this.offering) {
+ this.webRtcPeer.generateOffer()
+ .then(this.sendStartReq.bind(this))
+ .catch(this._handleOfferGenerationFailure.bind(this));
+ } else {
+ this.sendStartReq();
+ }
+
+ resolve();
+ } catch (error) {
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ const normalizedError = BaseBroker.assembleError(1305);
+ logger.error({
+ logCode: `${this.logCodePrefix}_peer_creation_failed`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ errorCode: normalizedError.errorCode,
+ role: this.role,
+ sfuComponent: this.sfuComponent,
+ started: this.started,
+ },
+ }, 'Screenshare peer creation failed');
+ this.onerror(normalizedError);
+ reject(normalizedError);
+
+ }
+ });
+ }
+}
+
+export default ScreenshareBroker;
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js
new file mode 100644
index 00000000..3e34314d
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js
@@ -0,0 +1,366 @@
+import logger from '/imports/startup/client/logger';
+import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
+import { SFU_BROKER_ERRORS } from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors';
+
+const WS_HEARTBEAT_OPTS = {
+ interval: 15000,
+ delay: 3000,
+};
+
+class BaseBroker {
+ static assembleError(code, reason) {
+ const message = reason || SFU_BROKER_ERRORS[code];
+ const error = new Error(message);
+ error.errorCode = code;
+ // Duplicating key-vals because we can't settle on an error pattern... - prlanzarin
+ error.errorCause = error.message;
+ error.errorMessage = error.message;
+
+ return error;
+ }
+
+ constructor(sfuComponent, wsUrl) {
+ this.wsUrl = wsUrl;
+ this.sfuComponent = sfuComponent;
+ this.ws = null;
+ this.webRtcPeer = null;
+ this.wsHeartbeat = null;
+ this.started = false;
+ this.signallingTransportOpen = false;
+ this.logCodePrefix = `${this.sfuComponent}_broker`;
+ this.peerConfiguration = {};
+
+ this.onbeforeunload = this.onbeforeunload.bind(this);
+ this._onWSError = this._onWSError.bind(this);
+ window.addEventListener('beforeunload', this.onbeforeunload);
+ }
+
+ set started (val) {
+ this._started = val;
+ }
+
+ get started () {
+ return this._started;
+ }
+
+ onbeforeunload () {
+ return this.stop();
+ }
+
+ onstart () {
+ // To be implemented by inheritors
+ }
+
+ onerror (error) {
+ // To be implemented by inheritors
+ }
+
+ onended () {
+ // To be implemented by inheritors
+ }
+
+ handleSFUError (sfuResponse) {
+ // To be implemented by inheritors
+ }
+
+ sendLocalDescription (localDescription) {
+ // To be implemented by inheritors
+ }
+
+ _onWSMessage(message) {
+ this._updateLastMsgTime();
+ this.onWSMessage(message);
+ }
+
+ onWSMessage(message) {
+ // To be implemented by inheritors
+ }
+
+ _onWSError(error) {
+ let normalizedError;
+
+ logger.error({
+ logCode: `${this.logCodePrefix}_websocket_error`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ sfuComponent: this.sfuComponent,
+ }
+ }, 'WebSocket connection to SFU failed');
+
+ if (this.signallingTransportOpen) {
+ // 1301: "WEBSOCKET_DISCONNECTED", transport was already open
+ normalizedError = BaseBroker.assembleError(1301);
+ } else {
+ // 1302: "WEBSOCKET_CONNECTION_FAILED", transport errored before establishment
+ normalizedError = BaseBroker.assembleError(1302);
+ }
+
+ this.onerror(normalizedError);
+ return normalizedError;
+ }
+
+ openWSConnection () {
+ return new Promise((resolve, reject) => {
+ this.ws = new WebSocket(this.wsUrl);
+
+ this.ws.onmessage = this._onWSMessage.bind(this);
+
+ this.ws.onclose = () => {
+ // 1301: "WEBSOCKET_DISCONNECTED",
+ this.onerror(BaseBroker.assembleError(1301));
+ };
+
+ this.ws.onerror = (error) => reject(this._onWSError(error));
+
+ this.ws.onopen = () => {
+ this.setupWSHeartbeat();
+ this.signallingTransportOpen = true;
+ return resolve();
+ };
+ });
+ }
+
+ closeWs() {
+ this.clearWSHeartbeat();
+
+ if (this.ws !== null) {
+ this.ws.onclose = function (){};
+ this.ws.close();
+ }
+ }
+
+ _updateLastMsgTime() {
+ this.ws.isAlive = true;
+ this.ws.lastMsgTime = Date.now();
+ }
+
+ _getTimeSinceLastMsg() {
+ return Date.now() - this.ws.lastMsgTime;
+ }
+
+ setupWSHeartbeat() {
+ if (WS_HEARTBEAT_OPTS.interval === 0 || this.ws == null) return;
+
+ this.ws.isAlive = true;
+ this.wsHeartbeat = setInterval(() => {
+ if (this.ws.isAlive === false) {
+ logger.warn({
+ logCode: `${this.logCodePrefix}_ws_heartbeat_failed`,
+ }, `WS heartbeat failed (${this.sfuComponent})`);
+ this.closeWs();
+ this._onWSError(new Error('HeartbeatFailed'));
+ return;
+ }
+
+ if (this._getTimeSinceLastMsg() < (
+ WS_HEARTBEAT_OPTS.interval - WS_HEARTBEAT_OPTS.delay
+ )) {
+ return;
+ }
+
+ this.ws.isAlive = false;
+ this.ping();
+ }, WS_HEARTBEAT_OPTS.interval);
+
+ this.ping();
+ }
+
+ clearWSHeartbeat() {
+ if (this.wsHeartbeat) {
+ clearInterval(this.wsHeartbeat);
+ }
+ }
+
+ sendMessage (message) {
+ const jsonMessage = JSON.stringify(message);
+
+ try {
+ this.ws.send(jsonMessage);
+ } catch (error) {
+ logger.error({
+ logCode: `${this.logCodePrefix}_ws_send_error`,
+ extraInfo: {
+ errorName: error.name,
+ errorMessage: error.message,
+ sfuComponent: this.sfuComponent,
+ },
+ }, `Failed to send WebSocket message (${this.sfuComponent})`);
+ }
+ }
+
+ ping () {
+ this.sendMessage({ id: 'ping' });
+ }
+
+ _processRemoteDescription(localDescription = null) {
+ // There is a new local description; send it back to the server
+ if (localDescription) this.sendLocalDescription(localDescription);
+ // Mark the peer as negotiated and flush the ICE queue
+ this.webRtcPeer.negotiated = true;
+ this.processIceQueue();
+ }
+
+ _validateStartResponse (sfuResponse) {
+ const { response, role } = sfuResponse;
+
+ if (response !== 'accepted') {
+ this.handleSFUError(sfuResponse);
+ return false;
+ }
+
+ logger.debug({
+ logCode: `${this.logCodePrefix}_start_success`,
+ extraInfo: {
+ role,
+ sfuComponent: this.sfuComponent,
+ }
+ }, `Start request accepted for ${this.sfuComponent}`);
+
+ return true;
+ }
+
+ processOffer(sfuResponse) {
+ if (this._validateStartResponse(sfuResponse)) {
+ this.webRtcPeer.processOffer(sfuResponse.sdpAnswer)
+ .then(this._processRemoteDescription.bind(this))
+ .catch((error) => {
+ logger.error({
+ logCode: `${this.logCodePrefix}_processoffer_error`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ sfuComponent: this.sfuComponent,
+ },
+ }, `Error processing offer from SFU for ${this.sfuComponent}`);
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ this.onerror(BaseBroker.assembleError(1305));
+ });
+ }
+ }
+
+ processAnswer(sfuResponse) {
+ if (this._validateStartResponse(sfuResponse)) {
+ this.webRtcPeer.processAnswer(sfuResponse.sdpAnswer)
+ .then(this._processRemoteDescription.bind(this))
+ .catch((error) => {
+ logger.error({
+ logCode: `${this.logCodePrefix}_processanswer_error`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ sfuComponent: this.sfuComponent,
+ },
+ }, `Error processing answer from SFU for ${this.sfuComponent}`);
+ // 1305: "PEER_NEGOTIATION_FAILED",
+ this.onerror(BaseBroker.assembleError(1305));
+ });
+ }
+ }
+
+ populatePeerConfiguration () {
+ this.addIceServers();
+ if (this.forceRelay) {
+ this.setRelayTransportPolicy();
+ }
+
+ return this.peerConfiguration;
+ }
+
+ addIceServers () {
+ if (this.iceServers && this.iceServers.length > 0) {
+ this.peerConfiguration.iceServers = this.iceServers;
+ }
+ }
+
+ setRelayTransportPolicy () {
+ this.peerConfiguration.iceTransportPolicy = 'relay';
+ }
+
+ handleConnectionStateChange (eventIdentifier) {
+ if (this.webRtcPeer) {
+ const { peerConnection } = this.webRtcPeer;
+ const connectionState = peerConnection.connectionState;
+ if (eventIdentifier) {
+ notifyStreamStateChange(eventIdentifier, connectionState);
+ }
+
+ if (connectionState === 'failed' || connectionState === 'closed') {
+ if (this.webRtcPeer?.peerConnection) {
+ this.webRtcPeer.peerConnection.onconnectionstatechange = null;
+ }
+ // 1307: "ICE_STATE_FAILED",
+ const error = BaseBroker.assembleError(1307);
+ this.onerror(error);
+ }
+ }
+ }
+
+ addIceCandidate(candidate) {
+ this.webRtcPeer.addIceCandidate(candidate).catch((error) => {
+ // Just log the error. We can't be sure if a candidate failure on add is
+ // fatal or not, so that's why we have a timeout set up for negotiations and
+ // listeners for ICE state transitioning to failures, so we won't act on it here
+ logger.error({
+ logCode: `${this.logCodePrefix}_addicecandidate_error`,
+ extraInfo: {
+ errorMessage: error.name || error.message || 'Unknown error',
+ errorCode: error.code || 'Unknown code',
+ sfuComponent: this.sfuComponent,
+ started: this.started,
+ },
+ }, 'Adding ICE candidate failed');
+ });
+ }
+
+ processIceQueue () {
+ const peer = this.webRtcPeer;
+ while (peer.iceQueue.length) {
+ const candidate = peer.iceQueue.shift();
+ this.addIceCandidate(candidate);
+ }
+ }
+
+ handleIceCandidate (candidate) {
+ const peer = this.webRtcPeer;
+
+ if (peer.negotiated) {
+ this.addIceCandidate(candidate);
+ } else {
+ // ICE candidates are queued until a SDP answer has been processed.
+ // This was done due to a long term iOS/Safari quirk where it'd (as of 2018)
+ // fail if candidates were added before the offer/answer cycle was completed.
+ // IT STILL HAPPENS - prlanzarin sept 2019
+ // still happens - prlanzarin sept 2020
+ peer.iceQueue.push(candidate);
+ }
+ }
+
+ disposePeer () {
+ if (this.webRtcPeer) {
+ this.webRtcPeer.dispose();
+ this.webRtcPeer = null;
+ }
+ }
+
+ stop () {
+ this.onstart = function(){};
+ this.onerror = function(){};
+ window.removeEventListener('beforeunload', this.onbeforeunload);
+
+ if (this.webRtcPeer?.peerConnection) {
+ this.webRtcPeer.peerConnection.onconnectionstatechange = null;
+ }
+
+ this.closeWs();
+ this.disposePeer();
+ this.started = false;
+
+ logger.debug({
+ logCode: `${this.logCodePrefix}_stop`,
+ extraInfo: { sfuComponent: this.sfuComponent },
+ }, `Stopped broker session for ${this.sfuComponent}`);
+
+ this.onended();
+ this.onended = function(){};
+ }
+}
+
+export default BaseBroker;
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js
new file mode 100644
index 00000000..f45fefdf
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js
@@ -0,0 +1,45 @@
+/*
+ * The idea behind this whole utilitary is proving a decoupled way of propagating
+ * peer connection states up and down the component tree without coming up with
+ * weird trackers, hooks and/or prop drilling. This is mainly aimed at component
+ * trees that aren't well organized in the first place (ie video-provider).
+ * The base use case for this is notifying stream state changes to correctly
+ * handle UI for reconnection scenarios.
+ */
+
+const STREAM_STATE_CHANGED_EVENT_PREFIX = 'streamStateChanged';
+
+/*
+ * The event name format for notify/subscribe/unsubscribe is
+ * `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`. eventTag can be any string.
+ * streamState must be a valid member of either RTCIceConnectionState or
+ * RTCPeerConnectionState enums
+ */
+export const notifyStreamStateChange = (eventTag, streamState) => {
+ const eventName = `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`;
+ const streamStateChanged = new CustomEvent(
+ eventName,
+ { detail: { eventTag, streamState } },
+ );
+ window.dispatchEvent(streamStateChanged);
+}
+
+// `callback` is the method to be called when a new state is notified
+// via notifyStreamStateChange
+export const subscribeToStreamStateChange = (eventTag, callback) => {
+ const eventName = `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`;
+ window.addEventListener(eventName, callback, false);
+}
+
+export const unsubscribeFromStreamStateChange = (eventTag, callback) => {
+ const eventName = `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`;
+ window.removeEventListener(eventName, callback);
+}
+
+export const isStreamStateUnhealthy = (streamState) => {
+ return streamState === 'failed' || streamState === 'closed';
+}
+
+export const isStreamStateHealthy = (streamState) => {
+ return streamState === 'connected' || streamState === 'completed';
+}
diff --git a/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/utils.js b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/utils.js
new file mode 100644
index 00000000..0675388d
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/bbb-webrtc-sfu/utils.js
@@ -0,0 +1,25 @@
+import browserInfo from '/imports/utils/browserInfo';
+import deviceInfo from '/imports/utils/deviceInfo';
+import { hasTurnServer } from '/imports/utils/fetchStunTurnServers';
+
+const FORCE_RELAY_ON_FF = Meteor.settings.public.media.forceRelayOnFirefox;
+const FORCE_RELAY = Meteor.settings.public.media.forceRelay;
+
+/*
+ * Whether TURN/relay usage should be forced to work around Firefox's lack of
+ * support for regular nomination when dealing with ICE-litee peers (e.g.:
+ * mediasoup). See: https://bugzilla.mozilla.org/show_bug.cgi?id=1034964
+ *
+ * iOS endpoints are ignored from the trigger because _all_ iOS browsers
+ * are either native WebKit or WKWebView based (so they shouldn't be affected)
+ */
+const shouldForceRelay = () => {
+ const { isFirefox } = browserInfo;
+ const { isIos } = deviceInfo;
+
+ return FORCE_RELAY || ((isFirefox && !isIos) && FORCE_RELAY_ON_FF && hasTurnServer());
+};
+
+export {
+ shouldForceRelay,
+};
diff --git a/src/2.7.12/imports/ui/services/features/index.js b/src/2.7.12/imports/ui/services/features/index.js
new file mode 100644
index 00000000..3740cec5
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/features/index.js
@@ -0,0 +1,107 @@
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+
+export function getDisabledFeatures() {
+ const selector = {
+ meetingId: Auth.meetingID,
+ };
+
+ const meetingData = Meetings.findOne(selector, { fields: { 'meetingProp.disabledFeatures': 1 } });
+ const disabledFeatures = ((meetingData || {}).meetingProp || {}).disabledFeatures || [];
+
+ return disabledFeatures;
+}
+
+export function isScreenSharingEnabled() {
+ return getDisabledFeatures().indexOf('screenshare') === -1 && Meteor.settings.public.kurento.enableScreensharing;
+}
+
+export function isLearningDashboardEnabled() {
+ return getDisabledFeatures().indexOf('learningDashboard') === -1;
+}
+
+export function isPollingEnabled() {
+ return getDisabledFeatures().indexOf('polls') === -1 && Meteor.settings.public.poll.enabled;
+}
+
+export function isExternalVideoEnabled() {
+ return getDisabledFeatures().indexOf('externalVideos') === -1 && Meteor.settings.public.externalVideoPlayer.enabled;
+}
+
+export function isChatEnabled() {
+ return getDisabledFeatures().indexOf('chat') === -1 && Meteor.settings.public.chat.enabled;
+}
+
+export function isSharedNotesEnabled() {
+ return getDisabledFeatures().indexOf('sharedNotes') === -1 && Meteor.settings.public.notes.enabled;
+}
+
+export function isCaptionsEnabled() {
+ return getDisabledFeatures().indexOf('captions') === -1 && Meteor.settings.public.captions.enabled;
+}
+
+export function isLiveTranscriptionEnabled() {
+ return getDisabledFeatures().indexOf('liveTranscription') === -1 && Meteor.settings.public.app.audioCaptions.enabled;
+}
+
+export function isBreakoutRoomsEnabled() {
+ return getDisabledFeatures().indexOf('breakoutRooms') === -1;
+}
+
+export function isLayoutsEnabled() {
+ return getDisabledFeatures().indexOf('layouts') === -1;
+}
+
+export function isVirtualBackgroundsEnabled() {
+ return getDisabledFeatures().indexOf('virtualBackgrounds') === -1 && Meteor.settings.public.virtualBackgrounds.enabled;
+}
+
+export function isCustomVirtualBackgroundsEnabled() {
+ return getDisabledFeatures().indexOf('customVirtualBackgrounds') === -1;
+}
+
+export function isDownloadPresentationWithAnnotationsEnabled() {
+ return getDisabledFeatures().indexOf('downloadPresentationWithAnnotations') === -1 && Meteor.settings.public.presentation.allowDownloadWithAnnotations;
+}
+
+export function isDownloadPresentationConvertedToPdfEnabled() {
+ return getDisabledFeatures().indexOf('downloadPresentationConvertedToPdf') === -1;
+}
+
+export function isDownloadPresentationOriginalFileEnabled() {
+ return getDisabledFeatures().indexOf('downloadPresentationOriginalFile') === -1 && Meteor.settings.public.presentation.allowDownloadOriginal;
+}
+
+export function isSnapshotOfCurrentSlideEnabled() {
+ return getDisabledFeatures().indexOf('snapshotOfCurrentSlide') === -1 && Meteor.settings.public.presentation.allowSnapshotOfCurrentSlide;
+}
+
+export function isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled() {
+ return getDisabledFeatures().indexOf('importPresentationWithAnnotationsFromBreakoutRooms') === -1;
+}
+
+export function isImportSharedNotesFromBreakoutRoomsEnabled() {
+ return getDisabledFeatures().indexOf('importSharedNotesFromBreakoutRooms') === -1;
+}
+
+export function isPresentationEnabled() {
+ return getDisabledFeatures().indexOf('presentation') === -1;
+}
+
+export function isReactionsEnabled() {
+ const USER_REACTIONS_ENABLED = Meteor.settings.public.userReaction.enabled;
+ const REACTIONS_BUTTON_ENABLED = Meteor.settings.public.app.reactionsButton.enabled;
+
+ return getDisabledFeatures().indexOf('reactions') === -1 && USER_REACTIONS_ENABLED && REACTIONS_BUTTON_ENABLED;
+}
+
+export function isTimerFeatureEnabled() {
+ return getDisabledFeatures().indexOf('timer') === -1 && Meteor.settings.public.timer.enabled;
+}
+
+export function isCameraAsContentEnabled() {
+ return (
+ getDisabledFeatures().indexOf('cameraAsContent') === -1 &&
+ Meteor.settings.public.app.enableCameraAsContent
+ );
+}
diff --git a/src/2.7.12/imports/ui/services/locale/index.js b/src/2.7.12/imports/ui/services/locale/index.js
new file mode 100644
index 00000000..aba87832
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/locale/index.js
@@ -0,0 +1,75 @@
+import { createIntl } from 'react-intl';
+
+const FALLBACK_ON_EMPTY_STRING = Meteor.settings.public.app.fallbackOnEmptyLocaleString;
+
+/**
+ * Use this if you need any translation outside of React lifecyle.
+ */
+class BBBIntl {
+ _intl = {
+ tracker: new Tracker.Dependency(),
+ value: undefined,
+ };
+
+ _fetching = {
+ tracker: new Tracker.Dependency(),
+ value: true,
+ };
+
+ constructor({ fallback }) {
+ this._fallback = fallback;
+ }
+
+ setLocale(locale, messages) {
+ this.intl = createIntl({
+ locale,
+ messages,
+ fallbackOnEmptyString: this._fallback,
+ });
+
+ this.fetching = false;
+ }
+
+ set fetching(value) {
+ this._fetching.value = value;
+ this._fetching.tracker.changed();
+ }
+
+ get fetching() {
+ this._fetching.tracker.depend();
+ return this._fetching.value;
+ }
+
+ set intl(value) {
+ this._intl.value = value;
+ this._intl.tracker.changed();
+ }
+
+ get intl() {
+ this._intl.tracker.depend();
+ return this._intl.value;
+ }
+
+ formatMessage(descriptor, values, options) {
+ return new Promise((resolve, reject) => {
+ try {
+ if (!this.fetching && this.intl) {
+ resolve(this.intl.formatMessage(descriptor, values, options));
+ } else {
+ Tracker.autorun((c) => {
+ const { fetching, intl } = this;
+
+ if (fetching || !intl) return;
+
+ resolve(this.intl.formatMessage(descriptor, values, options));
+ c.stop();
+ });
+ }
+ } catch (e) {
+ reject(e);
+ }
+ });
+ }
+}
+
+export default new BBBIntl({ fallback: FALLBACK_ON_EMPTY_STRING });
diff --git a/src/2.7.12/imports/ui/services/meeting-settings/index.js b/src/2.7.12/imports/ui/services/meeting-settings/index.js
new file mode 100644
index 00000000..a6657205
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/meeting-settings/index.js
@@ -0,0 +1,11 @@
+import Auth from '/imports/ui/services/auth';
+import Meetings from '/imports/api/meetings';
+
+export default function getFromMeetingSettings(setting, defaultValue) {
+ const meeting = Meetings.findOne(
+ { meetingId: Auth.meetingID },
+ { fields: { metadataProp: 1 } },
+ );
+
+ return meeting?.metadataProp?.metadata?.[setting] ?? defaultValue;
+}
diff --git a/src/2.7.12/imports/ui/services/mobile-app/index.js b/src/2.7.12/imports/ui/services/mobile-app/index.js
new file mode 100644
index 00000000..9bcb07f6
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/mobile-app/index.js
@@ -0,0 +1,301 @@
+import browserInfo from '/imports/utils/browserInfo';
+import logger from '/imports/startup/client/logger';
+import Auth from '/imports/ui/services/auth';
+import { fetchStunTurnServers } from '/imports/utils/fetchStunTurnServers';
+
+(function (){
+ // This function must be executed during the import time, that's why it's not exported to the caller component.
+ // It's needed because it changes some functions provided by browser, and these functions are verified during
+ // import time (like in ScreenshareBridgeService)
+ if(browserInfo.isTabletApp) {
+ logger.debug(`BBB-MOBILE - Mobile APP detected`);
+
+ const WEBRTC_CALL_TYPE_FULL_AUDIO = 'full_audio';
+ const WEBRTC_CALL_TYPE_SCREEN_SHARE = 'screen_share';
+ const WEBRTC_CALL_TYPE_STANDARD = 'standard';
+
+ // This function detects if the call happened to publish a screenshare
+ function detectWebRtcCallType(caller, peerConnection = null, args = null) {
+ // Keep track of how many webRTC evaluations was done
+ if(!peerConnection.detectWebRtcCallTypeEvaluations)
+ peerConnection.detectWebRtcCallTypeEvaluations = 0;
+
+ peerConnection.detectWebRtcCallTypeEvaluations ++;
+
+ // If already successfully evaluated, reuse
+ if(peerConnection && peerConnection.webRtcCallType !== undefined ) {
+ logger.info(`BBB-MOBILE - detectWebRtcCallType (already evaluated as ${peerConnection.webRtcCallType})`, {caller, peerConnection});
+ return peerConnection.webRtcCallType;
+ }
+
+ // Evaluate context otherwise
+ const e = new Error('dummy');
+ const stackTrace = e.stack;
+ logger.info(`BBB-MOBILE - detectWebRtcCallType (evaluating)`, {caller, peerConnection, stackTrace: stackTrace.split('\n'), detectWebRtcCallTypeEvaluations: peerConnection.detectWebRtcCallTypeEvaluations, args});
+
+ // addEventListener is the first call for screensharing and it has a startScreensharing in its stackTrace
+ if( peerConnection.detectWebRtcCallTypeEvaluations == 1) {
+ if(caller == 'addEventListener' && stackTrace.indexOf('startScreensharing') !== -1) {
+ peerConnection.webRtcCallType = WEBRTC_CALL_TYPE_SCREEN_SHARE; // this uses mobile app broadcast upload extension
+ } else if(caller == 'addEventListener' && stackTrace.indexOf('invite') !== -1) {
+ peerConnection.webRtcCallType = WEBRTC_CALL_TYPE_FULL_AUDIO; // this uses mobile app webRTC
+ } else {
+ peerConnection.webRtcCallType = WEBRTC_CALL_TYPE_STANDARD; // this uses the webview webRTC
+ }
+
+ return peerConnection.webRtcCallType;
+ }
+
+ }
+ // Store the method call sequential
+ const sequenceHolder = {sequence: 0};
+
+ // Store the promise for each method call
+ const promisesHolder = {};
+
+ // Call a method in the mobile application, returning a promise for its execution
+ function callNativeMethod(method, args=[]) {
+ try {
+ const sequence = ++sequenceHolder.sequence;
+
+ return new Promise ( (resolve, reject) => {
+ promisesHolder[sequence] = {
+ resolve, reject
+ };
+
+ window.ReactNativeWebView.postMessage(JSON.stringify({
+ sequence: sequenceHolder.sequence,
+ method: method,
+ arguments: args,
+ }));
+ } );
+ } catch(e) {
+ logger.error(`Error on callNativeMethod ${e.message}`, e);
+ }
+ }
+
+ // This method is called from the mobile app to notify us about a method invocation result
+ window.nativeMethodCallResult = (sequence, isResolve, resultOrException) => {
+
+ const promise = promisesHolder[sequence];
+ if(promise) {
+ if(isResolve) {
+ promise.resolve( resultOrException );
+ delete promisesHolder[sequence];
+ } else {
+ promise.reject( resultOrException );
+ delete promisesHolder[sequence];
+ }
+ }
+ return true;
+ }
+
+ // WebRTC replacement functions
+ const buildVideoTrack = function () {}
+ const stream = {};
+
+ // Navigator
+ navigator.getDisplayMedia = function() {
+ logger.info(`BBB-MOBILE - getDisplayMedia called`, arguments);
+
+ return new Promise((resolve, reject) => {
+ callNativeMethod('initializeScreenShare').then(
+ () => {
+ const fakeVideoTrack = {};
+ fakeVideoTrack.applyConstraints = function (constraints) {
+ return new Promise(
+ (resolve, reject) => {
+ resolve();
+ }
+ );
+ };
+ fakeVideoTrack.onended = null; // callbacks added from screenshare (we can use it later)
+ fakeVideoTrack.oninactive = null; // callbacks added from screenshare (we can use it later)
+ fakeVideoTrack.addEventListener = function() {}; // skip listeners
+
+ const videoTracks = [
+ fakeVideoTrack
+ ];
+ stream.getTracks = stream.getVideoTracks = function () {
+ return videoTracks;
+ };
+ stream.active=true;
+ resolve(stream);
+ }
+ ).catch(
+ (e) => {
+ logger.error(`Failure calling native initializeScreenShare`, e.message)
+ }
+ );
+ });
+ }
+
+ // RTCPeerConnection
+ const prototype = window.RTCPeerConnection.prototype;
+
+ prototype.originalCreateOffer = prototype.createOffer;
+ prototype.createOffer = async function (options) {
+ const webRtcCallType = detectWebRtcCallType('createOffer', this);
+
+ if(webRtcCallType === WEBRTC_CALL_TYPE_STANDARD){
+ return prototype.originalCreateOffer.call(this, ...arguments);
+ }
+ logger.info(`BBB-MOBILE - createOffer called`, {options});
+
+ const stunTurn = await fetchStunTurnServers(Auth._authToken);
+
+ const createOfferMethod = (webRtcCallType === WEBRTC_CALL_TYPE_SCREEN_SHARE) ? 'createScreenShareOffer' : 'createFullAudioOffer';
+
+ return await new Promise( (resolve, reject) => {
+ callNativeMethod(createOfferMethod, [stunTurn]).then ( sdp => {
+ logger.info(`BBB-MOBILE - createOffer resolved`, {sdp});
+
+ // send offer to BBB code
+ resolve({
+ type: 'offer',
+ sdp
+ });
+ });
+ } );
+ };
+
+ prototype.originalAddEventListener = prototype.addEventListener;
+ prototype.addEventListener = function (event, callback) {
+ if(WEBRTC_CALL_TYPE_STANDARD === detectWebRtcCallType('addEventListener', this, arguments)){
+ return prototype.originalAddEventListener.call(this, ...arguments);
+ }
+
+ logger.info(`BBB-MOBILE - addEventListener called`, {event, callback});
+
+ switch(event) {
+ case 'icecandidate':
+ window.bbbMobileScreenShareIceCandidateCallback = function () {
+ logger.info("Received a bbbMobileScreenShareIceCandidateCallback call with arguments", arguments);
+ if(callback){
+ callback.apply(this, arguments);
+ }
+ return true;
+ }
+ break;
+ case 'signalingstatechange':
+ window.bbbMobileScreenShareSignalingStateChangeCallback = function (newState) {
+ this.signalingState = newState;
+ callback();
+ };
+ break;
+ }
+ }
+
+ prototype.originalSetLocalDescription = prototype.setLocalDescription;
+ prototype.setLocalDescription = function (description, successCallback, failureCallback) {
+ if(WEBRTC_CALL_TYPE_STANDARD === detectWebRtcCallType('setLocalDescription', this)){
+ return prototype.originalSetLocalDescription.call(this, ...arguments);
+ }
+ logger.info(`BBB-MOBILE - setLocalDescription called`, {description, successCallback, failureCallback});
+
+ // store the value
+ this._localDescription = JSON.parse(JSON.stringify(description));
+ // replace getter of localDescription to return this value
+ Object.defineProperty(this, 'localDescription', {get: function() {return this._localDescription;},set: function(newValue) {}});
+
+ // return a promise that resolves immediately
+ return new Promise( (resolve, reject) => {
+ resolve();
+ })
+ }
+
+ prototype.originalSetRemoteDescription = prototype.setRemoteDescription;
+ prototype.setRemoteDescription = function (description, successCallback, failureCallback) {
+ const webRtcCallType = detectWebRtcCallType('setRemoteDescription', this);
+ if(WEBRTC_CALL_TYPE_STANDARD === webRtcCallType){
+ return prototype.originalSetRemoteDescription.call(this, ...arguments);
+ }
+
+ logger.info(`BBB-MOBILE - setRemoteDescription called`, {description, successCallback, failureCallback});
+
+ this._remoteDescription = JSON.parse(JSON.stringify(description));
+ Object.defineProperty(this, 'remoteDescription', {get: function() {return this._remoteDescription;},set: function(newValue) {}});
+
+ const setRemoteDescriptionMethod = (webRtcCallType === WEBRTC_CALL_TYPE_SCREEN_SHARE) ? 'setScreenShareRemoteSDP' : 'setFullAudioRemoteSDP';
+
+ return new Promise( (resolve, reject) => {
+ callNativeMethod(setRemoteDescriptionMethod, [description]).then ( () => {
+ logger.info(`BBB-MOBILE - setRemoteDescription resolved`);
+
+ resolve();
+
+ if(webRtcCallType === WEBRTC_CALL_TYPE_FULL_AUDIO) {
+ Object.defineProperty(this, "iceGatheringState", {get: function() { return "complete" }, set: ()=>{} });
+ Object.defineProperty(this, "iceConnectionState", {get: function() { return "completed" }, set: ()=>{} });
+ this.onicegatheringstatechange && this.onicegatheringstatechange({target: this});
+ this.oniceconnectionstatechange && this.oniceconnectionstatechange({target: this});
+ }
+ });
+ } );
+ }
+
+ prototype.originalAddTrack = prototype.addTrack;
+ prototype.addTrack = function (description, successCallback, failureCallback) {
+ if(WEBRTC_CALL_TYPE_STANDARD === detectWebRtcCallType('addTrack', this)){
+ return prototype.originalAddTrack.call(this, ...arguments);
+ }
+
+ logger.info(`BBB-MOBILE - addTrack called`, {description, successCallback, failureCallback});
+ }
+
+ prototype.originalGetLocalStreams = prototype.getLocalStreams;
+ prototype.getLocalStreams = function() {
+ if(WEBRTC_CALL_TYPE_STANDARD === detectWebRtcCallType('getLocalStreams', this)){
+ return prototype.originalGetLocalStreams.call(this, ...arguments);
+ }
+ logger.info(`BBB-MOBILE - getLocalStreams called`, arguments);
+
+ //
+ return [
+ stream
+ ];
+ }
+
+ prototype.originalAddTransceiver = prototype.addTransceiver;
+ prototype.addTransceiver = function() {
+ if(WEBRTC_CALL_TYPE_STANDARD === detectWebRtcCallType('addTransceiver', this)){
+ return prototype.originalAddTransceiver.call(this, ...arguments);
+ }
+ logger.info(`BBB-MOBILE - addTransceiver called`, arguments);
+ }
+
+ prototype.originalAddIceCandidate = prototype.addIceCandidate;
+ prototype.addIceCandidate = function (candidate) {
+ if(WEBRTC_CALL_TYPE_STANDARD === detectWebRtcCallType('addIceCandidate', this)){
+ return prototype.originalAddIceCandidate.call(this, ...arguments);
+ }
+ logger.info(`BBB-MOBILE - addIceCandidate called`, {candidate});
+
+ return new Promise( (resolve, reject) => {
+ callNativeMethod('addRemoteIceCandidate', [candidate]).then ( () => {
+ logger.info("BBB-MOBILE - addRemoteIceCandidate resolved");
+
+ resolve();
+ });
+ } );
+ }
+
+ // Handle screenshare stop
+ const KurentoScreenShareBridge = require('/imports/api/screenshare/client/bridge/index.js').default;
+ //Kurento Screen Share
+ var stopOriginal = KurentoScreenShareBridge.stop.bind(KurentoScreenShareBridge);
+ KurentoScreenShareBridge.stop = function(){
+ callNativeMethod('stopScreenShare')
+ logger.debug(`BBB-MOBILE - Click on stop screen share`);
+ stopOriginal()
+ }
+
+ // Handle screenshare stop requested by application (i.e. stopped the broadcast extension)
+ window.bbbMobileScreenShareBroadcastFinishedCallback = function () {
+ document.querySelector('[data-test="stopScreenShare"]')?.click();
+ }
+
+ }
+})();
+
+
diff --git a/src/2.7.12/imports/ui/services/notification/index.js b/src/2.7.12/imports/ui/services/notification/index.js
new file mode 100644
index 00000000..3177113f
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/notification/index.js
@@ -0,0 +1,52 @@
+/* eslint react/jsx-filename-extension: 0 */
+import React from 'react';
+import { toast } from 'react-toastify';
+import { isEqual } from 'radash';
+
+import Toast from '/imports/ui/components/common/toast/component';
+
+let lastToast = {
+ id: null,
+ message: null,
+ type: null,
+ icon: null,
+};
+
+export function notify(message, type = 'default', icon, options, content, small) {
+ const settings = {
+ type,
+ ...options,
+ };
+
+ const { id: lastToastId, ...lastToastProps } = lastToast;
+ const toastProps = {
+ message,
+ type,
+ icon,
+ content,
+ small,
+ };
+
+ if (!toast.isActive(lastToast.id) || !isEqual(lastToastProps, toastProps)) {
+ if (toast.isActive(lastToast.id)
+ && isEqual(lastToastProps.key, toastProps.key) && options?.autoClose > 0) {
+ toast.update(
+ lastToast.id,
+ {
+ render:
,
+ autoClose: options.autoClose,
+ ...toastProps,
+ },
+ );
+ } else {
+ const id = toast(
, settings);
+
+ lastToast = { id, ...toastProps };
+
+ return id;
+ }
+ }
+ return null;
+}
+
+export default { notify };
diff --git a/src/2.7.12/imports/ui/services/settings/index.js b/src/2.7.12/imports/ui/services/settings/index.js
new file mode 100644
index 00000000..3c2047e9
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/settings/index.js
@@ -0,0 +1,123 @@
+import {default as LocalStorage} from '/imports/ui/services/storage/local';
+import {default as SessionStorage} from '/imports/ui/services/storage/session';
+
+import { makeCall } from '/imports/ui/services/api';
+import { isEmpty } from 'radash';
+
+const APP_CONFIG = Meteor.settings.public.app;
+
+const SETTINGS = [
+ 'application',
+ 'audio',
+ 'video',
+ 'cc',
+ 'dataSaving',
+ 'animations',
+ 'selfViewDisable',
+ 'transcription',
+];
+
+const CHANGED_SETTINGS = 'changed_settings';
+const DEFAULT_SETTINGS = 'default_settings';
+
+class Settings {
+ constructor(defaultValues = {}) {
+ SETTINGS.forEach((p) => {
+ const privateProp = `_${p}`;
+ this[privateProp] = {
+ tracker: new Tracker.Dependency(),
+ value: undefined,
+ };
+
+ Object.defineProperty(this, p, {
+ get: () => {
+ this[privateProp].tracker.depend();
+ return this[privateProp].value;
+ },
+
+ set: (v) => {
+ this[privateProp].value = v;
+ this[privateProp].tracker.changed();
+ },
+ });
+ });
+ this.defaultSettings = {};
+ // Sets default locale to browser locale
+ defaultValues.application.locale = navigator.languages ? navigator.languages[0] : false
+ || navigator.language
+ || defaultValues.application.locale;
+
+ this.setDefault(defaultValues);
+ this.loadChanged();
+ }
+
+ setDefault(defaultValues) {
+ Object.keys(defaultValues).forEach((key) => {
+ this[key] = defaultValues[key];
+ this.defaultSettings[`_${key}`] = defaultValues[key];
+ });
+
+ this.save(DEFAULT_SETTINGS);
+ }
+
+ loadChanged() {
+ const Storage = (APP_CONFIG.userSettingsStorage === 'local') ? LocalStorage : SessionStorage;
+ const savedSettings = {};
+
+ SETTINGS.forEach((s) => {
+ savedSettings[s] = Storage.getItem(`${CHANGED_SETTINGS}_${s}`);
+ });
+
+ Object.keys(savedSettings).forEach((key) => {
+ const savedItem = savedSettings[key];
+ if (!savedItem) return;
+ this[key] = {
+ ...this[key],
+ ...savedItem,
+ };
+ });
+ }
+
+ save(settings = CHANGED_SETTINGS) {
+ const Storage = (APP_CONFIG.userSettingsStorage === 'local') ? LocalStorage : SessionStorage;
+ if (settings === CHANGED_SETTINGS) {
+ Object.keys(this).forEach((k) => {
+ const values = this[k].value;
+ const defaultValues = this.defaultSettings[k];
+
+ if (!values) return;
+ const changedValues = Object.keys(values)
+ .filter(item => values[item] !== defaultValues[item])
+ .reduce((acc, item) => ({
+ ...acc,
+ [item]: values[item],
+ }), {});
+
+ if (isEmpty(changedValues)) Storage.removeItem(`${settings}${k}`);
+ Storage.setItem(`${settings}${k}`, changedValues);
+ });
+ } else {
+ Object.keys(this).forEach((k) => {
+ Storage.setItem(`${settings}${k}`, this[k].value);
+ });
+ }
+
+
+ const userSettings = {};
+
+ SETTINGS.forEach((e) => {
+ userSettings[e] = this[e];
+ });
+
+ Tracker.autorun((c) => {
+ const { status } = Meteor.status();
+ if (status === 'connected') {
+ c.stop();
+ makeCall('userChangedLocalSettings', userSettings);
+ }
+ });
+ }
+}
+
+const SettingsSingleton = new Settings(Meteor.settings.public.app.defaultSettings);
+export default SettingsSingleton;
diff --git a/src/2.7.12/imports/ui/services/storage/index.js b/src/2.7.12/imports/ui/services/storage/index.js
new file mode 100644
index 00000000..4b2c703c
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/storage/index.js
@@ -0,0 +1,8 @@
+import Local from './local';
+import Session from './session';
+
+const APP_CONFIG = Meteor.settings.public.app;
+
+const BBBStorage = APP_CONFIG.userSettingsStorage === 'local' ? Local : Session;
+
+export default BBBStorage;
diff --git a/src/2.7.12/imports/ui/services/storage/local.js b/src/2.7.12/imports/ui/services/storage/local.js
new file mode 100644
index 00000000..efcdc266
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/storage/local.js
@@ -0,0 +1,5 @@
+import ReactiveStorage from './reactive';
+
+const _singleton = new ReactiveStorage(window.localStorage, 'BBB_');
+
+export default _singleton;
diff --git a/src/2.7.12/imports/ui/services/storage/reactive.js b/src/2.7.12/imports/ui/services/storage/reactive.js
new file mode 100644
index 00000000..01ca7ecd
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/storage/reactive.js
@@ -0,0 +1,74 @@
+import { Tracker } from 'meteor/tracker';
+import { EJSON } from 'meteor/ejson';
+import { isObject, isArray, isString } from 'radash';
+
+// Reactive wrapper for browser Storage's
+
+export default class StorageTracker {
+ constructor(storage, prefix = '') {
+ if (!(storage instanceof Storage)) {
+ throw `Expecting a instanceof Storage recieve a '${storage.constructor.name}' instance`;
+ }
+
+ this._trackers = {};
+ this._prefix = prefix;
+ this._storage = storage;
+ }
+
+ _ensureDeps(key) {
+ if (!this._trackers[key]) {
+ this._trackers[key] = new Tracker.Dependency();
+ }
+ }
+
+ _prefixedKey(key) {
+ key = key.replace(this._prefix, '');
+ return `${this._prefix}${key}`;
+ }
+
+ key(n) {
+ return this._storage.key(n);
+ }
+
+ getItem(key) {
+ const prefixedKey = this._prefixedKey(key);
+ this._ensureDeps(prefixedKey);
+ this._trackers[prefixedKey].depend();
+
+ let value = this._storage.getItem(prefixedKey);
+
+ if (value && isString(value)) {
+ try {
+ value = EJSON.parse(value);
+ } catch (e) {}
+ }
+
+ return value;
+ }
+
+ setItem(key, value) {
+ const prefixedKey = this._prefixedKey(key);
+ this._ensureDeps(prefixedKey);
+
+ if (isObject(value) || isArray(value)) {
+ value = EJSON.stringify(value);
+ }
+
+ this._storage.setItem(prefixedKey, value);
+ this._trackers[prefixedKey].changed();
+ }
+
+ removeItem(key) {
+ const prefixedKey = this._prefixedKey(key);
+ this._storage.removeItem(prefixedKey);
+ if (!this._trackers[prefixedKey]) return;
+ this._trackers[prefixedKey].changed();
+ delete this._trackers[prefixedKey];
+ }
+
+ clear() {
+ Object.keys(this._trackers).forEach((key) => {
+ this.removeItem(key);
+ });
+ }
+}
diff --git a/src/2.7.12/imports/ui/services/storage/session.js b/src/2.7.12/imports/ui/services/storage/session.js
new file mode 100644
index 00000000..ade20a01
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/storage/session.js
@@ -0,0 +1,5 @@
+import ReactiveStorage from './reactive';
+
+const _singleton = new ReactiveStorage(window.sessionStorage, 'BBB_');
+
+export default _singleton;
diff --git a/src/2.7.12/imports/ui/services/subscription-registry/subscriptionRegistry.js b/src/2.7.12/imports/ui/services/subscription-registry/subscriptionRegistry.js
new file mode 100644
index 00000000..2c46d331
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/subscription-registry/subscriptionRegistry.js
@@ -0,0 +1,26 @@
+import { Tracker } from 'meteor/tracker';
+
+export const subscriptionReactivity = new Tracker.Dependency();
+// Create the subscription and store its data to use in the client (E.G. subscriptionId)
+class SubscriptionRegistry {
+ constructor() {
+ this.registry = {};
+ }
+
+ createSubscription(subscription, options, ...params) {
+ const opt = { ...options };
+ opt.onStop = () => {
+ subscriptionReactivity.changed();
+ if (options?.onStop) options.onStop();
+ this.registry[subscription] = null;
+ };
+ this.registry[subscription] = Meteor.subscribe(subscription, ...params, opt);
+ return this.registry[subscription];
+ }
+
+ getSubscription(name) {
+ return this.registry[name];
+ }
+}
+
+export default new SubscriptionRegistry();
diff --git a/src/2.7.12/imports/ui/services/unread-messages/index.js b/src/2.7.12/imports/ui/services/unread-messages/index.js
new file mode 100644
index 00000000..d9174dcd
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/unread-messages/index.js
@@ -0,0 +1,55 @@
+import { Tracker } from 'meteor/tracker';
+
+import Storage from '/imports/ui/services/storage/session';
+
+const CHAT_CONFIG = Meteor.settings.public.chat;
+const STORAGE_KEY = CHAT_CONFIG.storage_key;
+const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id;
+
+class UnreadMessagesTracker {
+ constructor() {
+ this._tracker = new Tracker.Dependency();
+ this._unreadChats = {
+ ...Storage.getItem('UNREAD_CHATS'),
+ [PUBLIC_GROUP_CHAT_ID]: (new Date()).getTime(),
+ };
+ this.get = this.get.bind(this);
+ }
+
+ get(chatID) {
+ this._tracker.depend();
+ return this._unreadChats[chatID] || 0;
+ }
+
+ update(chatID, timestamp = 0) {
+ const currentValue = this.get(chatID);
+ if (currentValue < timestamp) {
+ this._unreadChats[chatID] = timestamp;
+ this._tracker.changed();
+ Storage.setItem(STORAGE_KEY, this._unreadChats);
+ }
+
+ return this._unreadChats[chatID];
+ }
+
+ getUnreadMessages(chatID, messages) {
+ const isPublicChat = chatID === PUBLIC_GROUP_CHAT_ID;
+
+ let unreadMessages = [];
+
+ if (messages[chatID]) {
+ const contextChat = messages[chatID];
+ const unreadTimewindows = contextChat.unreadTimeWindows;
+ for (const unreadTimeWindowId of unreadTimewindows) {
+ unreadMessages.push(isPublicChat
+ ? contextChat?.preJoinMessages[unreadTimeWindowId] || contextChat?.posJoinMessages[unreadTimeWindowId]
+ : contextChat?.messageGroups[unreadTimeWindowId]);
+ }
+ }
+
+ return unreadMessages;
+ }
+}
+
+const UnreadTrackerSingleton = new UnreadMessagesTracker();
+export default UnreadTrackerSingleton;
diff --git a/src/2.7.12/imports/ui/services/users-settings/index.js b/src/2.7.12/imports/ui/services/users-settings/index.js
new file mode 100644
index 00000000..9ccb66a0
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/users-settings/index.js
@@ -0,0 +1,18 @@
+import Auth from '/imports/ui/services/auth';
+import UserSettings from '/imports/api/users-settings';
+
+export default function getFromUserSettings(setting, defaultValue) {
+ const selector = {
+ meetingId: Auth.meetingID,
+ userId: Auth.userID,
+ setting,
+ };
+
+ const userSetting = UserSettings.findOne(selector);
+
+ if (userSetting !== undefined) {
+ return userSetting.value;
+ }
+
+ return defaultValue;
+}
diff --git a/src/2.7.12/imports/ui/services/virtual-background/README.md b/src/2.7.12/imports/ui/services/virtual-background/README.md
new file mode 100644
index 00000000..25e5e677
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/virtual-background/README.md
@@ -0,0 +1,80 @@
+# Virtual Backgrounds for BBB
+
+> Inspired from https://ai.googleblog.com/2020/10/background-features-in-google-meet.html, https://github.com/Volcomix/virtual-background.git and https://github.com/jitsi/jitsi-meet/tree/master/react/features/stream-effects/virtual-background/vendor
+
+## iOS and macOS
+
+The feature works on macOS, however Safari is not supported. Due to limitations on iOS, it is currently not possible to enable virtual backgrounds feature on iPhones and iPads. The reason behind is documented on [Apple Developer Documentation website](https://developer.apple.com/documentation/webkitjs/canvasrenderingcontext2d/1630282-drawimage).
+
+> The image object can be an img element, a canvas element, or a video element. **Use of the video element is not supported in Safari on iOS, however.**
+
+## Settings
+
+Virtual backgrounds feature for BBB is turned on by default as long as the version you have comes with the feature included. In case your `settings.yml` doesn't include the virtual background settings, it will fall back to the default settings, which are identical to having the following parameters in your `settings.yml` file.
+
+
+ virtualBackgrounds:
+ enabled: true
+ storedOnBBB: true
+ showThumbnails: true
+ imagesPath: /resources/images/virtual-backgrounds/
+ thumbnailsPath: /resources/images/virtual-backgrounds/thumbnails/
+ fileNames:
+ - home.jpg
+ - coffeeshop.jpg
+ - board.jpg
+
+In case you don't have the virtual background settings in your `settings.yml`, add the above lines under `public.app` namespace, so it looks like the following.
+
+ public:
+ app:
+ ...
+ ...
+ virtualBackgrounds:
+ (your settings here)
+
+### Explanation of settings
+
+| Setting | Description | Type |
+| --- | --- | --- |
+| `enabled` | Enables or disables virtual background feature | `boolean` |
+| `storedOnBBB` | Determines where the images and thumbnails are stored. If set to true, virtual backgrounds and thumbnails will be fetched from `/resources/images/virtual-backgrounds/`. | `boolean` |
+| `showThumbnails`| Determines whether to show or hide thumbnails. If set to `false`, a dropdown of `fileNames` will be shown. | `boolean` |
+| `imagesPath` | Location of virtual background images. If `storedOnBBB` is set to false, it is possible to give an external location. **IMPORTANT: File names must be given explicitly under `fileNames` if using non-default images.** | `string` |
+| `thumbnailsPath` | Location of virtual background image thumbnails. If `storedOnBBB` is set to false, it is possible to give an external location. **IMPORTANT: Thumbnail file names and extensions must match their corresponding virtual background images. If `showThumbnails` is set to false, this can be ignored.** | `string` |
+| `fileNames` | List of file names that will be used as virtual backgrounds. File extensions must also be given. The same values are used for images and thumbnails. | `array` |
+
+
+
+## Canvas 2D + CPU
+
+This rendering pipeline is pretty much the same as for BodyPix. It relies on Canvas compositing properties to blend rendering layers according to the segmentation mask.
+
+Interactions with TFLite inference tool are executed on CPU to convert from UInt8 to Float32 for the model input and to apply softmax on the model output.
+
+The framerate is higher and the quality looks better than BodyPix
+
+## SIMD and non-SIMD
+
+How to test on SIMD:
+1. Go to chrome://flags/
+2. Search for SIMD flag
+3. Enable WebAssembly SIMD support(Enables support for the WebAssembly SIMD proposal).
+4. Reopen Google Chrome
+
+More details:
+- [WebAssembly](https://webassembly.org/)
+- [WebAssembly SIMD](https://github.com/WebAssembly/simd)
+- [TFLite](https://blog.tensorflow.org/2020/07/accelerating-tensorflow-lite-xnnpack-integration.html)
+
+## LICENSE
+
+The models vendored here were downloaded early January (they were available as early as the 4th), before Google switched the license away from Apache 2. Thus we understand they are not covered by the new license which according to the [model card](https://github.com/tensorflow/tfjs/files/6466044/Model.Card-Meet.Segmentation.pdf) dates from the 21st of January.
+
+We are not lawyers so do get legal advise if in doubt.
+
+References:
+
+- Model license discussion: https://github.com/tensorflow/tfjs/issues/4177
+- Current vendored model is discovered: https://github.com/tensorflow/tfjs/issues/4177#issuecomment-753934631
+- License change is noticed: https://github.com/tensorflow/tfjs/issues/4177#issuecomment-771536641
diff --git a/src/2.7.12/imports/ui/services/virtual-background/TimeWorker.js b/src/2.7.12/imports/ui/services/virtual-background/TimeWorker.js
new file mode 100644
index 00000000..4c5d58c4
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/virtual-background/TimeWorker.js
@@ -0,0 +1,62 @@
+/* NOTICE: This file is a Derivative Work of the original component in Jitsi Meet
+ * See https://github.com/jitsi/jitsi-meet/tree/master/react/features/stream-effects/virtual-background/vendor.
+ * It is copied under the Apache Public License 2.0 (see https://www.apache.org/licenses/LICENSE-2.0).
+ */
+
+/**
+ * SET_TIMEOUT constant is used to set interval and it is set in
+ * the id property of the request.data property. timeMs property must
+ * also be set. request.data example:
+ *
+ * {
+ * id: SET_TIMEOUT,
+ * timeMs: 33
+ * }
+ */
+ export const SET_TIMEOUT = 1;
+
+ /**
+ * CLEAR_TIMEOUT constant is used to clear the interval and it is set in
+ * the id property of the request.data property.
+ *
+ * {
+ * id: CLEAR_TIMEOUT
+ * }
+ */
+ export const CLEAR_TIMEOUT = 2;
+
+ /**
+ * TIMEOUT_TICK constant is used as response and it is set in the id property.
+ *
+ * {
+ * id: TIMEOUT_TICK
+ * }
+ */
+ export const TIMEOUT_TICK = 3;
+
+ /**
+ * The following code is needed as string to create a URL from a Blob.
+ * The URL is then passed to a WebWorker. Reason for this is to enable
+ * use of setInterval that is not throttled when tab is inactive.
+ */
+ const code = `
+ var timer;
+ onmessage = function(request) {
+ switch (request.data.id) {
+ case ${SET_TIMEOUT}: {
+ timer = setTimeout(() => {
+ postMessage({ id: ${TIMEOUT_TICK} });
+ }, request.data.timeMs);
+ break;
+ }
+ case ${CLEAR_TIMEOUT}: {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ break;
+ }
+ }
+ };
+ `;
+
+ export const timerWorkerScript = URL.createObjectURL(new Blob([ code ], { type: 'application/javascript' }));
diff --git a/src/2.7.12/imports/ui/services/virtual-background/index.js b/src/2.7.12/imports/ui/services/virtual-background/index.js
new file mode 100644
index 00000000..5497dbe2
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/virtual-background/index.js
@@ -0,0 +1,412 @@
+/* NOTICE: This file is a Derivative Work of the original component in Jitsi Meet
+ * See https://github.com/jitsi/jitsi-meet/tree/master/react/features/stream-effects/virtual-background/vendor.
+ * It is partially copied under the Apache Public License 2.0 (see https://www.apache.org/licenses/LICENSE-2.0).
+ */
+
+import {
+ CLEAR_TIMEOUT,
+ TIMEOUT_TICK,
+ SET_TIMEOUT,
+ timerWorkerScript
+} from './TimeWorker';
+import {
+ BASE_PATH,
+ MODELS,
+ getVirtualBgImagePath,
+} from '/imports/ui/services/virtual-background/service'
+import logger from '/imports/startup/client/logger';
+import { simd } from 'wasm-feature-detect/dist/cjs/index';
+
+const blurValue = '25px';
+
+function drawImageProp(ctx, img, x, y, w, h, offsetX, offsetY) {
+ if (arguments.length === 2) {
+ x = y = 0;
+ w = ctx.canvas.width;
+ h = ctx.canvas.height;
+ }
+
+ // Default offset is center
+ offsetX = typeof offsetX === 'number' ? offsetX : 0.5;
+ offsetY = typeof offsetY === 'number' ? offsetY : 0.5;
+
+ // Keep bounds [0.0, 1.0]
+ if (offsetX < 0) offsetX = 0;
+ if (offsetY < 0) offsetY = 0;
+ if (offsetX > 1) offsetX = 1;
+ if (offsetY > 1) offsetY = 1;
+
+ const iw = img.width,
+ ih = img.height,
+ r = Math.min(w / iw, h / ih);
+
+ let nw = iw * r,
+ nh = ih * r,
+ cx, cy, cw, ch, ar = 1;
+
+ // Decide which gap to fill
+ if (nw < w) ar = w / nw;
+ if (Math.abs(ar - 1) < 1e-14 && nh < h) ar = h / nh;
+ nw *= ar;
+ nh *= ar;
+
+ // Calc source rectangle
+ cw = iw / (nw / w);
+ ch = ih / (nh / h);
+ cx = (iw - cw) * offsetX;
+ cy = (ih - ch) * offsetY;
+
+ // Make sure source rectangle is valid
+ if (cx < 0) cx = 0;
+ if (cy < 0) cy = 0;
+ if (cw > iw) cw = iw;
+ if (ch > ih) ch = ih;
+
+ ctx.drawImage(img, cx, cy, cw, ch, x, y, w, h);
+}
+
+class VirtualBackgroundService {
+
+ _model;
+ _options;
+ _segmentationMask;
+ _inputVideoElement;
+ _outputCanvasElement;
+ _segmentationMask;
+ _segmentationPixelCount;
+ _segmentationMaskCanvas;
+ _segmentationMaskCtx;
+ _virtualImage;
+ _maskFrameTimerWorker;
+
+ constructor(model, options) {
+ this._model = model;
+ this._options = options;
+ this._options.brightness = 100;
+ this._options.wholeImageBrightness = false;
+ if (this._options.virtualBackground.backgroundType === 'image') {
+ this._virtualImage = document.createElement('img');
+ this._virtualImage.crossOrigin = 'anonymous';
+ this._virtualImage.src = this._options.virtualBackground.virtualSource;
+ }
+
+ this._segmentationPixelCount = this._options.width * this._options.height;
+
+ // Bind event handler so it is only bound once for every instance.
+ this._onMaskFrameTimer = this._onMaskFrameTimer.bind(this);
+
+ this._outputCanvasElement = document.createElement('canvas');
+ this._outputCanvasElement.getContext('2d');
+ this._inputVideoElement = document.createElement('video');
+ }
+
+ /**
+ * EventHandler onmessage for the maskFrameTimerWorker WebWorker.
+ * @param {EventHandler} response - The onmessage EventHandler parameter.
+ * @returns {void}
+ */
+ _onMaskFrameTimer(response) {
+ if (response.data.id === TIMEOUT_TICK) {
+ this._renderMask();
+ }
+ }
+
+ /**
+ * Represents the run post processing.
+ *
+ * @returns {void}
+ */
+ runPostProcessing() {
+ this._outputCanvasCtx.globalCompositeOperation = 'copy';
+
+ // Draw segmentation mask.
+ //
+
+ // Smooth out the edges.
+ if (this._options.virtualBackground.isVirtualBackground) {
+ this._outputCanvasCtx.filter = 'blur(4px)';
+ } else if (this._options.virtualBackground.backgroundType === 'blur') {
+ this._outputCanvasCtx.filter = 'blur(8px)';
+ }
+
+ this._outputCanvasCtx.drawImage(
+ this._segmentationMaskCanvas,
+ 0,
+ 0,
+ this._options.width,
+ this._options.height,
+ 0,
+ 0,
+ this._inputVideoElement.width,
+ this._inputVideoElement.height
+ );
+ this._outputCanvasCtx.globalCompositeOperation = 'source-in';
+ this._outputCanvasCtx.filter = 'none';
+
+ // Draw the foreground video.
+ //
+
+ this._outputCanvasCtx.filter = `brightness(${this._options.brightness}%)`;
+ this._outputCanvasCtx.drawImage(this._inputVideoElement, 0, 0);
+ this._outputCanvasCtx.filter = 'none';
+
+ // Draw the background.
+ //
+
+ if (this._options.wholeImageBrightness) {
+ this._outputCanvasCtx.filter = `brightness(${this._options.brightness}%)`;
+ }
+
+ this._outputCanvasCtx.globalCompositeOperation = 'destination-over';
+ if (this._options.virtualBackground.isVirtualBackground) {
+ drawImageProp(
+ this._outputCanvasCtx,
+ this._virtualImage,
+ 0,
+ 0,
+ this._inputVideoElement.width,
+ this._inputVideoElement.height,
+ 0.5,
+ 0.5,
+ );
+ } else if (this._options.virtualBackground.backgroundType === 'blur') {
+ this._outputCanvasCtx.filter = `blur(${blurValue})`;
+ this._outputCanvasCtx.drawImage(this._inputVideoElement, 0, 0);
+ } else {
+ this._outputCanvasCtx.drawImage(this._inputVideoElement, 0, 0);
+ }
+ }
+
+ /**
+ * Represents the run Tensorflow Interference.
+ *
+ * @returns {void}
+ */
+ runInference() {
+ this._model._runInference();
+ const outputMemoryOffset = this._model._getOutputMemoryOffset() / 4;
+
+ for (let i = 0; i < this._segmentationPixelCount; i++) {
+ const background = this._model.HEAPF32[outputMemoryOffset + (i * 2)];
+ const person = this._model.HEAPF32[outputMemoryOffset + (i * 2) + 1];
+ const shift = Math.max(background, person);
+ const backgroundExp = Math.exp(background - shift);
+ const personExp = Math.exp(person - shift);
+
+ // Sets only the alpha component of each pixel.
+ this._segmentationMask.data[(i * 4) + 3] = (255 * personExp) / (backgroundExp + personExp);
+ }
+ this._segmentationMaskCtx.putImageData(this._segmentationMask, 0, 0);
+ }
+
+ /**
+ * Loop function to render the background mask.
+ *
+ * @private
+ * @returns {void}
+ */
+ _renderMask() {
+ try {
+ this.resizeSource();
+ this.runInference();
+ this.runPostProcessing();
+ } catch (error) {
+ // TODO This is a high frequency log so that's why it's debug level.
+ // Should be reviewed later when the actual problem with runPostProcessing
+ // throwing on stalled pages/iframes - prlanzarin Jun 30 2022
+ logger.debug({
+ logCode: 'virtualbg_renderMask_failure',
+ extraInfo: {
+ errorMessage: error.message,
+ errorCode: error.code,
+ errorName: error.name,
+ },
+ }, `Virtual background renderMask failed: ${error.message || error.name}`);
+ }
+
+ this._maskFrameTimerWorker.postMessage({
+ id: SET_TIMEOUT,
+ timeMs: 1000 / 30
+ });
+ }
+
+ /**
+ * Represents the resize source process.
+ *
+ * @returns {void}
+ */
+ resizeSource() {
+ this._segmentationMaskCtx.drawImage(
+ this._inputVideoElement,
+ 0,
+ 0,
+ this._inputVideoElement.width,
+ this._inputVideoElement.height,
+ 0,
+ 0,
+ this._options.width,
+ this._options.height
+ );
+
+ const imageData = this._segmentationMaskCtx.getImageData(
+ 0,
+ 0,
+ this._options.width,
+ this._options.height
+ );
+ const inputMemoryOffset = this._model._getInputMemoryOffset() / 4;
+
+ for (let i = 0; i < this._segmentationPixelCount; i++) {
+ this._model.HEAPF32[inputMemoryOffset + (i * 3)] = imageData.data[i * 4] / 255;
+ this._model.HEAPF32[inputMemoryOffset + (i * 3) + 1] = imageData.data[(i * 4) + 1] / 255;
+ this._model.HEAPF32[inputMemoryOffset + (i * 3) + 2] = imageData.data[(i * 4) + 2] / 255;
+ }
+ }
+
+ changeBackgroundImage(parameters = null) {
+ const virtualBackgroundImagePath = getVirtualBgImagePath();
+ let name = '';
+ let type = 'blur';
+ let isVirtualBackground = false;
+ if (parameters != null && Object.keys(parameters).length > 0) {
+ name = parameters.name;
+ type = parameters.type;
+ isVirtualBackground = parameters.isVirtualBackground;
+ }
+ this._options.virtualBackground.virtualSource = virtualBackgroundImagePath + name;
+ this._options.virtualBackground.backgroundType = type;
+ this._options.virtualBackground.isVirtualBackground = isVirtualBackground;
+ if (this._options.virtualBackground.backgroundType === 'image') {
+ this._virtualImage = document.createElement('img');
+ this._virtualImage.crossOrigin = 'anonymous';
+ this._virtualImage.src = virtualBackgroundImagePath + name;
+ }
+ if (parameters.customParams) {
+ this._virtualImage.src = parameters.customParams.file;
+ }
+ }
+
+ /**
+ * Starts loop to capture video frame and render the segmentation mask.
+ *
+ * @param {MediaStream} stream - Stream to be used for processing.
+ * @returns {MediaStream} - The stream with the applied effect.
+ */
+ startEffect(stream) {
+ this._maskFrameTimerWorker = new Worker(timerWorkerScript, { name: 'Blur effect worker' });
+ this._maskFrameTimerWorker.onmessage = this._onMaskFrameTimer;
+
+ const firstVideoTrack = stream.getVideoTracks()[0];
+
+ const { height, frameRate, width }
+ = firstVideoTrack.getSettings ? firstVideoTrack.getSettings() : firstVideoTrack.getConstraints();
+
+ this._segmentationMask = new ImageData(this._options.width, this._options.height);
+ this._segmentationMaskCanvas = document.createElement('canvas');
+ this._segmentationMaskCanvas.width = this._options.width;
+ this._segmentationMaskCanvas.height = this._options.height;
+ this._segmentationMaskCtx = this._segmentationMaskCanvas.getContext('2d');
+
+ this._outputCanvasElement.width = parseInt(width, 10);
+ this._outputCanvasElement.height = parseInt(height, 10);
+ this._outputCanvasCtx = this._outputCanvasElement.getContext('2d');
+ this._inputVideoElement.width = parseInt(width, 10);
+ this._inputVideoElement.height = parseInt(height, 10);
+ this._inputVideoElement.autoplay = true;
+ this._inputVideoElement.srcObject = stream;
+ this._inputVideoElement.onloadeddata = () => {
+ this._maskFrameTimerWorker.postMessage({
+ id: SET_TIMEOUT,
+ timeMs: 1000 / 30
+ });
+ };
+
+ return this._outputCanvasElement.captureStream(parseInt(frameRate, 15));
+ }
+
+ /**
+ * Stops the capture and render loop.
+ *
+ * @returns {void}
+ */
+ stopEffect() {
+ this._maskFrameTimerWorker.postMessage({
+ id: CLEAR_TIMEOUT
+ });
+
+ this._maskFrameTimerWorker.terminate();
+ }
+
+
+ set brightness(value) {
+ this._options.brightness = value;
+ }
+
+ get brightness() {
+ return this._options.brightness;
+ }
+
+ set wholeImageBrightness(value) {
+ this._options.wholeImageBrightness = value;
+ }
+
+ get wholeImageBrightness() {
+ return this._options.wholeImageBrightness;
+ }
+}
+
+ /**
+ * Creates VirtualBackgroundService. If parameters are empty, the default
+ * effect is blur. Parameters (if given) must contain the following:
+ * isVirtualBackground (boolean) - false for blur, true for image
+ * backgroundType (string) - 'image' for image, anything else for blur
+ * backgroundFilename (string) - File name that is stored in /public/resources/images/virtual-backgrounds/
+ * @param {Object} parameters
+ * @returns {VirtualBackgroundService}
+ */
+export async function createVirtualBackgroundService(parameters = null) {
+ let tflite;
+ let modelResponse;
+ const simdSupported = await simd();
+
+ if (simdSupported) {
+ tflite = await window.createTFLiteSIMDModule();
+ modelResponse = await fetch(BASE_PATH+MODELS.model144.path);
+ } else {
+ tflite = await window.createTFLiteModule();
+ modelResponse = await fetch(BASE_PATH+MODELS.model96.path);
+ }
+
+ const modelBufferOffset = tflite._getModelBufferMemoryOffset();
+ const virtualBackgroundImagePath = getVirtualBgImagePath();
+
+ if (parameters == null) {
+ parameters = {};
+ parameters.virtualSource = virtualBackgroundImagePath + '';
+ parameters.backgroundType = 'blur';
+ parameters.isVirtualBackground = false;
+ } else {
+ parameters.virtualSource = virtualBackgroundImagePath + parameters.backgroundFilename;
+
+ if (parameters?.customParams?.file) {
+ parameters.virtualSource = parameters.customParams.file;
+ }
+ }
+
+ if (!modelResponse.ok) {
+ throw new Error('Failed to download tflite model!');
+ }
+
+ const model = await modelResponse.arrayBuffer();
+ tflite.HEAPU8.set(new Uint8Array(model), modelBufferOffset);
+ tflite._loadModel(model.byteLength);
+
+ const options = {
+ ... simdSupported ? MODELS.model144.segmentationDimensions : MODELS.model96.segmentationDimensions,
+ virtualBackground: parameters
+ };
+
+ return new VirtualBackgroundService(tflite, options);
+}
+
+export default VirtualBackgroundService;
diff --git a/src/2.7.12/imports/ui/services/virtual-background/service.js b/src/2.7.12/imports/ui/services/virtual-background/service.js
new file mode 100644
index 00000000..803e32f2
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/virtual-background/service.js
@@ -0,0 +1,111 @@
+import deviceInfo from '/imports/utils/deviceInfo';
+import browserInfo from '/imports/utils/browserInfo';
+import { createVirtualBackgroundService } from '/imports/ui/services/virtual-background';
+
+const BLUR_FILENAME = 'blur.jpg';
+const EFFECT_TYPES = {
+ BLUR_TYPE: 'blur',
+ IMAGE_TYPE: 'image',
+ NONE_TYPE: 'none',
+}
+
+// TODO I'm sure this is centralized somewhere; fetch it from "there" if possible
+const BASE_PATH = Meteor.settings.public.app.cdn
+ + Meteor.settings.public.app.basename
+ + Meteor.settings.public.app.instanceId;
+
+const MODELS = {
+ model96: {
+ path: '/resources/tfmodels/segm_lite_v681.tflite',
+ segmentationDimensions: {
+ height: 96,
+ width: 160
+ }
+ },
+ model144: {
+ path: '/resources/tfmodels/segm_full_v679.tflite',
+ segmentationDimensions: {
+ height: 144,
+ width: 256
+ }
+ },
+};
+
+const {
+ thumbnailsPath: THUMBNAILS_PATH = '/resources/images/virtual-backgrounds/thumbnails/',
+ fileNames: IMAGE_NAMES = ['home.jpg', 'coffeeshop.jpg', 'board.jpg'],
+ storedOnBBB: IS_STORED_ON_BBB = true,
+ imagesPath: IMAGES_PATH = '/resources/images/virtual-backgrounds/',
+ showThumbnails: SHOW_THUMBNAILS = true,
+} = Meteor.settings.public.virtualBackgrounds;
+
+const createVirtualBackgroundStream = (type, name, isVirtualBackground, stream, customParams) => {
+ const buildParams = {
+ backgroundType: type,
+ backgroundFilename: name,
+ isVirtualBackground,
+ customParams,
+ }
+
+ return createVirtualBackgroundService(buildParams).then((service) => {
+ const effect = service.startEffect(stream)
+ return { service, effect };
+ });
+}
+
+const getVirtualBackgroundThumbnail = (name) => {
+ if (name === BLUR_FILENAME) {
+ return BASE_PATH + '/resources/images/virtual-backgrounds/thumbnails/' + name;
+ }
+
+ return (IS_STORED_ON_BBB ? BASE_PATH : '') + THUMBNAILS_PATH + name;
+}
+
+// Stores the last chosen camera effect into the session storage in the following format:
+// {
+// type: ,
+// name: effect filename, if any
+// }
+const setSessionVirtualBackgroundInfo = (
+ type,
+ name,
+ customParams,
+ deviceId,
+) => Session.set(`VirtualBackgroundInfo_${deviceId}`, { type, name, customParams });
+
+const getSessionVirtualBackgroundInfo = (deviceId) => Session.get(`VirtualBackgroundInfo_${deviceId}`);
+
+const clearSessionVirtualBackgroundInfo = (deviceId) => Session.set(`VirtualBackgroundInfo_${deviceId}`, null);
+
+const getSessionVirtualBackgroundInfoWithDefault = (deviceId) => Session.get(`VirtualBackgroundInfo_${deviceId}`) || {
+ type: EFFECT_TYPES.NONE_TYPE,
+ name: '',
+};
+
+const isVirtualBackgroundSupported = () => {
+ return !(deviceInfo.isIos || browserInfo.isSafari);
+}
+
+const getVirtualBgImagePath = () => {
+ return (IS_STORED_ON_BBB ? BASE_PATH : '') + IMAGES_PATH;
+}
+
+export {
+ BASE_PATH,
+ MODELS,
+ THUMBNAILS_PATH,
+ SHOW_THUMBNAILS,
+ IMAGE_NAMES,
+ IS_STORED_ON_BBB,
+ IMAGES_PATH,
+ BLUR_FILENAME,
+ EFFECT_TYPES,
+ setSessionVirtualBackgroundInfo,
+ getSessionVirtualBackgroundInfo,
+ getSessionVirtualBackgroundInfoWithDefault,
+ isVirtualBackgroundSupported,
+ createVirtualBackgroundStream,
+ getVirtualBackgroundThumbnail,
+ getVirtualBgImagePath,
+ clearSessionVirtualBackgroundInfo,
+};
diff --git a/src/2.7.12/imports/ui/services/webrtc-base/bbb-video-stream.js b/src/2.7.12/imports/ui/services/webrtc-base/bbb-video-stream.js
new file mode 100644
index 00000000..d34d3c8e
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/webrtc-base/bbb-video-stream.js
@@ -0,0 +1,153 @@
+import {
+ EFFECT_TYPES,
+ BLUR_FILENAME,
+ createVirtualBackgroundStream,
+} from '/imports/ui/services/virtual-background/service';
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+import { EventEmitter2 } from 'eventemitter2';
+
+export default class BBBVideoStream extends EventEmitter2 {
+ static isVirtualBackground(type) {
+ return type === EFFECT_TYPES.IMAGE_TYPE;
+ }
+
+ static trackStreamTermination(stream, handler) {
+ const _handler = () => {
+ handler({ id: stream?.id });
+ };
+
+ // Dirty, but effective way of checking whether the browser supports the 'inactive'
+ // event. If the oninactive interface is null, it can be overridden === supported.
+ // If undefined, it's not; so we fallback to the track 'ended' event.
+ // The track ended listener should probably be reviewed once we create
+ // thin wrapper classes for MediaStreamTracks as well, because we'll want a single
+ // media stream holding multiple tracks in the future
+ if (stream.oninactive === null) {
+ stream.addEventListener('inactive', _handler, { once: true });
+ } else {
+ const track = MediaStreamUtils.getVideoTracks(stream)[0];
+ if (track) {
+ track.addEventListener('ended', _handler, { once: true });
+ // Extra safeguard: Firefox doesn't fire the 'ended' when it should
+ // but it invokes the callback (?), so hook up to both
+ track.onended = _handler;
+ }
+ }
+ }
+
+ constructor(mediaStream) {
+ super();
+ this.mediaStream = mediaStream;
+ this.originalStream = mediaStream;
+ this.effect = null;
+ this.virtualBgEnabled = false;
+ this.virtualBgService = null;
+ this.virtualBgType = EFFECT_TYPES.NONE_TYPE;
+ this.virtualBgName = BLUR_FILENAME;
+ this._trackOriginalStreamTermination();
+ }
+
+ set mediaStream(mediaStream) {
+ if (!this.mediaStream
+ || mediaStream == null
+ || mediaStream.id !== this.mediaStream.id) {
+ const oldStream = this.mediaStream;
+ this._mediaStream = mediaStream;
+ this.emit('streamSwapped', {
+ oldStream,
+ newStream: this.mediaStream,
+ });
+ }
+ }
+
+ get mediaStream() {
+ return this._mediaStream;
+ }
+
+ set virtualBgService(service) {
+ this._virtualBgService = service;
+ }
+
+ get virtualBgService() {
+ return this._virtualBgService;
+ }
+
+ _trackOriginalStreamTermination() {
+ const notify = ({ id }) => {
+ this.emit('inactive', { id });
+ };
+
+ BBBVideoStream.trackStreamTermination(this.originalStream, notify);
+ }
+
+ _changeVirtualBackground(type, name, customParams) {
+ try {
+ this.virtualBgService.changeBackgroundImage({
+ type,
+ name,
+ isVirtualBackground: BBBVideoStream.isVirtualBackground(type),
+ customParams,
+ });
+ this.virtualBgType = type;
+ this.virtualBgName = name;
+ this.customParams = customParams;
+ return Promise.resolve();
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ }
+
+ startVirtualBackground(type, name = '', customParams) {
+ if (this.virtualBgService) return this._changeVirtualBackground(type, name, customParams);
+
+ return createVirtualBackgroundStream(
+ type,
+ name,
+ BBBVideoStream.isVirtualBackground(type),
+ this.mediaStream,
+ customParams,
+ ).then(({ service, effect }) => {
+ this.virtualBgService = service;
+ this.virtualBgType = type;
+ this.virtualBgName = name;
+ this.customParams = customParams;
+ this.originalStream = this.mediaStream;
+ this.mediaStream = effect;
+ this.isVirtualBackgroundEnabled = true;
+ });
+ }
+
+ changeCameraBrightness(brightness) {
+ if (!this.virtualBgService) return;
+
+ this.virtualBgService.brightness = brightness;
+ }
+
+ toggleCameraBrightnessArea(value) {
+ if (!this.virtualBgService) return;
+
+ this.virtualBgService.wholeImageBrightness = value;
+ }
+
+ stopVirtualBackground() {
+ if (this.virtualBgService != null) {
+ this.virtualBgService.stopEffect();
+ this.virtualBgService = null;
+ }
+
+ this.virtualBgType = EFFECT_TYPES.NONE_TYPE;
+ this.virtualBgName = undefined;
+ this.mediaStream = this.originalStream;
+ this.isVirtualBackgroundEnabled = false;
+ }
+
+ stop() {
+ if (this.isVirtualBackgroundEnabled) {
+ this.stopVirtualBackground();
+ }
+
+ MediaStreamUtils.stopMediaStreamTracks(this.mediaStream);
+ this.originalStream = null;
+ this.mediaStream = null;
+ }
+}
diff --git a/src/2.7.12/imports/ui/services/webrtc-base/local-pc-loopback.js b/src/2.7.12/imports/ui/services/webrtc-base/local-pc-loopback.js
new file mode 100644
index 00000000..079cde87
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/webrtc-base/local-pc-loopback.js
@@ -0,0 +1,111 @@
+import MediaStreamUtils from '/imports/utils/media-stream-utils';
+
+export default class LocalPCLoopback {
+ constructor(constraints) {
+ this.constraints = constraints;
+ this.inputStream = null;
+ this.localPC = null;
+ this.loopbackPC = null;
+ this.loopbackStream = new MediaStream();
+ }
+
+ _initializeLocalPC() {
+ this.localPC = new RTCPeerConnection();
+ this.localPC.onicecandidate = (({ candidate }) => {
+ if (candidate && this.loopbackPC) {
+ this.loopbackPC.addIceCandidate(new RTCIceCandidate(candidate));
+ }
+ });
+ this.inputStream.getTracks().forEach((track) => this.localPC.addTrack(track, this.inputStream));
+ }
+
+ _initializeLoopbackPC() {
+ this.loopbackPC = new RTCPeerConnection();
+ this.loopbackPC.onicecandidate = (({ candidate }) => {
+ if (candidate && this.localPC) {
+ this.localPC.addIceCandidate(new RTCIceCandidate(candidate));
+ }
+ });
+ this.loopbackPC.ontrack = (({ streams }) => {
+ streams.forEach((stream) => {
+ stream.getTracks().forEach((track) => this.loopbackStream.addTrack(track));
+ });
+ });
+ }
+
+ _replaceInputStream(inputStream) {
+ let replaced = false;
+
+ if (this.localPC == null || inputStream == null || !inputStream?.active) {
+ return Promise.resolve(this.loopbackStream);
+ }
+
+ const newTracks = {
+ audio: inputStream.getAudioTracks(),
+ video: inputStream.getVideoTracks(),
+ };
+ this.localPC.getSenders().forEach((sender, index) => {
+ if (sender.track) {
+ const { kind } = sender.track;
+ if (this.constraints[kind]) {
+ const newTrack = newTracks[kind][index];
+ if (newTrack == null) return;
+ sender.replaceTrack(newTrack);
+ replaced = true;
+ }
+ }
+ });
+
+ if (replaced) this.inputStream = inputStream;
+
+ return Promise.resolve(this.loopbackStream);
+ }
+
+ async start(inputStream) {
+ if (inputStream == null || !inputStream?.active) throw (new TypeError('Invalid input stream'));
+
+ if (this.localPC && this.loopbackPC) return this._replaceInputStream(inputStream);
+
+ this.inputStream = inputStream;
+ const nOptions = {
+ offerAudio: this.constraints.audio,
+ offerVideo: this.constraints.video,
+ offerToReceiveAudio: false,
+ offerToReceiveVideo: false,
+ };
+
+ try {
+ this._initializeLocalPC();
+ this._initializeLoopbackPC();
+ const offer = await this.localPC.createOffer(nOptions);
+ await this.localPC.setLocalDescription(offer);
+ await this.loopbackPC.setRemoteDescription(offer);
+ const answer = await this.loopbackPC.createAnswer();
+ await this.loopbackPC.setLocalDescription(answer);
+ await this.localPC.setRemoteDescription(answer);
+
+ return this.loopbackStream;
+ } catch (error) {
+ // Rollback
+ this.stop();
+ throw error;
+ }
+ }
+
+ stop() {
+ if (this.localPC) {
+ this.localPC.close();
+ this.localPC = null;
+ }
+
+ if (this.loopbackPC) {
+ this.loopbackPC.close();
+ this.loopbackPC = null;
+ }
+
+ if (this.loopbackStream) {
+ MediaStreamUtils.stopMediaStreamTracks(this.loopbackStream);
+ this.loopbackStream = null;
+ }
+ }
+}
diff --git a/src/2.7.12/imports/ui/services/webrtc-base/peer.js b/src/2.7.12/imports/ui/services/webrtc-base/peer.js
new file mode 100644
index 00000000..133d7046
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/webrtc-base/peer.js
@@ -0,0 +1,477 @@
+import { EventEmitter2 } from 'eventemitter2';
+import {
+ stopStream,
+ stopTrack,
+ silentConsole,
+} from '/imports/ui/services/webrtc-base/utils';
+
+export default class WebRtcPeer extends EventEmitter2 {
+ constructor(mode, options = {}) {
+ super({ newListener: true });
+
+ this.mode = mode;
+ this.options = options;
+ this.peerConnection = this.options.peerConnection;
+ this.videoStream = this.options.videoStream;
+ this.audioStream = this.options.audioStream;
+ this.mediaConstraints = this.options.mediaConstraints;
+ this.trace = this.options.trace;
+ this.configuration = this.options.configuration;
+ this.onicecandidate = this.options.onicecandidate;
+ this.oncandidategatheringdone = this.options.oncandidategatheringdone;
+ // this.networkPriorities: <{
+ // audio: <'very-low' | 'low' | 'medium' | 'high' | undefined>
+ // video: <'very-low' | 'low' | 'medium' | 'high' | undefined>
+ // } | undefined >
+ this.networkPriorities = this.options.networkPriorities;
+
+ this.candidateGatheringDone = false;
+
+ this._outboundCandidateQueue = [];
+ this._inboundCandidateQueue = [];
+ this._waitForGatheringPromise = null;
+ this._waitForGatheringTimeout = null;
+
+ this._handleIceCandidate = this._handleIceCandidate.bind(this);
+ this._handleSignalingStateChange = this._handleSignalingStateChange.bind(this);
+ this._gatheringTimeout = this.options.gatheringTimeout;
+
+ this._assignOverrides();
+ }
+
+ _assignOverrides() {
+ if (typeof this.onicecandidate === 'function') {
+ this.on('icecandidate', this.onicecandidate);
+ }
+ if (typeof this.oncandidategatheringdone === 'function') {
+ this.on('candidategatheringdone', this.oncandidategatheringdone);
+ }
+ if (typeof this.options.mediaStreamFactory === 'function') {
+ this._mediaStreamFactory = this.options.mediaStreamFactory.bind(this);
+ }
+ }
+
+ _processEncodingOptions() {
+ this.peerConnection?.getSenders().forEach((sender) => {
+ const { track } = sender;
+ if (track) {
+ // TODO: this is not ideal and a bit anti-spec. The correct thing to do
+ // would be to set this in the transceiver creation via sendEncodings in
+ // addTransceiver, but FF doesn't support that. So we should split this
+ // between Chromium/WebKit (addTransceiver) and FF (this way) later - prlanzarin
+ const parameters = sender.getParameters();
+ // The encoder parameters might not be up yet; if that's the case,
+ // add a filler object so we can alter the parameters anyways
+ if (parameters.encodings == null || parameters.encodings.length === 0) {
+ parameters.encodings = [{}];
+ }
+
+ parameters.encodings.forEach((encoding) => {
+ // networkPriority
+ if (this.networkPriorities && this.networkPriorities[track.kind]) {
+ // eslint-disable-next-line no-param-reassign
+ encoding.networkPriority = this.networkPriorities[track.kind];
+ }
+
+ // Add further custom encoding parameters here
+ });
+
+ try {
+ sender.setParameters(parameters);
+ } catch (error) {
+ this.logger.error('BBB::WebRtcPeer::_processEncodingOptions - setParameters failed', error);
+ }
+ }
+ });
+ }
+
+ _flushInboundCandidateQueue() {
+ while (this._inboundCandidateQueue.length) {
+ const entry = this._inboundCandidateQueue.shift();
+ if (entry.candidate && entry.promise) {
+ try {
+ if (this.isPeerConnectionClosed()) {
+ entry.promise.resolve();
+ } else {
+ this.peerConnection.addIceCandidate(entry.candidate)
+ .then(entry.promise.resolve)
+ .catch(entry.promise.reject);
+ }
+ } catch (error) {
+ entry.promise.reject(error);
+ }
+ }
+ }
+ }
+
+ _trackQueueFlushEvents() {
+ this.on('newListener', (event) => {
+ if (event === 'icecandidate' || event === 'candidategatheringdone') {
+ while (this._outboundCandidateQueue.length) {
+ const candidate = this._outboundCandidateQueue.shift();
+
+ if (!candidate) this._emitCandidateGatheringDone();
+ }
+ }
+ });
+
+ this.peerConnection?.addEventListener('signalingstatechange', this._handleSignalingStateChange);
+ }
+
+ _emitCandidateGatheringDone() {
+ if (!this.candidateGatheringDone) {
+ this.emit('candidategatheringdone');
+ this.candidateGatheringDone = true;
+ }
+ }
+
+ _handleIceCandidate({ candidate }) {
+ if (this.hasListeners('icecandidate') || this.hasListeners('candidategatheringdone')) {
+ if (candidate) {
+ this.emit('icecandidate', candidate);
+ this.candidateGatheringDone = false;
+ } else this._emitCandidateGatheringDone();
+ } else if (!this.candidateGatheringDone) {
+ this._outboundCandidateQueue.push(candidate);
+ if (!candidate) this.candidateGatheringDone = true;
+ }
+ }
+
+ _handleSignalingStateChange() {
+ if (this.peerConnection?.signalingState === 'stable') {
+ this._flushInboundCandidateQueue();
+ }
+ }
+
+ waitForGathering(timeout = 0) {
+ if (timeout <= 0) return Promise.resolve();
+ if (this.isPeerConnectionClosed()) throw new Error('PeerConnection is closed');
+ if (this.peerConnection.iceGatheringState === 'complete') return Promise.resolve();
+ if (this._waitForGatheringPromise) return this._waitForGatheringPromise;
+
+ this._waitForGatheringPromise = new Promise((resolve) => {
+ this.once('candidategatheringdone', resolve);
+ this._waitForGatheringTimeout = setTimeout(() => {
+ this._emitCandidateGatheringDone();
+ }, timeout);
+ });
+
+ return this._waitForGatheringPromise;
+ }
+
+ _setRemoteDescription(rtcSessionDescription) {
+ if (this.isPeerConnectionClosed()) {
+ this.logger.error('BBB::WebRtcPeer::_setRemoteDescription - peer connection closed');
+ throw new Error('Peer connection is closed');
+ }
+
+ this.logger.debug('BBB::WebRtcPeer::_setRemoteDescription - setting remote description', rtcSessionDescription);
+ return this.peerConnection.setRemoteDescription(rtcSessionDescription);
+ }
+
+ _setLocalDescription(rtcSessionDescription) {
+ if (this.isPeerConnectionClosed()) {
+ this.logger.error('BBB::WebRtcPeer::_setLocalDescription - peer connection closed');
+ throw new Error('Peer connection is closed');
+ }
+
+ if (typeof this._gatheringTimeout === 'number' && this._gatheringTimeout > 0) {
+ this.logger.debug('BBB::WebRtcPeer::_setLocalDescription - setting description with gathering timer', rtcSessionDescription, this._gatheringTimeout);
+ return this.peerConnection.setLocalDescription(rtcSessionDescription)
+ .then(() => this.waitForGathering(this._gatheringTimeout));
+ }
+
+ this.logger.debug('BBB::WebRtcPeer::_setLocalDescription- setting description', rtcSessionDescription);
+ return this.peerConnection.setLocalDescription(rtcSessionDescription);
+ }
+
+ // Public method can be overriden via options
+ mediaStreamFactory() {
+ if (this.videoStream || this.audioStream) {
+ return Promise.resolve();
+ }
+
+ const handleGUMResolution = (stream) => {
+ if (stream.getAudioTracks().length > 0) {
+ this.audioStream = stream;
+ this.logger.debug('BBB::WebRtcPeer::mediaStreamFactory - generated audio', this.audioStream);
+ }
+ if (stream.getVideoTracks().length > 0) {
+ this.videoStream = stream;
+ this.logger.debug('BBB::WebRtcPeer::mediaStreamFactory - generated video', this.videoStream);
+ }
+
+ return stream;
+ }
+
+ if (typeof this._mediaStreamFactory === 'function') {
+ return this._mediaStreamFactory(this.mediaConstraints).then(handleGUMResolution);
+ }
+
+ this.logger.info('BBB::WebRtcPeer::mediaStreamFactory - running default factory', this.mediaConstraints);
+
+ return navigator.mediaDevices.getUserMedia(this.mediaConstraints)
+ .then(handleGUMResolution)
+ .catch((error) => {
+ this.logger.error('BBB::WebRtcPeer::mediaStreamFactory - gUM failed', error);
+ throw error;
+ });
+ }
+
+ set peerConnection(pc) {
+ this._pc = pc;
+ }
+
+ get peerConnection() {
+ return this._pc;
+ }
+
+ get logger() {
+ if (this.trace) return console;
+ return silentConsole;
+ }
+
+ getLocalSessionDescriptor() {
+ return this.peerConnection?.localDescription;
+ }
+
+ getRemoteSessionDescriptor() {
+ return this.peerConnection?.remoteDescription;
+ }
+
+ getLocalStream() {
+ if (this.peerConnection) {
+ if (this.localStream == null) this.localStream = new MediaStream();
+ const senders = this.peerConnection.getSenders();
+ const oldTracks = this.localStream.getTracks();
+
+ senders.forEach(({ track }) => {
+ if (track && !oldTracks.includes(track)) {
+ this.localStream.addTrack(track);
+ }
+ });
+
+ oldTracks.forEach((oldTrack) => {
+ if (!senders.some(({ track }) => track && track.id === oldTrack.id)) {
+ this.localStream.removeTrack(oldTrack);
+ }
+ });
+
+ return this.localStream;
+ }
+
+ return null;
+ }
+
+ getRemoteStream() {
+ if (this.remoteStream) {
+ return this.remoteStream;
+ }
+
+ if (this.peerConnection) {
+ this.remoteStream = new MediaStream();
+ this.peerConnection.getReceivers().forEach(({ track }) => {
+ if (track) {
+ this.remoteStream.addTrack(track);
+ }
+ });
+ return this.remoteStream;
+ }
+
+ return null;
+ }
+
+ isPeerConnectionClosed() {
+ return !this.peerConnection || this.peerConnection.signalingState === 'closed';
+ }
+
+ start() {
+ // Init PeerConnection
+ if (!this.peerConnection) {
+ this.peerConnection = new RTCPeerConnection(this.configuration);
+ }
+
+ if (this.isPeerConnectionClosed()) {
+ this.logger.trace('BBB::WebRtcPeer::start - peer connection closed');
+ throw new Error('Invalid peer state: closed');
+ }
+
+ this.peerConnection.addEventListener('icecandidate', this._handleIceCandidate);
+ this._trackQueueFlushEvents();
+ }
+
+ addIceCandidate(iceCandidate) {
+ const candidate = new RTCIceCandidate(iceCandidate);
+
+ switch (this.peerConnection?.signalingState) {
+ case 'closed':
+ this.logger.trace('BBB::WebRtcPeer::addIceCandidate - peer connection closed');
+ throw new Error('PeerConnection object is closed');
+ case 'stable': {
+ if (this.peerConnection.remoteDescription) {
+ this.logger.debug('BBB::WebRtcPeer::addIceCandidate - adding candidate', candidate);
+ return this.peerConnection.addIceCandidate(candidate);
+ }
+ }
+ // eslint-ignore-next-line no-fallthrough
+ default: {
+ this.logger.debug('BBB::WebRtcPeer::addIceCandidate - buffering inbound candidate', candidate);
+ const promise = new Promise();
+ this._inboundCandidateQueue.push({
+ candidate,
+ promise,
+ });
+ return promise;
+ }
+ }
+ }
+
+ async generateOffer() {
+ switch (this.mode) {
+ case 'recvonly': {
+ const useAudio = this.mediaConstraints
+ && ((typeof this.mediaConstraints.audio === 'boolean' && this.mediaConstraints.audio)
+ || (typeof this.mediaConstraints.audio === 'object'));
+ const useVideo = this.mediaConstraints
+ && ((typeof this.mediaConstraints.video === 'boolean' && this.mediaConstraints.video)
+ || (typeof this.mediaConstraints.video === 'object'));
+
+ if (useAudio) {
+ this.peerConnection.addTransceiver('audio', {
+ direction: 'recvonly',
+ });
+ }
+
+ if (useVideo) {
+ this.peerConnection.addTransceiver('video', {
+ direction: 'recvonly',
+ });
+ }
+ break;
+ }
+
+ case 'sendonly':
+ case 'sendrecv': {
+ await this.mediaStreamFactory();
+
+ if (this.videoStream) {
+ this.videoStream.getTracks().forEach((track) => {
+ this.peerConnection.addTrack(track, this.videoStream);
+ });
+ }
+
+ if (this.audioStream) {
+ this.audioStream.getTracks().forEach((track) => {
+ this.peerConnection.addTrack(track, this.audioStream);
+ });
+ }
+
+ this.peerConnection.getTransceivers().forEach((transceiver) => {
+ // eslint-disable-next-line no-param-reassign
+ transceiver.direction = this.mode;
+ });
+ break;
+ }
+
+ default:
+ break;
+ }
+
+ return this.peerConnection.createOffer()
+ .then((offer) => {
+ this.logger.debug('BBB::WebRtcPeer::generateOffer - created offer', offer);
+ return this._setLocalDescription(offer);
+ })
+ .then(() => {
+ this._processEncodingOptions();
+ const localDescription = this.getLocalSessionDescriptor();
+ this.logger.debug('BBB::WebRtcPeer::generateOffer - local description set', localDescription);
+ return localDescription.sdp;
+ });
+ }
+
+ processAnswer(sdp) {
+ const answer = new RTCSessionDescription({
+ type: 'answer',
+ sdp,
+ });
+
+ return this._setRemoteDescription(answer);
+ }
+
+ processOffer(sdp) {
+ const offer = new RTCSessionDescription({
+ type: 'offer',
+ sdp,
+ });
+
+ return this._setRemoteDescription(offer)
+ .then(async () => {
+ if (this.mode === 'sendonly' || this.mode === 'sendrecv') {
+ await this.mediaStreamFactory();
+
+ if (this.videoStream) {
+ this.videoStream.getTracks().forEach((track) => {
+ this.peerConnection.addTrack(track, this.videoStream);
+ });
+ }
+
+ if (this.audioStream) {
+ this.audioStream.getTracks().forEach((track) => {
+ this.peerConnection.addTrack(track, this.audioStream);
+ });
+ }
+
+ this.peerConnection.getTransceivers().forEach((transceiver) => {
+ // eslint-disable-next-line no-param-reassign
+ transceiver.direction = this.mode;
+ });
+ }
+ })
+ .then(() => this.peerConnection.createAnswer())
+ .then((answer) => {
+ this.logger.debug('BBB::WebRtcPeer::processOffer - created answer', answer);
+ return this._setLocalDescription(answer);
+ })
+ .then(() => {
+ const localDescription = this.getLocalSessionDescriptor();
+ this.logger.debug('BBB::WebRtcPeer::processOffer - local description set', localDescription.sdp);
+ return localDescription.sdp;
+ });
+ }
+
+ dispose() {
+ this.logger.debug('BBB::WebRtcPeer::dispose');
+
+ try {
+ if (this.peerConnection) {
+ this.peerConnection.getSenders().forEach(({ track }) => stopTrack(track));
+ if (!this.isPeerConnectionClosed()) this.peerConnection.close();
+ this.peerConnection = null;
+ }
+
+ if (this.localStream) {
+ stopStream(this.localStream);
+ this.localStream = null;
+ }
+
+ if (this.remoteStream) {
+ stopStream(this.remoteStream);
+ this.remoteStream = null;
+ }
+
+ this._outboundCandidateQueue = [];
+ this.candidateGatheringDone = false;
+
+ if (this._waitForGatheringPromise) this._waitForGatheringPromise = null;
+ if (this._waitForGatheringTimeout) {
+ clearTimeout(this._waitForGatheringTimeout);
+ this._waitForGatheringTimeout = null;
+ }
+ } catch (error) {
+ this.logger.trace('BBB::WebRtcPeer::dispose - failed', error);
+ }
+
+ this.removeAllListeners();
+ }
+}
diff --git a/src/2.7.12/imports/ui/services/webrtc-base/utils.js b/src/2.7.12/imports/ui/services/webrtc-base/utils.js
new file mode 100644
index 00000000..9947bf81
--- /dev/null
+++ b/src/2.7.12/imports/ui/services/webrtc-base/utils.js
@@ -0,0 +1,29 @@
+const stopTrack = (track) => {
+ if (track && typeof track.stop === 'function' && track.readyState !== 'ended') {
+ track.stop();
+ // Manually emit the event as a safeguard; Firefox doesn't fire it when it
+ // should with live MediaStreamTracks...
+ const trackStoppedEvt = new MediaStreamTrackEvent('ended', { track });
+ track.dispatchEvent(trackStoppedEvt);
+ }
+};
+
+const stopStream = (stream) => {
+ stream.getTracks().forEach(stopTrack);
+};
+
+const silentConsole = {
+ log: () => {},
+ info: () => {},
+ error: () => {},
+ warn: () => {},
+ debug: () => {},
+ trace: () => {},
+ assert: () => {},
+};
+
+export {
+ stopStream,
+ stopTrack,
+ silentConsole,
+};
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/breakpoints.js b/src/2.7.12/imports/ui/stylesheets/styled-components/breakpoints.js
new file mode 100644
index 00000000..82aa979d
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/breakpoints.js
@@ -0,0 +1,21 @@
+const smallOnly = 'only screen and (max-width: 40em)';
+const mediumOnly = 'only screen and (min-width: 40.063em) and (max-width: 64em)';
+const mediumUp = 'only screen and (min-width: 40.063em)';
+const mediumDown = 'only screen and (max-width: 40.0629em)';
+const landscape = "only screen and (orientation: landscape)";
+const phoneLandscape = 'only screen and (max-width: 480px) and (orientation: landscape)';
+const largeUp = 'only screen and (min-width: 64.063em)';
+const hasPhoneDimentions = 'only screen and (max-height: 479px), only screen and (max-width: 479px)';
+const hasPhoneWidth = 'only screen and (max-width: 479px)';
+
+export {
+ smallOnly,
+ mediumOnly,
+ mediumUp,
+ landscape,
+ phoneLandscape,
+ largeUp,
+ hasPhoneDimentions,
+ mediumDown,
+ hasPhoneWidth,
+};
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/general.js b/src/2.7.12/imports/ui/stylesheets/styled-components/general.js
new file mode 100644
index 00000000..8b45d817
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/general.js
@@ -0,0 +1,173 @@
+const borderSizeSmall = '1px';
+const borderSize = '2px';
+const borderSizeLarge = '3px';
+const borderRadius = '.2rem';
+const smPaddingX = '.75rem';
+const smPaddingY = '.3rem';
+const mdPaddingY = '.45rem';
+const mdPaddingX = '1rem';
+const lgPaddingX = '1.25rem';
+const lgPaddingY = '0.6rem';
+const jumboPaddingY = '1.5rem';
+const jumboPaddingX = '3.025rem';
+
+const whiteboardToolbarPadding = '.5rem';
+const whiteboardToolbarMargin = '.5rem';
+const whiteboardToolbarPaddingSm = '.3rem';
+const minModalHeight = '20rem';
+const descriptionMargin = '3.5rem';
+const navbarHeight = '3.9375rem';
+const barsPadding = '0.8rem'; // so user-list and chat title is aligned with the presentation title
+const pollHeaderOffset = '-0.875rem';
+const toastContentWidth = '98%';
+const modalMargin = '3rem';
+const titlePositionLeft = '2.2rem';
+const userIndicatorsOffset = '-5px';
+const indicatorPadding = '.45rem'; // used to center presenter indicator icon in Chrome / Firefox / Edge
+const actionsBarHeight = '75px'; // TODO: Change to ActionsBar real height
+const audioIndicatorWidth = '1.12rem';
+const audioIndicatorFs = '75%';
+const chatPollMarginSm = '.5rem';
+
+const talkerBorderRadius = '2rem';
+const talkerPaddingXsm = '.13rem';
+const talkerPaddingLg = '.75rem';
+const talkerPaddingXl = '1.62rem';
+const talkerMaxWidth = '10rem';
+const talkerMarginSm = '.5rem';
+const spokeOpacity = '.5';
+
+const toolbarButtonWidth = '3rem';
+const toolbarButtonHeight = '3rem';
+const toolbarItemOutlineOffset = '-.19rem';
+const toolbarButtonBorder = '1px';
+const toolbarButtonBorderRadius = '5px';
+const toolbarItemTrianglePadding = '2px';
+const toolbarMargin = '.8rem';
+
+const fileLineWidth = '16.75rem';
+const iconPaddingMd = '.4rem';
+const statusIconSize = '16px';
+const toastMdMargin = '.5rem';
+const toastMarginMobile = '.35rem';
+const uploadListHeight = '30vh';
+const modalInnerWidth = '40rem';
+const statusInfoHeight = '8px';
+const itemActionsWidth = '68px'; // size of the 2 icons (check/trash)
+const uploadIconSize = '2.286rem';
+const iconLineHeight = '2.35rem';
+const innerToastWidth = '17rem';
+const toastIconSide = '40px';
+const pollStatsElementWidth = '17%';
+const pollSmMargin = '0.3125rem';
+const pollMdMargin = '0.7rem';
+const pollResultWidth = '15rem';
+const pollInputHeight = '2.5rem';
+const pollWidth = '18rem';
+const overlayIndex = '999';
+const overlayOpacity = '0.349';
+const pollIndex = '1016';
+const pollBottomOffset = '4.5rem';
+const pollColAmount = '2';
+
+const toastMargin = '.5rem';
+const avatarSide = '34px';
+const avatarWrapperOffset = '14px';
+const avatarInset = '-7px';
+
+const dropdownCaretHeight = '8px';
+const dropdownCaretWidth = '12px';
+
+const toastOffsetSm = '.325rem';
+const btnSpacing = '.35rem';
+
+const toastIconMd = '2rem';
+const toastIconSm = '1.2rem';
+
+const presentationMenuHeight = '45px';
+
+const styleMenuOffset = '6.25rem';
+const styleMenuOffsetSmall = '5rem';
+
+export {
+ borderSizeSmall,
+ borderSize,
+ borderSizeLarge,
+ borderRadius,
+ smPaddingX,
+ smPaddingY,
+ mdPaddingX,
+ mdPaddingY,
+ lgPaddingX,
+ lgPaddingY,
+ jumboPaddingY,
+ jumboPaddingX,
+ whiteboardToolbarPadding,
+ whiteboardToolbarPaddingSm,
+ minModalHeight,
+ descriptionMargin,
+ navbarHeight,
+ barsPadding,
+ pollHeaderOffset,
+ toastContentWidth,
+ modalMargin,
+ titlePositionLeft,
+ userIndicatorsOffset,
+ indicatorPadding,
+ actionsBarHeight,
+ audioIndicatorWidth,
+ audioIndicatorFs,
+ chatPollMarginSm,
+ talkerBorderRadius,
+ talkerPaddingXsm,
+ talkerPaddingLg,
+ talkerMaxWidth,
+ talkerMarginSm,
+ spokeOpacity,
+ talkerPaddingXl,
+ toolbarButtonWidth,
+ toolbarButtonHeight,
+ toolbarItemOutlineOffset,
+ toolbarButtonBorder,
+ toolbarButtonBorderRadius,
+ toolbarItemTrianglePadding,
+ toolbarMargin,
+ whiteboardToolbarMargin,
+ fileLineWidth,
+ iconPaddingMd,
+ statusIconSize,
+ toastMdMargin,
+ toastMarginMobile,
+ uploadListHeight,
+ modalInnerWidth,
+ statusInfoHeight,
+ itemActionsWidth,
+ uploadIconSize,
+ iconLineHeight,
+ innerToastWidth,
+ toastIconSide,
+ pollStatsElementWidth,
+ pollSmMargin,
+ pollMdMargin,
+ pollResultWidth,
+ pollInputHeight,
+ pollWidth,
+ overlayIndex,
+ overlayOpacity,
+ pollIndex,
+ pollBottomOffset,
+ pollColAmount,
+ toastMargin,
+ avatarSide,
+ avatarWrapperOffset,
+ avatarInset,
+ dropdownCaretHeight,
+ dropdownCaretWidth,
+ toastOffsetSm,
+ btnSpacing,
+ toastIconMd,
+ toastIconSm,
+ presentationMenuHeight,
+ styleMenuOffset,
+ styleMenuOffsetSmall,
+};
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/globalStyles.js b/src/2.7.12/imports/ui/stylesheets/styled-components/globalStyles.js
new file mode 100644
index 00000000..4a9e39a7
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/globalStyles.js
@@ -0,0 +1,170 @@
+import { createGlobalStyle } from 'styled-components';
+import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
+import {
+ smPaddingX,
+ borderRadius,
+ borderSize,
+ borderSizeSmall,
+} from '/imports/ui/stylesheets/styled-components/general';
+import {
+ dropdownBg,
+ colorText,
+ colorWhite,
+ colorGrayLighter,
+ colorOverlay,
+} from '/imports/ui/stylesheets/styled-components/palette';
+
+const GlobalStyle = createGlobalStyle`
+ // BBBMenu
+ @media ${smallOnly} {
+ .MuiPopover-root {
+ top: 0 !important;
+ }
+ .MuiPaper-root-mobile {
+ top: 0 !important;
+ left: 0 !important;
+ bottom: 0 !important;
+ right: 0 !important;
+ max-width: none !important;
+ }
+ }
+ .MuiList-padding {
+ padding: 0 !important;
+ }
+ .MuiPaper-root {
+ background-color: ${dropdownBg};
+ border-radius: ${borderRadius};
+ border: 0;
+ z-index: 999;
+ max-width: 22rem;
+ }
+
+ // modal
+ @keyframes fade-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ .permissionsOverlay {
+ position: fixed;
+ z-index: 1002;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: rgba(0, 0, 0, .85);
+ animation: fade-in .5s ease-in;
+ }
+
+ .modalOverlay {
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: ${colorOverlay};
+ }
+
+ .fullscreenModalOverlay {
+ z-index: 1000;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+
+ // toast
+ .toastClass {
+ position: relative;
+ margin-bottom: ${smPaddingX};
+ padding: ${smPaddingX};
+ border-radius: ${borderRadius};
+ box-shadow: 0 ${borderSizeSmall} 10px 0 rgba(0, 0, 0, 0.1), 0 ${borderSize} 15px 0 rgba(0, 0, 0, 0.05);
+ display: flex;
+ justify-content: space-between;
+ color: ${colorText};
+ -webkit-animation-duration: 0.75s;
+ animation-duration: 0.75s;
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+ max-width: 20rem !important;
+ min-width: 20rem !important;
+ width: 20rem !important;
+ cursor: pointer;
+ background-color: ${colorWhite};
+
+ &:hover,
+ &:focus {
+ background-color: #EEE;
+ }
+ }
+
+ .toastBodyClass {
+ margin: auto auto;
+ flex: 1;
+ background-color: inherit;
+ max-width: 17.75rem !important;
+ }
+
+ @keyframes track-progress {
+ 0% {
+ width: 100%;
+ }
+ 100% {
+ width: 0;
+ }
+ }
+
+ .toastProgressClass {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: auto;
+ width: 0;
+ height: 5px;
+ z-index: 999;
+ animation: track-progress linear 1;
+ background-color: ${colorGrayLighter};
+ border-radius: ${borderRadius};
+
+ [dir="rtl"] & {
+ left: auto;
+ right: 0;
+ }
+ }
+
+ .actionToast {
+ background-color: ${colorWhite};
+ display: flex;
+ padding: ${smPaddingX};
+ border-radius: ${borderRadius};
+
+ i.close {
+ left: none !important;
+ }
+ }
+
+ .raiseHandToast {
+ background-color: ${colorWhite};
+ padding: 1rem;
+
+ i.close {
+ left: none !important;
+ }
+ }
+`;
+
+export default GlobalStyle;
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/palette.js b/src/2.7.12/imports/ui/stylesheets/styled-components/palette.js
new file mode 100644
index 00000000..4d0d1c06
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/palette.js
@@ -0,0 +1,218 @@
+const colorWhite = 'var(--color-white, #FFF)';
+const colorOffWhite = 'var(--color-off-white, #F3F6F9)';
+
+const colorBlack = 'var(--color-black, #000000)';
+
+const colorGray = 'var(--color-gray, #4E5A66)';
+const colorGrayDark = 'var(--color-gray-dark, #06172A)';
+const colorGrayLight = 'var(--color-gray-light, #8B9AA8)';
+const colorGrayLighter = 'var(--color-gray-lighter, #A7B3BD)';
+const colorGrayLightest = 'var(--color-gray-lightest, #D4D9DF)';
+
+const colorBlueLight = 'var(--color-blue-light, #54a1f3)';
+const colorBlueLighter = 'var(--color-blue-lighter, #92BCEA)';
+const colorBlueLightest = 'var(--color-blue-lightest, #E4ECF2)';
+
+const colorTransparent = 'var(--color-transparent, #ff000000)';
+
+const colorPrimary = 'var(--color-primary, #0F70D7)';
+const colorDanger = 'var(--color-danger, #DF2721)';
+const colorDangerDark = 'var(--color-danger-dark, #AE1010)';
+const colorSuccess = 'var(--color-success, #008081)';
+const colorWarning = 'var(--color-warning, purple)';
+const colorOffline = `var(--color-offline, ${colorGrayLight})`;
+const colorMuted = 'var(--color-muted, #586571)';
+const colorMutedBackground = 'var(--color-muted-background, #F3F6F9)';
+
+const colorBackground = `var(--color-background, ${colorGrayDark})`;
+const colorOverlay = 'var(--color-overlay, rgba(6, 23, 42, 0.75))';
+
+const userListBg = `var(--user-list-bg, ${colorOffWhite})`;
+const userListText = `var(--user-list-text, ${colorGray})`;
+const unreadMessagesBg = `var(--unread-messages-bg, ${colorDanger})`;
+const colorGrayLabel = `var(--color-gray-label, ${colorGray})`;
+const colorText = `var(--color-text, ${colorGray})`;
+const colorLink = `var(--color-link, ${colorPrimary})`;
+
+const listItemBgHover = 'var(--list-item-bg-hover, #DCE4ED)';
+const colorTipBg = 'var(--color-tip-bg, #333333)';
+const itemFocusBorder = `var(--item-focus-border, ${colorBlueLighter})`;
+
+const btnDefaultColor = `var(--btn-default-color, ${colorGray})`;
+const btnDefaultBg = `var(--btn-default-bg, ${colorWhite})`;
+const btnDefaultBorder = `var(--btn-default-border, ${colorWhite})`;
+
+const btnDefaultGhostColor = `var(--btn-default-color, ${colorWhite})`;
+const btnDefaultGhostBg = 'var(--btn-default-bg, rgba(255, 255, 255, 0.1))'; // colorWhite, 10%
+const btnDefaultGhostBorder = 'var(--btn-default-border, rgba(255, 255, 255, 0.5))'; // colorWhite, 50%
+const btnDefaultGhostActiveBg = 'var(--btn-default-active-bg, rgba(255, 255, 255, 0.2))'; // colorWhite, 20%
+
+const btnPrimaryBorder = 'var(--btn-primary-border, rgba(15, 112, 215, 0.5))'; // colorPrimary, 50%
+const btnPrimaryColor = `var(--btn-primary-color, ${colorWhite})`;
+const btnPrimaryBg = `var(--btn-primary-bg, ${colorPrimary})`;
+const btnPrimaryHoverBg = 'var(--btn-primary-hover-bg, #0C57A7)';
+const btnPrimaryActiveBg = 'var(--btn-primary-active-bg, #0A4B8F)';
+
+const btnSuccessBorder = `var(--btn-success-border, ${colorSuccess})`;
+const btnSuccessColor = `var(--btn-success-color, ${colorWhite})`;
+const btnSuccessBg = `var(--btn-success-bg, ${colorSuccess})`;
+
+const btnWarningBorder = `var(--btn-warning-border, ${colorWarning})`;
+const btnWarningColor = `var(--btn-warning-color, ${colorWhite})`;
+const btnWarningBg = `var(--btn-warning-bg, ${colorWarning})`;
+
+const btnDangerBorder = `var(--btn-danger-border, ${colorDanger})`;
+const btnDangerColor = `var(--btn-danger-color, ${colorWhite})`;
+const btnDangerBg = `var(--btn-danger-bg, ${colorDanger})`;
+const btnDangerBgHover = 'var(--btn-danger-bg-hover, #C61C1C)';
+
+const btnDarkBorder = `var(--btn-dark-border, ${colorDanger})`;
+const btnDarkColor = `var(--btn-dark-color, ${colorWhite})`;
+const btnDarkBg = `var(--btn-dark-bg, ${colorGrayDark})`;
+
+const btnOfflineBorder = `var(--btn-offline-border, ${colorOffline})`;
+const btnOfflineColor = `var(--btn-offline-color, ${colorWhite})`;
+const btnOfflineBg = `var(--btn-offline-bg, ${colorOffline})`;
+
+const btnMutedBorder = `var(--btn-muted-border, ${colorMutedBackground})`;
+const btnMutedColor = `var(--btn-muted-color, ${colorMuted})`;
+const btnMutedBg = `var(--btn-muted-bg, ${colorMutedBackground})`;
+
+const toolbarButtonColor = `var(--toolbar-button-color, ${btnDefaultColor})`;
+const userThumbnailBorder = `var(--user-thumbnail-border, ${colorGrayLight})`;
+const loaderBg = `var(--loader-bg, ${colorGrayDark})`;
+const loaderBullet = `var(--loader-bullet, ${colorWhite})`;
+
+const systemMessageBackgroundColor = 'var(--system-message-background-color, #F9FBFC)';
+const systemMessageBorderColor = 'var(--system-message-border-color, #C5CDD4)';
+const systemMessageFontColor = `var(--system-message-font-color, ${colorGrayDark})`;
+const highlightedMessageBackgroundColor = 'var(--system-message-background-color, #fef9f1)';
+const highlightedMessageBorderColor = 'var(--system-message-border-color, #f5c67f)';
+const colorHeading = `var(--color-heading, ${colorGrayDark})`;
+const palettePlaceholderText = 'var(--palette-placeholder-text, #787675)';
+const pollAnnotationGray = 'var(--poll-annotation-gray, #333333)';
+
+const toolbarButtonBorderColor = `var(--toolbar-button-border-color, ${colorGrayLighter})`;
+const toolbarListColor = `var(--toolbar-list-color, ${colorGray})`;
+const toolbarButtonBg = `var(--toolbar-button-bg, ${btnDefaultBg})`;
+const toolbarListBg = 'var(--toolbar-list-bg, #DDD)';
+const toolbarListBgFocus = 'var(--toolbar-list-bg-focus, #C6C6C6)';
+const colorContentBackground = 'var(--color-content-background, #1B2A3A)';
+
+const dropdownBg = `var(--dropdown-bg, ${colorWhite})`;
+
+const pollStatsBorderColor = 'var(--poll-stats-border-color, #D4D9DF)';
+const pollBlue = `var(--poll-blue, ${colorPrimary})`;
+
+const toastDefaultColor = `var(--toast-default-color, ${colorWhite})`;
+const toastDefaultBg = `var(--toast-default-bg, ${colorGray})`;
+
+const toastInfoColor = `var(--toast-info-color, ${colorWhite})`;
+const toastInfoBg = `var(--toast-info-bg, ${colorPrimary})`;
+
+const toastSuccessColor = `var(--toast-success-color, ${colorWhite})`;
+const toastSuccessBg = `var(--toast-success-bg, ${colorSuccess})`;
+
+const toastErrorColor = `var(--toast-error-color, ${colorWhite})`;
+const toastErrorBg = `var(--toast-error-bg, ${colorDanger})`;
+
+const webcamBackgroundColor = 'var(--webcam-background-color, #001428FF)';
+const webcamPlaceholderBorder = 'var(--webcam-placeholder-border, rgba(255, 255, 255, 0.5))'; // colorWhite, 50%
+
+const toastWarningColor = `var(--toast-warning-color, ${colorWhite})`;
+const toastWarningBg = `var(--toast-warning-bg, ${colorWarning})`;
+
+export {
+ colorWhite,
+ colorOffWhite,
+ colorBlack,
+ colorGray,
+ colorGrayDark,
+ colorGrayLight,
+ colorGrayLighter,
+ colorGrayLightest,
+ colorTransparent,
+ colorBlueLight,
+ colorBlueLighter,
+ colorBlueLightest,
+ colorPrimary,
+ colorDanger,
+ colorDangerDark,
+ colorSuccess,
+ colorWarning,
+ colorBackground,
+ colorOverlay,
+ userListBg,
+ userListText,
+ unreadMessagesBg,
+ colorGrayLabel,
+ colorText,
+ colorLink,
+ listItemBgHover,
+ colorTipBg,
+ itemFocusBorder,
+ btnDefaultColor,
+ btnDefaultBg,
+ btnDefaultBorder,
+ btnDefaultGhostColor,
+ btnDefaultGhostBg,
+ btnDefaultGhostBorder,
+ btnDefaultGhostActiveBg,
+ btnPrimaryBorder,
+ btnPrimaryColor,
+ btnPrimaryBg,
+ btnPrimaryHoverBg,
+ btnPrimaryActiveBg,
+ btnSuccessBorder,
+ btnSuccessColor,
+ btnSuccessBg,
+ btnWarningBorder,
+ btnWarningColor,
+ btnWarningBg,
+ btnDangerBorder,
+ btnDangerColor,
+ btnDangerBg,
+ btnDangerBgHover,
+ btnDarkBorder,
+ btnDarkColor,
+ btnDarkBg,
+ btnOfflineBorder,
+ btnOfflineColor,
+ btnOfflineBg,
+ btnMutedBorder,
+ btnMutedColor,
+ btnMutedBg,
+ toolbarButtonColor,
+ userThumbnailBorder,
+ loaderBg,
+ loaderBullet,
+ systemMessageBackgroundColor,
+ systemMessageBorderColor,
+ systemMessageFontColor,
+ highlightedMessageBackgroundColor,
+ highlightedMessageBorderColor,
+ colorHeading,
+ palettePlaceholderText,
+ pollAnnotationGray,
+ toolbarButtonBorderColor,
+ toolbarListColor,
+ toolbarButtonBg,
+ toolbarListBg,
+ toolbarListBgFocus,
+ pollStatsBorderColor,
+ pollBlue,
+ colorContentBackground,
+ dropdownBg,
+ toastDefaultColor,
+ toastDefaultBg,
+ toastInfoColor,
+ toastInfoBg,
+ toastSuccessColor,
+ toastSuccessBg,
+ toastErrorColor,
+ toastErrorBg,
+ toastWarningColor,
+ toastWarningBg,
+ webcamBackgroundColor,
+ webcamPlaceholderBorder,
+};
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/placeholders.js b/src/2.7.12/imports/ui/stylesheets/styled-components/placeholders.js
new file mode 100644
index 00000000..5abbfbd1
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/placeholders.js
@@ -0,0 +1,61 @@
+import styled from 'styled-components';
+import Button from '/imports/ui/components/common/button/component';
+
+const FlexColumn = styled.div`
+ display: flex;
+ flex-flow: column;
+`;
+
+const FlexRow = styled.div`
+ display: flex;
+ flex-flow: row;
+`;
+
+const DivElipsis = styled.div`
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const TextElipsis = styled.span`
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const TitleElipsis = styled.h2`
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const HeaderElipsis = styled.h3`
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const ButtonElipsis = styled(Button)`
+ min-width: 0;
+ display: inline-block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+export {
+ FlexColumn,
+ FlexRow,
+ DivElipsis,
+ TextElipsis,
+ TitleElipsis,
+ HeaderElipsis,
+ ButtonElipsis,
+};
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/scrollable.js b/src/2.7.12/imports/ui/stylesheets/styled-components/scrollable.js
new file mode 100644
index 00000000..19df490a
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/scrollable.js
@@ -0,0 +1,126 @@
+import styled from 'styled-components';
+import { List } from 'react-virtualized';
+import ReactModal from 'react-modal';
+
+const ScrollboxVertical = styled.div`
+ overflow-y: auto;
+ background: linear-gradient(white 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), white 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ background-repeat: no-repeat;
+ background-color: transparent;
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
+ background-attachment: local, local, scroll, scroll;
+
+ // Fancy scroll
+ &::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+ &::-webkit-scrollbar-button {
+ width: 0;
+ height: 0;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.5); }
+ &::-webkit-scrollbar-thumb:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-track:hover { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-corner { background: 0 0; }
+`;
+
+const VirtualizedScrollboxVertical = styled(List)`
+ overflow-y: auto;
+ background: linear-gradient(white 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), white 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ background-repeat: no-repeat;
+ background-color: transparent;
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
+ background-attachment: local, local, scroll, scroll;
+
+ // Fancy scroll
+ &::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+ &::-webkit-scrollbar-button {
+ width: 0;
+ height: 0;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.5); }
+ &::-webkit-scrollbar-thumb:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-track:hover { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-corner { background: 0 0; }
+`;
+
+const ModalScrollboxVertical = styled(ReactModal)`
+ overflow-y: auto;
+ background: linear-gradient(white 30%, rgba(255,255,255,0)),
+ linear-gradient(rgba(255,255,255,0), white 70%) 0 100%,
+ /* Shadows */
+ radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)),
+ radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
+
+ background-repeat: no-repeat;
+ background-color: transparent;
+ background-size: 100% 40px, 100% 40px, 100% 14px, 100% 14px;
+ background-attachment: local, local, scroll, scroll;
+
+ // Fancy scroll
+ &::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+ &::-webkit-scrollbar-button {
+ width: 0;
+ height: 0;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,.5); }
+ &::-webkit-scrollbar-thumb:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track {
+ background: rgba(0,0,0,.25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-track:hover { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-track:active { background: rgba(0,0,0,.25); }
+ &::-webkit-scrollbar-corner { background: 0 0; }
+`;
+
+export {
+ ScrollboxVertical,
+ VirtualizedScrollboxVertical,
+ ModalScrollboxVertical,
+};
diff --git a/src/2.7.12/imports/ui/stylesheets/styled-components/typography.js b/src/2.7.12/imports/ui/stylesheets/styled-components/typography.js
new file mode 100644
index 00000000..a1c0408f
--- /dev/null
+++ b/src/2.7.12/imports/ui/stylesheets/styled-components/typography.js
@@ -0,0 +1,38 @@
+const lineHeightComputed = '1rem';
+const lineHeightBase = '1.25';
+const fontSizeBase = '1rem';
+const fontSizeSmall = '0.875rem';
+const fontSizeSmaller = '.75rem';
+const fontSizeSmallest = '.35rem';
+const fontSizeXS = '.575rem';
+const fontSizeLarge = '1.25rem';
+const fontSizeLarger = '1.5rem';
+const fontSizeXL = '1.75rem';
+const fontSizeXXL = '2.75rem';
+const fontSizeMD = '0.95rem';
+
+const headingsFontWeight = '500';
+const btnFontWeight = '600';
+const talkerFontWeight = '400';
+const toolbarButtonFontSize = '1.75rem';
+const modalTitleFw = '400';
+
+export {
+ lineHeightComputed,
+ lineHeightBase,
+ fontSizeBase,
+ fontSizeSmall,
+ fontSizeSmaller,
+ fontSizeSmallest,
+ fontSizeXS,
+ fontSizeLarge,
+ fontSizeLarger,
+ fontSizeXL,
+ fontSizeXXL,
+ fontSizeMD,
+ headingsFontWeight,
+ btnFontWeight,
+ talkerFontWeight,
+ toolbarButtonFontSize,
+ modalTitleFw,
+};
diff --git a/src/2.7.12/imports/utils/array-utils.js b/src/2.7.12/imports/utils/array-utils.js
new file mode 100644
index 00000000..fd59d979
--- /dev/null
+++ b/src/2.7.12/imports/utils/array-utils.js
@@ -0,0 +1,39 @@
+import { isObject } from 'radash';
+
+export const range = (start, end) => {
+ const length = end - start;
+ return Array.from({ length }, (_, i) => start + i);
+};
+
+export const partition = (arr, criteria) => [
+ arr.filter((item) => criteria(item)),
+ arr.filter((item) => !criteria(item)),
+];
+
+export const indexOf = (arr, value) => (arr ? arr.findIndex((item) => item === value) : -1);
+
+export const without = (arr, value) => arr.filter((item) => item !== value);
+
+export const defaultsDeep = (override, initial) => {
+ if (!initial || !override) return initial ?? override ?? {};
+
+ return Object.entries({ ...initial, ...override }).reduce(
+ (acc, [key, value]) => ({
+ ...acc,
+ [key]: (() => {
+ if (isObject(initial[key])) {
+ return defaultsDeep(value, initial[key]);
+ }
+ return value;
+ })(),
+ }), {},
+ );
+};
+
+export default {
+ range,
+ partition,
+ indexOf,
+ without,
+ defaultsDeep,
+};
diff --git a/src/2.7.12/imports/utils/browserInfo.js b/src/2.7.12/imports/utils/browserInfo.js
new file mode 100644
index 00000000..235e22ce
--- /dev/null
+++ b/src/2.7.12/imports/utils/browserInfo.js
@@ -0,0 +1,56 @@
+import Bowser from 'bowser';
+import logger from '/imports/startup/client/logger';
+
+const userAgent = window.navigator.userAgent;
+const BOWSER_RESULTS = Bowser.parse(userAgent);
+
+const isChrome = BOWSER_RESULTS.browser.name === 'Chrome';
+const isSafari = BOWSER_RESULTS.browser.name === 'Safari';
+const isEdge = BOWSER_RESULTS.browser.name === 'Microsoft Edge';
+const isIe = BOWSER_RESULTS.browser.name === 'Internet Explorer';
+const isFirefox = BOWSER_RESULTS.browser.name === 'Firefox';
+
+const browserName = BOWSER_RESULTS.browser.name;
+
+const getVersionNumber = () => {
+ if (BOWSER_RESULTS.browser.version) return BOWSER_RESULTS.browser.version;
+
+ // There are some scenarios (e.g.; WKWebView) where Bowser can't detect the
+ // Safari version. In such cases, we can use the WebKit version to determine
+ // it.
+ if (isSafari && BOWSER_RESULTS.engine.version) return BOWSER_RESULTS.engine.version;
+
+ // If the version number is not available, log an warning and return Infinity
+ // so that we do not deny access to the user (even if we're uncertain about
+ // whether it's a supported browser).
+ logger.warn({
+ logCode: 'browserInfo_invalid_version',
+ extraInfo: {
+ userAgent,
+ },
+ }, 'Unable to determine the browser version number');
+
+ return 'Infinity';
+};
+
+const versionNumber = getVersionNumber();
+
+const isValidSafariVersion = Bowser.getParser(userAgent).satisfies({
+ safari: '>12',
+});
+
+const isTabletApp = !!(userAgent.match(/BigBlueButton-Tablet/i));
+
+const browserInfo = {
+ isChrome,
+ isSafari,
+ isEdge,
+ isIe,
+ isFirefox,
+ browserName,
+ versionNumber,
+ isValidSafariVersion,
+ isTabletApp
+};
+
+export default browserInfo;
diff --git a/src/2.7.12/imports/utils/caseInsensitiveReducer.js b/src/2.7.12/imports/utils/caseInsensitiveReducer.js
new file mode 100644
index 00000000..f8cd942d
--- /dev/null
+++ b/src/2.7.12/imports/utils/caseInsensitiveReducer.js
@@ -0,0 +1,17 @@
+const caseInsensitiveReducer = (acc, item) => {
+ const index = acc.findIndex(ans => ans.key.toLowerCase() === item.key.toLowerCase());
+ if(index !== -1) {
+ if(acc[index].numVotes >= item.numVotes) acc[index].numVotes += item.numVotes;
+ else {
+ const tempVotes = acc[index].numVotes;
+ acc[index] = item;
+ acc[index].numVotes += tempVotes;
+ }
+ } else {
+ acc.push(item);
+ }
+ return acc;
+};
+
+export default caseInsensitiveReducer;
+
\ No newline at end of file
diff --git a/src/2.7.12/imports/utils/debounce.js b/src/2.7.12/imports/utils/debounce.js
new file mode 100644
index 00000000..bd78c8af
--- /dev/null
+++ b/src/2.7.12/imports/utils/debounce.js
@@ -0,0 +1,52 @@
+/**
+ * Debounce function, includes leading and trailing options (lodash-like)
+ * @param {Function} func - function to be debounced
+ * @param {Number} delay - delay in milliseconds
+ * @param {Object} options - options object
+ * @param {Boolean} options.leading - whether to invoke the function on the leading edge
+ * @param {Boolean} options.trailing - whether to invoke the function on the trailing edge
+ * @returns {Function} - debounced function
+ */
+export function debounce(func, delay, options = {}) {
+ let timeoutId;
+ let lastArgs;
+ let lastThis;
+ let calledOnce = false;
+
+ const { leading = false, trailing = true } = options;
+
+ function invokeFunc() {
+ func.apply(lastThis, lastArgs);
+ lastArgs = null;
+ lastThis = null;
+ }
+
+ return function (...args) {
+ lastArgs = args;
+ lastThis = this;
+
+ if (!timeoutId) {
+ if (leading && !calledOnce) {
+ invokeFunc();
+ calledOnce = true;
+ }
+
+ timeoutId = setTimeout(() => {
+ if (!trailing) {
+ clearTimeout(timeoutId);
+ timeoutId = null;
+ } else {
+ invokeFunc();
+ timeoutId = null;
+ }
+ calledOnce = false;
+ }, delay);
+ } else if (trailing) {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => {
+ invokeFunc();
+ timeoutId = null;
+ }, delay);
+ }
+ };
+}
diff --git a/src/2.7.12/imports/utils/deviceInfo.js b/src/2.7.12/imports/utils/deviceInfo.js
new file mode 100644
index 00000000..726ee6b6
--- /dev/null
+++ b/src/2.7.12/imports/utils/deviceInfo.js
@@ -0,0 +1,30 @@
+import Bowser from 'bowser';
+
+const userAgent = window.navigator.userAgent;
+const BOWSER_RESULTS = Bowser.parse(userAgent);
+
+const isPhone = BOWSER_RESULTS.platform.type === 'mobile';
+// we need a 'hack' to correctly detect ipads with ios > 13
+const isTablet = BOWSER_RESULTS.platform.type === 'tablet' || (BOWSER_RESULTS.os.name === 'macOS' && window.navigator.maxTouchPoints > 0);
+const isMobile = isPhone || isTablet;
+const hasMediaDevices = !!navigator.mediaDevices;
+const osName = BOWSER_RESULTS.os.name;
+const isIos = osName === 'iOS' || (isTablet && osName=="macOS");
+const isMacos = osName === 'macOS';
+const isIphone = !!(userAgent.match(/iPhone/i));
+
+const isPortrait = () => window.innerHeight > window.innerWidth;
+
+const deviceInfo = {
+ isTablet,
+ isPhone,
+ isMobile,
+ hasMediaDevices,
+ osName,
+ isPortrait,
+ isIos,
+ isMacos,
+ isIphone,
+};
+
+export default deviceInfo;
diff --git a/src/2.7.12/imports/utils/dom-utils.js b/src/2.7.12/imports/utils/dom-utils.js
new file mode 100644
index 00000000..6ac1c258
--- /dev/null
+++ b/src/2.7.12/imports/utils/dom-utils.js
@@ -0,0 +1,32 @@
+
+const TITLE_WITH_VIEW = 3;
+
+const getTitleData = () => {
+ const title = document.getElementsByTagName('title')[0];
+ return { title, data: title?.text?.split(' - ') };
+}
+
+export const registerTitleView = (v) => {
+ const { title, data } = getTitleData();
+ if (data.length < TITLE_WITH_VIEW) data.push(`${v}`);
+ else data.splice(TITLE_WITH_VIEW - 1, TITLE_WITH_VIEW, v);
+ title.text = data.join(' - ');
+};
+
+export const unregisterTitleView = () => {
+ const { title, data } = getTitleData();
+ if (data.length === TITLE_WITH_VIEW) {
+ data.splice(TITLE_WITH_VIEW - 1, TITLE_WITH_VIEW, 'Default');
+ }
+ title.text = data.join(' - ');
+};
+
+export const convertRemToPixels = (rem) => {
+ return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
+}
+
+export default {
+ registerTitleView,
+ unregisterTitleView,
+ convertRemToPixels,
+};
diff --git a/src/2.7.12/imports/utils/fetchStunTurnServers.js b/src/2.7.12/imports/utils/fetchStunTurnServers.js
new file mode 100644
index 00000000..f8fc32cf
--- /dev/null
+++ b/src/2.7.12/imports/utils/fetchStunTurnServers.js
@@ -0,0 +1,96 @@
+const MEDIA = Meteor.settings.public.media;
+const STUN_TURN_FETCH_URL = MEDIA.stunTurnServersFetchAddress;
+const CACHE_STUN_TURN = MEDIA.cacheStunTurnServers;
+const FALLBACK_STUN_SERVER = MEDIA.fallbackStunServer;
+
+let STUN_TURN_DICT;
+let MAPPED_STUN_TURN_DICT;
+let TURN_CACHE_VALID_UNTIL = Math.floor(Date.now() / 1000);
+let HAS_SEEN_TURN_SERVER = false;
+
+const fetchStunTurnServers = function (sessionToken) {
+ const now = Math.floor(Date.now() / 1000);
+ if (STUN_TURN_DICT && CACHE_STUN_TURN && now < TURN_CACHE_VALID_UNTIL) return Promise.resolve(STUN_TURN_DICT);
+
+ const handleStunTurnResponse = ({ stunServers, turnServers }) => {
+ if (!stunServers && !turnServers) {
+ return Promise.reject(new Error('Could not fetch STUN/TURN servers'));
+ }
+
+ const turnReply = [];
+ let max_ttl = null;
+ turnServers.forEach((turnEntry) => {
+ const { password, url, username } = turnEntry;
+ const valid_until = parseInt(username.split(':')[0]);
+ if (!max_ttl) {
+ max_ttl = valid_until;
+ } else if (valid_until < max_ttl) {
+ max_ttl = valid_until;
+ }
+ turnReply.push({
+ urls: url,
+ password,
+ username,
+ });
+ HAS_SEEN_TURN_SERVER = true;
+ });
+ TURN_CACHE_VALID_UNTIL = max_ttl;
+
+ const stDictionary = {
+ stun: stunServers.map(server => server.url),
+ turn: turnReply,
+ };
+
+ STUN_TURN_DICT = stDictionary;
+
+ return Promise.resolve(stDictionary);
+ };
+
+ const url = `${STUN_TURN_FETCH_URL}?sessionToken=${sessionToken}`;
+ return fetch(url, { credentials: 'include' })
+ .then(res => res.json())
+ .then(handleStunTurnResponse);
+};
+
+const mapStunTurn = ({ stun, turn }) => {
+ const rtcStuns = stun.map(url => ({ urls: url }));
+ const rtcTurns = turn.map(t => ({ urls: t.urls, credential: t.password, username: t.username }));
+ return rtcStuns.concat(rtcTurns);
+};
+
+const getFallbackStun = () => {
+ const stun = FALLBACK_STUN_SERVER ? [FALLBACK_STUN_SERVER] : [];
+ return { stun, turn: [] };
+};
+
+const getMappedFallbackStun = () => (FALLBACK_STUN_SERVER ? [{ urls: FALLBACK_STUN_SERVER }] : []);
+
+const fetchWebRTCMappedStunTurnServers = function (sessionToken) {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const now = Math.floor(Date.now() / 1000);
+ if (MAPPED_STUN_TURN_DICT && CACHE_STUN_TURN && now < TURN_CACHE_VALID_UNTIL) {
+ return resolve(MAPPED_STUN_TURN_DICT);
+ }
+
+ const stDictionary = await fetchStunTurnServers(sessionToken);
+
+ MAPPED_STUN_TURN_DICT = mapStunTurn(stDictionary);
+ return resolve(MAPPED_STUN_TURN_DICT);
+ } catch (error) {
+ return reject(error);
+ }
+ });
+};
+
+const hasTurnServer = () => {
+ return HAS_SEEN_TURN_SERVER;
+}
+
+export {
+ fetchStunTurnServers,
+ fetchWebRTCMappedStunTurnServers,
+ getFallbackStun,
+ getMappedFallbackStun,
+ hasTurnServer,
+};
diff --git a/src/2.7.12/imports/utils/hexInt.js b/src/2.7.12/imports/utils/hexInt.js
new file mode 100644
index 00000000..573b50be
--- /dev/null
+++ b/src/2.7.12/imports/utils/hexInt.js
@@ -0,0 +1,16 @@
+
+export function HEXToINTColor(hexColor) {
+ const _rrggbb = hexColor.slice(1);
+ const rrggbb = _rrggbb.substr(0, 2) + _rrggbb.substr(2, 2) + _rrggbb.substr(4, 2);
+ return parseInt(rrggbb, 16);
+}
+
+export function INTToHEXColor(intColor) {
+ let hex;
+ hex = parseInt(intColor, 10).toString(16);
+ while (hex.length < 6) {
+ hex = `0${hex}`;
+ }
+
+ return `#${hex}`;
+}
diff --git a/src/2.7.12/imports/utils/humanizeSeconds.js b/src/2.7.12/imports/utils/humanizeSeconds.js
new file mode 100644
index 00000000..d2ea887b
--- /dev/null
+++ b/src/2.7.12/imports/utils/humanizeSeconds.js
@@ -0,0 +1,16 @@
+const humanizeSeconds = (time) => {
+ const minutes = Math.floor(time / 60);
+ const seconds = time % 60;
+ return [
+ minutes,
+ seconds,
+ ].map((x) => {
+ if (x < 10) {
+ return `0${x}`;
+ }
+ return x;
+ },
+ ).join(':');
+};
+
+export default humanizeSeconds;
diff --git a/src/2.7.12/imports/utils/ios-webview-audio-polyfills.js b/src/2.7.12/imports/utils/ios-webview-audio-polyfills.js
new file mode 100644
index 00000000..f4f8cc0e
--- /dev/null
+++ b/src/2.7.12/imports/utils/ios-webview-audio-polyfills.js
@@ -0,0 +1,100 @@
+const iosWebviewAudioPolyfills = function () {
+ function shimRemoteStreamsAPI(window) {
+ if (!('getRemoteStreams' in window.RTCPeerConnection.prototype)) {
+ window.RTCPeerConnection.prototype.getRemoteStreams = function () {
+ return this._remoteStreams ? this._remoteStreams : [];
+ };
+ }
+ if (!('onaddstream' in window.RTCPeerConnection.prototype)) {
+ Object.defineProperty(window.RTCPeerConnection.prototype, 'onaddstream', {
+ get: function get() {
+ return this._onaddstream;
+ },
+ set: function set(f) {
+ const _this3 = this;
+
+ if (this._onaddstream) {
+ this.removeEventListener('addstream', this._onaddstream);
+ this.removeEventListener('track', this._onaddstreampoly);
+ }
+ this.addEventListener('addstream', this._onaddstream = f);
+ this.addEventListener('track', this._onaddstreampoly = function (e) {
+ e.streams.forEach((stream) => {
+ if (!_this3._remoteStreams) {
+ _this3._remoteStreams = [];
+ }
+ if (_this3._remoteStreams.includes(stream)) {
+ return;
+ }
+ _this3._remoteStreams.push(stream);
+ const event = new Event('addstream');
+ event.stream = stream;
+ _this3.dispatchEvent(event);
+ });
+ });
+ },
+ });
+ const origSetRemoteDescription = window.RTCPeerConnection.prototype.setRemoteDescription;
+ window.RTCPeerConnection.prototype.setRemoteDescription = function () {
+ const pc = this;
+ if (!this._onaddstreampoly) {
+ this.addEventListener('track', this._onaddstreampoly = function (e) {
+ e.streams.forEach((stream) => {
+ if (!pc._remoteStreams) {
+ pc._remoteStreams = [];
+ }
+ if (pc._remoteStreams.indexOf(stream) >= 0) {
+ return;
+ }
+ pc._remoteStreams.push(stream);
+ const event = new Event('addstream');
+ event.stream = stream;
+ pc.dispatchEvent(event);
+ });
+ });
+ }
+ return origSetRemoteDescription.apply(pc, arguments);
+ };
+ }
+ }
+
+ function shimCallbacksAPI(window) {
+ const prototype = window.RTCPeerConnection.prototype;
+ const createOffer = prototype.createOffer;
+ const setLocalDescription = prototype.setLocalDescription;
+ const setRemoteDescription = prototype.setRemoteDescription;
+
+ prototype.createOffer = function (successCallback, failureCallback) {
+ const options = arguments.length >= 2 ? arguments[2] : arguments[0];
+ const promise = createOffer.apply(this, [options]);
+ if (!failureCallback) {
+ return promise;
+ }
+ promise.then(successCallback, failureCallback);
+ return Promise.resolve();
+ };
+
+ prototype.setLocalDescription = function withCallback(description, successCallback, failureCallback) {
+ const promise = setLocalDescription.apply(this, [description]);
+ if (!failureCallback) {
+ return promise;
+ }
+ promise.then(successCallback, failureCallback);
+ return Promise.resolve();
+ };
+
+ prototype.setRemoteDescription = function withCallback(description, successCallback, failureCallback) {
+ const promise = setRemoteDescription.apply(this, [description]);
+ if (!failureCallback) {
+ return promise;
+ }
+ promise.then(successCallback, failureCallback);
+ return Promise.resolve();
+ };
+ }
+
+ shimCallbacksAPI(window);
+ shimRemoteStreamsAPI(window);
+};
+
+export default iosWebviewAudioPolyfills;
diff --git a/src/2.7.12/imports/utils/keyCodes.js b/src/2.7.12/imports/utils/keyCodes.js
new file mode 100644
index 00000000..b2682b2e
--- /dev/null
+++ b/src/2.7.12/imports/utils/keyCodes.js
@@ -0,0 +1,25 @@
+export const SPACE = 32;
+export const ENTER = 13;
+export const TAB = 9;
+export const ESCAPE = 27;
+export const ARROW_UP = 38;
+export const ARROW_DOWN = 40;
+export const ARROW_RIGHT = 39;
+export const ARROW_LEFT = 37;
+export const PAGE_UP = 33;
+export const PAGE_DOWN = 34;
+export const A = 65;
+
+export default {
+ SPACE,
+ ENTER,
+ TAB,
+ ESCAPE,
+ ARROW_UP,
+ ARROW_DOWN,
+ ARROW_RIGHT,
+ ARROW_LEFT,
+ PAGE_UP,
+ PAGE_DOWN,
+ A,
+};
diff --git a/src/2.7.12/imports/utils/lineEndings.js b/src/2.7.12/imports/utils/lineEndings.js
new file mode 100644
index 00000000..0aeea6eb
--- /dev/null
+++ b/src/2.7.12/imports/utils/lineEndings.js
@@ -0,0 +1,11 @@
+// Used in Flash and HTML to show a legitimate break in the line
+const BREAK_LINE = ' ';
+
+// Soft return in HTML to signify a broken line without
+// displaying the escaped ' ' line break text
+const CARRIAGE_RETURN = '\r';
+
+// Handle this the same as carriage return, in case text copied has this
+const NEW_LINE = '\n';
+
+export { BREAK_LINE, CARRIAGE_RETURN, NEW_LINE };
diff --git a/src/2.7.12/imports/utils/logoutRouteHandler.js b/src/2.7.12/imports/utils/logoutRouteHandler.js
new file mode 100644
index 00000000..b8ae3504
--- /dev/null
+++ b/src/2.7.12/imports/utils/logoutRouteHandler.js
@@ -0,0 +1,13 @@
+import Auth from '/imports/ui/services/auth';
+
+const logoutRouteHandler = () => {
+ Auth.logout()
+ .then((logoutURL) => {
+ if (logoutURL) {
+ const protocolPattern = /^((http|https):\/\/)/;
+ window.location.href = protocolPattern.test(logoutURL) ? logoutURL : `http://${logoutURL}`;
+ }
+ });
+};
+
+export default logoutRouteHandler;
diff --git a/src/2.7.12/imports/utils/media-stream-utils.js b/src/2.7.12/imports/utils/media-stream-utils.js
new file mode 100644
index 00000000..515f5ffc
--- /dev/null
+++ b/src/2.7.12/imports/utils/media-stream-utils.js
@@ -0,0 +1,72 @@
+const stopMediaStreamTracks = (stream) => {
+ if (stream && typeof stream.getTracks === 'function') {
+ stream.getTracks().forEach((track) => {
+ if (typeof track.stop === 'function' && track.readyState !== 'ended') {
+ track.stop();
+ // Manually emit the event as a safeguard; Firefox doesn't fire it when it
+ // should with live MediaStreamTracks...
+ const trackStoppedEvt = new MediaStreamTrackEvent('ended', { track });
+ track.dispatchEvent(trackStoppedEvt);
+ }
+ });
+ }
+};
+
+const getAudioTracks = (stream) => {
+ if (stream) {
+ if (typeof stream.getAudioTracks === 'function') {
+ return stream.getAudioTracks();
+ }
+
+ if (typeof stream.getTracks === 'function') {
+ return stream.getTracks().filter((track) => track.kind === 'audio');
+ }
+ }
+
+ return [];
+};
+
+const getVideoTracks = (stream) => {
+ if (stream) {
+ if (typeof stream.getVideoTracks === 'function') {
+ return stream.getVideoTracks();
+ }
+
+ if (typeof stream.getTracks === 'function') {
+ return stream.getTracks().filter((track) => track.kind === 'video');
+ }
+ }
+
+ return [];
+};
+
+const getDeviceIdFromTrack = (track) => {
+ if (track && typeof track.getSettings === 'function') {
+ const { deviceId } = track.getSettings();
+ return deviceId;
+ }
+ return '';
+};
+
+const extractDeviceIdFromStream = (stream, kind) => {
+ // An empty string is the browser's default...
+ let tracks = [];
+
+ switch (kind) {
+ case 'audio':
+ tracks = getAudioTracks(stream);
+ return getDeviceIdFromTrack(tracks[0]);
+ case 'video':
+ tracks = getVideoTracks(stream);
+ return getDeviceIdFromTrack(tracks[0]);
+ default: {
+ return '';
+ }
+ }
+};
+
+export default {
+ stopMediaStreamTracks,
+ getVideoTracks,
+ extractDeviceIdFromStream,
+};
diff --git a/src/2.7.12/imports/utils/mediaElementPlayRetry.js b/src/2.7.12/imports/utils/mediaElementPlayRetry.js
new file mode 100644
index 00000000..fc8e1ec7
--- /dev/null
+++ b/src/2.7.12/imports/utils/mediaElementPlayRetry.js
@@ -0,0 +1,27 @@
+const DEFAULT_MAX_RETRIES = 10;
+const DEFAULT_RETRY_TIMEOUT = 500;
+
+const playAndRetry = async (mediaElement, maxRetries = DEFAULT_MAX_RETRIES) => {
+ let attempt = 0;
+ let played = false;
+
+ const playElement = () => new Promise((resolve, reject) => {
+ setTimeout(() => {
+ mediaElement.play().then(resolve).catch(reject);
+ }, DEFAULT_RETRY_TIMEOUT);
+ });
+
+ while (!played && attempt < maxRetries && mediaElement.paused) {
+ try {
+ await playElement();
+ played = true;
+ return played;
+ } catch (error) {
+ attempt += 1;
+ }
+ }
+
+ return played || mediaElement.paused;
+};
+
+export default playAndRetry;
diff --git a/src/2.7.12/imports/utils/mimeTypes.js b/src/2.7.12/imports/utils/mimeTypes.js
new file mode 100644
index 00000000..9bdf3cbc
--- /dev/null
+++ b/src/2.7.12/imports/utils/mimeTypes.js
@@ -0,0 +1,33 @@
+export const XLS = 'application/vnd.ms-excel';
+export const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+export const DOC = 'application/msword';
+export const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+export const PPT = 'application/vnd.ms-powerpoint';
+export const PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
+export const ODT = 'application/vnd.oasis.opendocument.text';
+export const RTF = 'application/rtf';
+export const TXT = 'text/plain';
+export const ODS = 'application/vnd.oasis.opendocument.spreadsheet';
+export const ODP = 'application/vnd.oasis.opendocument.presentation';
+export const PDF = 'application/pdf';
+export const JPEG = 'image/jpeg';
+export const PNG = 'image/png';
+export const SVG = 'image/svg+xml';
+export const WEBP = 'image/webp';
+
+export const UPLOAD_SUPORTED = [
+ XLS,
+ XLSX,
+ DOC,
+ DOCX,
+ PPT,
+ PPTX,
+ ODT,
+ RTF,
+ TXT,
+ ODS,
+ ODP,
+ PDF,
+ JPEG,
+ PNG,
+];
diff --git a/src/2.7.12/imports/utils/regex-weburl.js b/src/2.7.12/imports/utils/regex-weburl.js
new file mode 100644
index 00000000..575f0134
--- /dev/null
+++ b/src/2.7.12/imports/utils/regex-weburl.js
@@ -0,0 +1,120 @@
+//
+// Regular Expression for URL validation
+//
+// Author: Diego Perini
+// Updated: 2010/12/05
+// License: MIT
+//
+// Copyright (c) 2010-2013 Diego Perini (http://www.iport.it)
+//
+// Permission is hereby granted, free of charge, to any person
+// obtaining a copy of this software and associated documentation
+// files (the "Software"), to deal in the Software without
+// restriction, including without limitation the rights to use,
+// copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the
+// Software is furnished to do so, subject to the following
+// conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+// OTHER DEALINGS IN THE SOFTWARE.
+//
+// the regular expression composed & commented
+// could be easily tweaked for RFC compliance,
+// it was expressly modified to fit & satisfy
+// these test for an URL shortener:
+//
+// http://mathiasbynens.be/demo/url-regex
+//
+// Notes on possible differences from a standard/generic validation:
+//
+// - utf-8 char class take in consideration the full Unicode range
+// - TLDs have been made mandatory so single names like "localhost" fails
+// - protocols have been restricted to ftp, http and https only as requested
+//
+// Changes:
+//
+// - IP address dotted notation validation, range: 1.0.0.0 - 223.255.255.255
+// first and last IP address of each class is considered invalid
+// (since they are broadcast/network addresses)
+//
+// - Added exclusion of private, reserved and/or local networks ranges
+//
+// - Made starting path slash optional (http://example.com?foo=bar)
+//
+// - Allow a dot (.) at the end of hostnames (http://example.com.)
+//
+// Compressed one-line versions:
+//
+// Javascript version (merge into one line)
+//
+// /^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})
+// (?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})
+// (?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}
+// (?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)
+// (?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)
+// (?::\d{2,5})?(?:[/?#]\S*)?$/i
+//
+// PHP version (merge into one line)
+//
+// _^(?:(?:https?|ftp)://)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)
+// (?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])
+// (?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5]))
+// {2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]-*)
+// *[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]-*)*[a-z\x{00a1}-\x{ffff}0-9]+)
+// *(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,}))\.?)(?::\d{2,5})?(?:[/?#]\S*)?$_iuS
+//
+
+export default new RegExp(
+
+ // protocol identifier
+ '(?:(?:https?|ftp)://)' +
+
+ // user:pass authentication
+ '(?:\\S+(?::\\S*)?@)?' +
+ '(?:' +
+
+ // IP address exclusion
+ // private & local networks
+ '(?!(?:10|127)(?:\\.\\d{1,3}){3})' +
+ '(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' +
+ '(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' +
+
+ // IP address dotted notation octets
+ // excludes loopback network 0.0.0.0
+ // excludes reserved space >= 224.0.0.0
+ // excludes network & broacast addresses
+ // (first & last IP address of each class)
+ '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
+ '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
+ '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
+ '|' +
+
+ // host name
+ '(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' +
+
+ // domain name
+ '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' +
+
+ // TLD identifier
+ '(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' +
+
+ // TLD may end with dot
+ '\\.?' +
+ ')' +
+
+ // port number
+ '(?::\\d{2,5})?' +
+
+ // resource path
+ '(?:[/?#]\\S*)?', 'img',
+);
diff --git a/src/2.7.12/imports/utils/sdpUtils.js b/src/2.7.12/imports/utils/sdpUtils.js
new file mode 100644
index 00000000..dd15a876
--- /dev/null
+++ b/src/2.7.12/imports/utils/sdpUtils.js
@@ -0,0 +1,351 @@
+import Interop from '@jitsi/sdp-interop';
+import transform from 'sdp-transform';
+import logger from '/imports/startup/client/logger';
+
+// sdp-interop library for unified-plan <-> plan-b translation
+const interop = new Interop.Interop();
+
+// Some heuristics to determine if the input SDP is Unified Plan
+const isUnifiedPlan = (sdp) => {
+ const parsedSDP = transform.parse(sdp);
+ if (parsedSDP.media.length <= 3 && parsedSDP.media.every(m => ['video', 'audio', 'data'].indexOf(m.mid) !== -1)) {
+ logger.info({ logCode: 'sdp_utils_not_unified_plan' }, 'SDP does not look like Unified Plan');
+ return false;
+ }
+
+ logger.info({ logCode: 'sdp_utils_is_unified_plan' }, 'SDP looks like Unified Plan');
+
+ return true;
+};
+
+// Some heuristics to determine if the input SDP is Plan B
+const isPlanB = (sdp) => {
+ const parsedSDP = transform.parse(sdp);
+ if (parsedSDP.media.length > 3 || !parsedSDP.media.every(m => ['video', 'audio', 'data'].indexOf(m.mid) !== -1)) {
+ logger.info({ logCode: 'sdp_utils_not_plan_b' }, 'SDP does not look like Plan B');
+ return false;
+ }
+
+ logger.info({ logCode: 'sdp_utils_is_plan_b' }, 'SDP looks like Plan B');
+
+ return true;
+};
+
+
+// Specific method for translating FS SDPs from Plan B to Unified Plan (vice-versa)
+const toPlanB = (unifiedPlanSDP) => {
+ const planBSDP = interop.toPlanB(unifiedPlanSDP);
+ logger.info({ logCode: 'sdp_utils_unified_plan_to_plan_b' }, `Converted Unified Plan to Plan B ${JSON.stringify(planBSDP)}`);
+ return planBSDP;
+};
+
+const toUnifiedPlan = (planBSDP) => {
+ const unifiedPlanSDP = interop.toUnifiedPlan(planBSDP);
+ logger.info({ logCode: 'sdp_utils_plan_b_to_unified_plan' }, `Converted Plan B to Unified Plan ${JSON.stringify(unifiedPlanSDP)}`);
+ return unifiedPlanSDP;
+};
+
+const stripMDnsCandidates = (sdp) => {
+ const parsedSDP = transform.parse(sdp.sdp);
+ let strippedCandidates = 0;
+ parsedSDP.media.forEach((media) => {
+ if (media.candidates) {
+ media.candidates = media.candidates.filter((candidate) => {
+ if (candidate.ip && candidate.ip.indexOf('.local') === -1) {
+ return true;
+ }
+ strippedCandidates += 1;
+ return false;
+ });
+ }
+ });
+ if (strippedCandidates > 0) {
+ logger.info({ logCode: 'sdp_utils_mdns_candidate_strip' }, `Stripped ${strippedCandidates} mDNS candidates`);
+ }
+ return { sdp: transform.write(parsedSDP), type: sdp.type };
+};
+
+const filterValidIceCandidates = (validIceCandidates, sdp) => {
+ if (!validIceCandidates || !validIceCandidates.length) return sdp;
+
+ const matchCandidatesIp = (candidate, mediaCandidate) => (
+ (candidate.address && candidate.address.includes(mediaCandidate.ip))
+ || (candidate.relatedAddress
+ && candidate.relatedAddress.includes(mediaCandidate.ip))
+ );
+
+ const parsedSDP = transform.parse(sdp.sdp);
+ let strippedCandidates = 0;
+ parsedSDP.media.forEach((media) => {
+ if (media.candidates) {
+ media.candidates = media.candidates.filter((candidate) => {
+ if (candidate.ip
+ && candidate.type
+ && candidate.transport
+ && validIceCandidates.find((c) => (c.protocol === candidate.transport)
+ && matchCandidatesIp(c, candidate))
+ ) {
+ return true;
+ }
+ strippedCandidates += 1;
+ return false;
+ });
+ }
+ });
+ if (strippedCandidates > 0) {
+ logger.info({ logCode: 'sdp_utils_mdns_candidate_strip' },
+ `Filtered ${strippedCandidates} invalid candidates from trickle SDP`);
+ }
+ return { sdp: transform.write(parsedSDP), type: sdp.type };
+};
+
+const isPublicIpv4 = (ip) => {
+ const ipParts = ip.split('.');
+ switch (ipParts[0]) {
+ case 10:
+ case 127:
+ return false;
+ case 172:
+ return ipParts[1] <= 16 || ipParts[1] > 32;
+ case 192:
+ return ipParts[1] !== 168;
+ default:
+ return true;
+ }
+};
+
+const parseIP = (ip) => {
+ if (ip && typeof ip === 'string') {
+ if (ip.indexOf(':') !== -1) return { type: 'v6', public: true };
+ if (ip.indexOf('.local') !== -1) return { type: 'mdns', public: false };
+ if (ip.indexOf('.')) return { type: 'v4', public: isPublicIpv4(ip) };
+ }
+ return { type: 'unknown', public: false };
+};
+
+const analyzeSdp = (sdp, sendLogs = true) => {
+ // For now we just need to parse and log the different pieces. In the future we're going to want
+ // to be tracking whether there were TURN candidates and IPv4 candidates to make informed
+ // decisions about what to do on fallbacks/reconnects.
+ const parsedSDP = transform.parse(sdp);
+
+ const v4Info = {
+ found: false,
+ public: false,
+ };
+
+ const v6Info = {
+ found: false,
+ public: false,
+ };
+
+ const srflxInfo = {
+ found: false,
+ type: 'not found',
+ public: false,
+ };
+
+ const prflxInfo = {
+ found: false,
+ type: 'not found',
+ public: false,
+ };
+
+ const relayInfo = {
+ found: false,
+ type: 'not found',
+ public: false,
+ };
+
+ // Things to parse:
+ // Are there any IPv4/IPv6
+ // Is there a server reflexive candidate? (srflx) is a public or private IP
+ // Is there a relay (TURN) candidate
+ parsedSDP.media.forEach((media) => {
+ if (media.candidates) {
+ // console.log("**** Found candidates ****")
+ media.candidates.forEach((candidate) => {
+ // console.log(candidate)
+ const ipInfo = parseIP(candidate.ip);
+ switch (ipInfo.type) {
+ case 'v4':
+ v4Info.found = true;
+ v4Info.public = v4Info.public || ipInfo.public;
+ break;
+ case 'v6':
+ v6Info.found = true;
+ v6Info.public = v6Info.public || ipInfo.public;
+ break;
+ }
+
+ switch (candidate.type) {
+ case 'srflx':
+ srflxInfo.found = true;
+
+ if (srflxInfo.type === 'not found') {
+ srflxInfo.type = ipInfo.type;
+ } else if (srflxInfo.type !== ipInfo.type) {
+ srflxInfo.type = 'both';
+ }
+
+ srflxInfo.public = srflxInfo.public || ipInfo.public;
+ break;
+ case 'prflx':
+ prflxInfo.found = true;
+
+ if (prflxInfo.type === 'not found') {
+ prflxInfo.type = ipInfo.type;
+ } else if (prflxInfo.type !== ipInfo.type) {
+ prflxInfo.type = 'both';
+ }
+
+ prflxInfo.public = prflxInfo.public || ipInfo.public;
+ break;
+ case 'relay':
+ relayInfo.found = true;
+
+ if (relayInfo.type === 'not found') {
+ relayInfo.type = ipInfo.type;
+ } else if (relayInfo.type !== ipInfo.type) {
+ relayInfo.type = 'both';
+ }
+
+ relayInfo.public = relayInfo.public || ipInfo.public;
+ break;
+ }
+ });
+ // console.log("**** End of candidates ****")
+ }
+ });
+
+ // candidate types
+ if (sendLogs) {
+ logger.info({
+ logCode: 'sdp_utils_candidate_types',
+ extraInfo: {
+ foundV4Candidate: v4Info.found,
+ foundV4PublicCandidate: v4Info.public,
+ foundV6Candidate: v6Info.found,
+ },
+ }, `Found candidates ${v4Info.found ? 'with' : 'without'} type v4 (public? ${v4Info.public}) and ${v6Info.found ? 'with' : 'without'} type v6`);
+
+ // server reflexive
+ if (srflxInfo.found) {
+ logger.info({
+ logCode: 'sdp_utils_server_reflexive_found',
+ extraInfo: {
+ candidateType: srflxInfo.type,
+ candidatePublic: srflxInfo.public,
+ },
+ }, 'Found a server reflexive candidate');
+ } else {
+ logger.info({
+ logCode: 'sdp_utils_no_server_reflexive',
+ }, 'No server reflexive candidate found');
+ }
+
+ // peer reflexive
+ if (prflxInfo.found) {
+ logger.info({
+ logCode: 'sdp_utils_peer_reflexive_found',
+ extraInfo: {
+ candidateType: prflxInfo.type,
+ candidatePublic: prflxInfo.public,
+ },
+ }, 'Found a peer reflexive candidate');
+ } else {
+ logger.info({
+ logCode: 'sdp_utils_no_peer_reflexive',
+ }, 'No peer reflexive candidate found');
+ }
+
+ // relay
+ if (relayInfo.found) {
+ logger.info({
+ logCode: 'sdp_utils_relay_found',
+ extraInfo: {
+ candidateType: relayInfo.type,
+ candidatePublic: relayInfo.public,
+ },
+ }, 'Found a relay candidate');
+ } else {
+ logger.info({
+ logCode: 'sdp_utils_no_relay',
+ }, 'No relay candidate found');
+ }
+ }
+ return {
+ v4Info,
+ v6Info,
+ srflxInfo,
+ prflxInfo,
+ relayInfo,
+ };
+};
+
+// We grab the protocol type from the answer SDP because Safari stats don't contain the
+// candidate IP addresses
+const logSelectedCandidate = async (peer, isIpv6) => {
+ peer.getStats().then((report) => {
+ let localCandidate;
+
+ const values = Array.from(report.values());
+ const candidatePair = values.find(item => item.type === 'candidate-pair' && (item.selected || item.state === 'succeeded'));
+ if (candidatePair) {
+ localCandidate = values.find(item => item.id === candidatePair.localCandidateId);
+ }
+
+ const ipType = isIpv6 ? 'v6' : 'v4';
+ if (candidatePair) {
+ if (localCandidate) {
+ // console.log(localCandidate);
+ // Safari doesn't include the IP address in the candidate info so we can't rely on this
+ // const candidateIp = localCandidate.ip || localCandidate.address;
+ // const ipInfo = parseIP(candidateIp);
+
+ logger.info({
+ logCode: 'sip_js_candidate_selected',
+ extraInfo: {
+ candidateType: localCandidate.candidateType,
+ candidateProtocol: localCandidate.protocol,
+ ipType,
+ networkType: localCandidate.networkType,
+ },
+ }, `ICE Candidate selected - type: ${localCandidate.candidateType}, protocol: ${localCandidate.protocol}, ipProtocol: ${ipType}`);
+ } else {
+ logger.info({
+ logCode: 'sip_js_lcandidate_not_found',
+ extraInfo: {
+ ipType,
+ },
+ }, `ICE Candidate selected, but could not find local candidate - ipProtocol: ${ipType}`);
+ }
+ } else {
+ logger.info({
+ logCode: 'sip_js_candidate_pair_not_found',
+ extraInfo: {
+ ipType,
+ },
+ }, `ICE Candidate selected, but could not find pair - ipProtocol: ${ipType}`);
+ }
+
+ // TODO return the data back
+ });
+};
+
+const forceDisableStereo = ({ sdp, type }) => ({
+ sdp: sdp.replace(/stereo=1/ig, 'stereo=0'),
+ type,
+});
+
+export {
+ interop,
+ isUnifiedPlan,
+ toPlanB,
+ toUnifiedPlan,
+ stripMDnsCandidates,
+ filterValidIceCandidates,
+ analyzeSdp,
+ logSelectedCandidate,
+ forceDisableStereo,
+};
diff --git a/src/2.7.12/imports/utils/slideCalcUtils.js b/src/2.7.12/imports/utils/slideCalcUtils.js
new file mode 100644
index 00000000..354c6169
--- /dev/null
+++ b/src/2.7.12/imports/utils/slideCalcUtils.js
@@ -0,0 +1,73 @@
+export const HUNDRED_PERCENT = 100;
+export const MAX_PERCENT = 400;
+export const MYSTERY_NUM = 2;
+export const STEP = 25;
+
+export default class SlideCalcUtil {
+ // After lots of trial and error on why synching doesn't work properly, I found I had to
+ // multiply the coordinates by 2. There's something I don't understand probably on the
+ // canvas coordinate system. (ralam feb 22, 2012)
+
+ /**
+ * Calculate the viewed region width
+ */
+ static calcViewedRegionWidth(vpw, cpw) {
+ const width = (vpw / cpw) * HUNDRED_PERCENT;
+ if (width > HUNDRED_PERCENT) {
+ return HUNDRED_PERCENT;
+ }
+ return width;
+ }
+
+ static calcViewedRegionHeight(vph, cph) {
+ const height = (vph / cph) * HUNDRED_PERCENT;
+ if (height > HUNDRED_PERCENT) {
+ return HUNDRED_PERCENT;
+ }
+ return height;
+ }
+
+ static calcCalcPageSizeWidth(ftp, vpw, vrw) {
+ if (ftp) {
+ return (vpw / vrw) * HUNDRED_PERCENT;
+ }
+ return vpw;
+ }
+
+ static calcCalcPageSizeHeight(ftp, vph, vrh, cpw, cph, opw, oph) {
+ if (ftp) {
+ return (vph / vrh) * HUNDRED_PERCENT;
+ }
+ return (cpw / opw) * oph;
+ }
+
+ static calcViewedRegionX(cpx, cpw) {
+ const viewX = -cpx / 2 / cpw * 100;
+ if (viewX > 0) {
+ return 0;
+ }
+ return viewX;
+ }
+
+ static calcViewedRegionY(cpy, cph) {
+ const viewY = -cpy / 2 / cph * 100;
+ if (viewY > 0) {
+ return 0;
+ }
+ return viewY;
+ }
+
+ static calculateViewportX(vpw, pw) {
+ if (vpw === pw) {
+ return 0;
+ }
+ return (pw - vpw) / MYSTERY_NUM;
+ }
+
+ static calculateViewportY(vph, ph) {
+ if (vph === ph) {
+ return 0;
+ }
+ return (ph - vph) / MYSTERY_NUM;
+ }
+}
diff --git a/src/2.7.12/imports/utils/stats.js b/src/2.7.12/imports/utils/stats.js
new file mode 100644
index 00000000..54d2d900
--- /dev/null
+++ b/src/2.7.12/imports/utils/stats.js
@@ -0,0 +1,195 @@
+import logger from '/imports/startup/client/logger';
+
+const STATS = Meteor.settings.public.stats;
+
+// Probes done in an interval
+const PROBES = 5;
+const INTERVAL = STATS.interval / PROBES;
+
+const stop = callback => {
+ logger.debug(
+ { logCode: 'stats_stop_monitor' },
+ 'Lost peer connection. Stopping monitor'
+ );
+ callback(clearResult());
+ return;
+};
+
+const isActive = conn => {
+ let active = false;
+
+ if (conn) {
+ const { connectionState } = conn;
+ const logCode = 'stats_connection_state';
+
+ switch (connectionState) {
+ case 'new':
+ case 'connecting':
+ case 'connected':
+ case 'disconnected':
+ active = true;
+ break;
+ case 'failed':
+ case 'closed':
+ default:
+ logger.warn({ logCode }, connectionState);
+ }
+ } else {
+ logger.error(
+ { logCode: 'stats_missing_connection' },
+ 'Missing connection'
+ );
+ }
+
+ return active;
+};
+
+const collect = (conn, callback) => {
+ let stats = [];
+
+ const monitor = (conn, stats) => {
+ if (!isActive(conn)) return stop(callback);
+
+ conn.getStats().then(results => {
+ if (!results) return stop(callback);
+
+ let inboundRTP;
+ let remoteInboundRTP;
+
+ results.forEach(res => {
+ switch (res.type) {
+ case 'inbound-rtp':
+ inboundRTP = res;
+ break;
+ case 'remote-inbound-rtp':
+ remoteInboundRTP = res;
+ break;
+ default:
+ }
+ });
+
+ if (inboundRTP || remoteInboundRTP) {
+ if (!inboundRTP) {
+ logger.debug(
+ { logCode: 'stats_missing_inbound_rtc' },
+ 'Missing local inbound RTC. Using remote instead'
+ );
+ }
+
+ stats.push(buildData(inboundRTP || remoteInboundRTP));
+ while (stats.length > PROBES) stats.shift();
+
+ const interval = calculateInterval(stats);
+ callback(buildResult(interval));
+ }
+
+ setTimeout(monitor, INTERVAL, conn, stats);
+ }).catch(error => {
+ logger.debug(
+ {
+ logCode: 'stats_get_stats_error',
+ extraInfo: { error }
+ },
+ 'WebRTC stats not available'
+ );
+ });
+ };
+ monitor(conn, stats);
+};
+
+const buildData = inboundRTP => {
+ return {
+ packets: {
+ received: inboundRTP.packetsReceived,
+ lost: inboundRTP.packetsLost
+ },
+ bytes: {
+ received: inboundRTP.bytesReceived
+ },
+ jitter: inboundRTP.jitter
+ };
+};
+
+const buildResult = (interval) => {
+ const rate = calculateRate(interval.packets);
+ return {
+ packets: {
+ received: interval.packets.received,
+ lost: interval.packets.lost
+ },
+ bytes: {
+ received: interval.bytes.received
+ },
+ jitter: interval.jitter,
+ rate: rate,
+ loss: calculateLoss(rate),
+ MOS: calculateMOS(rate)
+ };
+};
+
+const clearResult = () => {
+ return {
+ packets: {
+ received: 0,
+ lost: 0
+ },
+ bytes: {
+ received: 0
+ },
+ jitter: 0,
+ rate: 0,
+ loss: 0,
+ MOS: 0
+ };
+};
+
+const diff = (single, first, last) => Math.abs((single ? 0 : last) - first);
+
+const calculateInterval = (stats) => {
+ const single = stats.length === 1;
+ const first = stats[0];
+ const last = stats[stats.length - 1];
+ return {
+ packets: {
+ received: diff(single, first.packets.received, last.packets.received),
+ lost: diff(single, first.packets.lost, last.packets.lost)
+ },
+ bytes: {
+ received: diff(single, first.bytes.received, last.bytes.received)
+ },
+ jitter: Math.max.apply(Math, stats.map(s => s.jitter))
+ };
+};
+
+const calculateRate = (packets) => {
+ const { received, lost } = packets;
+ const rate = (received > 0) ? ((received - lost) / received) * 100 : 100;
+ if (rate < 0 || rate > 100) return 100;
+ return rate;
+};
+
+const calculateLoss = (rate) => {
+ return 1 - (rate / 100);
+};
+
+const calculateMOS = (rate) => {
+ return 1 + (0.035) * rate + (0.000007) * rate * (rate - 60) * (100 - rate);
+};
+
+const monitorAudioConnection = conn => {
+ if (!conn) return;
+
+ logger.debug(
+ { logCode: 'stats_audio_monitor' },
+ 'Starting to monitor audio connection'
+ );
+
+ collect(conn, (result) => {
+ const event = new CustomEvent('audiostats', { detail: result });
+ window.dispatchEvent(event);
+ });
+};
+
+export {
+ monitorAudioConnection,
+};
diff --git a/src/2.7.12/imports/utils/statuses.js b/src/2.7.12/imports/utils/statuses.js
new file mode 100644
index 00000000..848c521c
--- /dev/null
+++ b/src/2.7.12/imports/utils/statuses.js
@@ -0,0 +1,14 @@
+export const EMOJI_STATUSES = {
+ // name: icon
+ away: 'time',
+ raiseHand: 'hand',
+ neutral: 'undecided',
+ confused: 'confused',
+ sad: 'sad',
+ happy: 'happy',
+ applause: 'applause',
+ thumbsUp: 'thumbs_up',
+ thumbsDown: 'thumbs_down',
+};
+
+export default { EMOJI_STATUSES };
diff --git a/src/2.7.12/imports/utils/string-utils.js b/src/2.7.12/imports/utils/string-utils.js
new file mode 100644
index 00000000..8361b28f
--- /dev/null
+++ b/src/2.7.12/imports/utils/string-utils.js
@@ -0,0 +1,78 @@
+import sanitize from 'sanitize-html';
+
+export const capitalizeFirstLetter = (s = '') => s.charAt(0).toUpperCase() + s.slice(1);
+
+/**
+ * Returns a string in the format 'Year-Month-Day_Hour-Minutes'.
+ * @param {Date} [date] - The Date object.
+ */
+export const getDateString = (date = new Date()) => {
+ const hours = date.getHours().toString().padStart(2, 0);
+ const minutes = date.getMinutes().toString().padStart(2, 0);
+ const month = (date.getMonth() + 1).toString().padStart(2, 0);
+ const dayOfMonth = date.getDate().toString().padStart(2, 0);
+ const time = `${hours}-${minutes}`;
+ const dateString = `${date.getFullYear()}-${month}-${dayOfMonth}_${time}`;
+ return dateString;
+};
+
+export const stripTags = (text) => sanitize(text, { allowedTags: [] });
+
+// Sanitize. See: https://gist.github.com/sagewall/47164de600df05fb0f6f44d48a09c0bd
+export const escapeHtml = (text) => {
+ const div = document.createElement('div');
+ div.appendChild(document.createTextNode(text));
+ return div.innerHTML;
+};
+
+export const unescapeHtml = (input) => {
+ const e = document.createElement('textarea');
+ e.innerHTML = input;
+ return e.value;
+};
+
+export const formatLocaleCode = (locale) => {
+ const formattedLocale = locale?.replace('_', '-').replace('@', '-');
+
+ return {
+ language: formattedLocale?.split('-')[0],
+ formattedLocale,
+ };
+};
+
+export const safeMatch = (regex, content, defaultValue) => {
+ const regexLimit = 50000;
+
+ if (content.length > regexLimit) return defaultValue;
+ return content.match(regex) || defaultValue;
+};
+
+export const lowercaseTrim = (text) => {
+ return text.trim().toLowerCase();
+}
+
+export const upperFirst = (string) => {
+ return string ? string.charAt(0).toUpperCase() + string.slice(1) : '';
+}
+
+export const uniqueId = (() => {
+ let num = 0;
+ return function (prefix) {
+ prefix = String(prefix) || '';
+ num += 1;
+ return prefix + num;
+ }
+})();
+
+export default {
+ capitalizeFirstLetter,
+ getDateString,
+ stripTags,
+ escapeHtml,
+ unescapeHtml,
+ formatLocaleCode,
+ safeMatch,
+ lowercaseTrim,
+ upperFirst,
+ uniqueId,
+};
diff --git a/src/2.7.12/imports/utils/throttle.js b/src/2.7.12/imports/utils/throttle.js
new file mode 100644
index 00000000..0ec52ed0
--- /dev/null
+++ b/src/2.7.12/imports/utils/throttle.js
@@ -0,0 +1,45 @@
+export function throttle(func, delay, options = {}) {
+ let timeoutId;
+ let lastExecTime = 0;
+ let leadingExec = true;
+
+ const { leading = true, trailing = true } = options;
+
+ let cancelPendingExecution = false; // Flag to track cancellation
+
+ const throttledFunction = function () {
+ const context = this;
+ const args = arguments;
+ const elapsed = Date.now() - lastExecTime;
+
+ function execute() {
+ if (!cancelPendingExecution) { // Only execute if not cancelled
+ func.apply(context, args);
+ lastExecTime = Date.now();
+ }
+ }
+
+ if (leadingExec && leading) {
+ execute();
+ leadingExec = false;
+ const nextExecDelay = elapsed < delay ? delay - elapsed : delay;
+ setTimeout(function () {
+ leadingExec = true;
+ }, nextExecDelay);
+ } else if (!timeoutId && trailing) {
+ timeoutId = setTimeout(function () {
+ execute();
+ timeoutId = null;
+ }, delay - elapsed);
+ }
+ };
+
+ // Add a cancel method to the throttled function
+ throttledFunction.cancel = function () {
+ cancelPendingExecution = true;
+ clearTimeout(timeoutId);
+ timeoutId = null;
+ };
+
+ return throttledFunction;
+}
diff --git a/src/2.7.12/jsconfig.json b/src/2.7.12/jsconfig.json
new file mode 100644
index 00000000..38b91063
--- /dev/null
+++ b/src/2.7.12/jsconfig.json
@@ -0,0 +1,6 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": { "/*": ["*"] }
+ }
+}
\ No newline at end of file
diff --git a/src/2.7.12/package-lock.json b/src/2.7.12/package-lock.json
new file mode 100644
index 00000000..ba775acb
--- /dev/null
+++ b/src/2.7.12/package-lock.json
@@ -0,0 +1,7447 @@
+{
+ "name": "bbb-html5-client",
+ "requires": true,
+ "lockfileVersion": 1,
+ "dependencies": {
+ "@babel/code-frame": {
+ "version": "7.12.11",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
+ "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
+ "requires": {
+ "@babel/highlight": "^7.10.4"
+ }
+ },
+ "@babel/generator": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz",
+ "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==",
+ "requires": {
+ "@babel/types": "^7.23.0",
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "@jridgewell/trace-mapping": "^0.3.17",
+ "jsesc": "^2.5.1"
+ },
+ "dependencies": {
+ "@babel/helper-string-parser": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
+ },
+ "@babel/types": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+ "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.22.5",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ }
+ }
+ }
+ },
+ "@babel/helper-annotate-as-pure": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz",
+ "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==",
+ "requires": {
+ "@babel/types": "^7.18.6"
+ }
+ },
+ "@babel/helper-environment-visitor": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz",
+ "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA=="
+ },
+ "@babel/helper-function-name": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz",
+ "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==",
+ "requires": {
+ "@babel/template": "^7.22.15",
+ "@babel/types": "^7.23.0"
+ },
+ "dependencies": {
+ "@babel/helper-string-parser": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
+ },
+ "@babel/types": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+ "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.22.5",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ }
+ }
+ }
+ },
+ "@babel/helper-hoist-variables": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz",
+ "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==",
+ "requires": {
+ "@babel/types": "^7.22.5"
+ },
+ "dependencies": {
+ "@babel/helper-string-parser": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
+ },
+ "@babel/types": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+ "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.22.5",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ }
+ }
+ }
+ },
+ "@babel/helper-module-imports": {
+ "version": "7.21.4",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.21.4.tgz",
+ "integrity": "sha512-orajc5T2PsRYUN3ZryCEFeMDYwyw09c/pZeaQEZPH0MpKzSvn3e0uXsDBu3k03VI+9DBiRo+l22BfKTpKwa/Wg==",
+ "requires": {
+ "@babel/types": "^7.21.4"
+ }
+ },
+ "@babel/helper-split-export-declaration": {
+ "version": "7.22.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz",
+ "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==",
+ "requires": {
+ "@babel/types": "^7.22.5"
+ },
+ "dependencies": {
+ "@babel/helper-string-parser": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
+ },
+ "@babel/types": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+ "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.22.5",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ }
+ }
+ }
+ },
+ "@babel/helper-string-parser": {
+ "version": "7.21.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.21.5.tgz",
+ "integrity": "sha512-5pTUx3hAJaZIdW99sJ6ZUUgWq/Y+Hja7TowEnLNMm1VivRgZQL3vpBY3qUACVsvw+yQU6+YgfBVmcbLaZtrA1w=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.19.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz",
+ "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w=="
+ },
+ "@babel/highlight": {
+ "version": "7.14.0",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz",
+ "integrity": "sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==",
+ "requires": {
+ "@babel/helper-validator-identifier": "^7.14.0",
+ "chalk": "^2.0.0",
+ "js-tokens": "^4.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ }
+ }
+ },
+ "@babel/parser": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz",
+ "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw=="
+ },
+ "@babel/runtime": {
+ "version": "7.21.5",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz",
+ "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==",
+ "requires": {
+ "regenerator-runtime": "^0.13.11"
+ }
+ },
+ "@babel/runtime-corejs3": {
+ "version": "7.17.9",
+ "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz",
+ "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==",
+ "dev": true,
+ "requires": {
+ "core-js-pure": "^3.20.2",
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "@babel/template": {
+ "version": "7.22.15",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
+ "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==",
+ "requires": {
+ "@babel/code-frame": "^7.22.13",
+ "@babel/parser": "^7.22.15",
+ "@babel/types": "^7.22.15"
+ },
+ "dependencies": {
+ "@babel/code-frame": {
+ "version": "7.22.13",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
+ "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
+ "requires": {
+ "@babel/highlight": "^7.22.13",
+ "chalk": "^2.4.2"
+ }
+ },
+ "@babel/helper-string-parser": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
+ },
+ "@babel/highlight": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
+ "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
+ "requires": {
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "chalk": "^2.4.2",
+ "js-tokens": "^4.0.0"
+ }
+ },
+ "@babel/types": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+ "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.22.5",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ }
+ },
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ }
+ }
+ },
+ "@babel/traverse": {
+ "version": "7.23.2",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz",
+ "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==",
+ "requires": {
+ "@babel/code-frame": "^7.22.13",
+ "@babel/generator": "^7.23.0",
+ "@babel/helper-environment-visitor": "^7.22.20",
+ "@babel/helper-function-name": "^7.23.0",
+ "@babel/helper-hoist-variables": "^7.22.5",
+ "@babel/helper-split-export-declaration": "^7.22.6",
+ "@babel/parser": "^7.23.0",
+ "@babel/types": "^7.23.0",
+ "debug": "^4.1.0",
+ "globals": "^11.1.0"
+ },
+ "dependencies": {
+ "@babel/code-frame": {
+ "version": "7.22.13",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz",
+ "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==",
+ "requires": {
+ "@babel/highlight": "^7.22.13",
+ "chalk": "^2.4.2"
+ }
+ },
+ "@babel/helper-string-parser": {
+ "version": "7.22.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz",
+ "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw=="
+ },
+ "@babel/helper-validator-identifier": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz",
+ "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A=="
+ },
+ "@babel/highlight": {
+ "version": "7.22.20",
+ "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz",
+ "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==",
+ "requires": {
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "chalk": "^2.4.2",
+ "js-tokens": "^4.0.0"
+ }
+ },
+ "@babel/types": {
+ "version": "7.23.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz",
+ "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.22.5",
+ "@babel/helper-validator-identifier": "^7.22.20",
+ "to-fast-properties": "^2.0.0"
+ }
+ },
+ "ansi-styles": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+ "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+ "requires": {
+ "color-convert": "^1.9.0"
+ }
+ },
+ "chalk": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+ "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+ "requires": {
+ "ansi-styles": "^3.2.1",
+ "escape-string-regexp": "^1.0.5",
+ "supports-color": "^5.3.0"
+ }
+ },
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "globals": {
+ "version": "11.12.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ },
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ }
+ }
+ },
+ "@babel/types": {
+ "version": "7.21.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.5.tgz",
+ "integrity": "sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==",
+ "requires": {
+ "@babel/helper-string-parser": "^7.21.5",
+ "@babel/helper-validator-identifier": "^7.19.1",
+ "to-fast-properties": "^2.0.0"
+ }
+ },
+ "@browser-bunyan/console-formatted-stream": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@browser-bunyan/console-formatted-stream/-/console-formatted-stream-1.8.0.tgz",
+ "integrity": "sha512-Lg5SC2uXrvZ6aLwLZT6SErfN1Is4NcrTOb5km4BW/BfL8Lv0CfpsYuhuD7ltdURL6awTYBUiT+BwhKw1Xd9glQ==",
+ "requires": {
+ "@browser-bunyan/levels": "^1.8.0"
+ }
+ },
+ "@browser-bunyan/console-plain-stream": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@browser-bunyan/console-plain-stream/-/console-plain-stream-1.8.0.tgz",
+ "integrity": "sha512-S0WNsH5zvMfkbayIx90wANGHQ8l3Bvd7mjgy95/bYmUzcI+Mwkv2eJcSufdTP/MbdHBhjv/lEdLDOXEPBi+w3A==",
+ "requires": {
+ "@browser-bunyan/levels": "^1.8.0"
+ }
+ },
+ "@browser-bunyan/console-raw-stream": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@browser-bunyan/console-raw-stream/-/console-raw-stream-1.8.0.tgz",
+ "integrity": "sha512-6M/xEiNckbFslQMaS1BHAxvuvN1Wtbh/aq4UzQD3fjEPFCxtubvf4KyzwPxUXA5CXq7leVZ+cibEUCRBsm5bzg==",
+ "requires": {
+ "@browser-bunyan/levels": "^1.8.0"
+ }
+ },
+ "@browser-bunyan/levels": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@browser-bunyan/levels/-/levels-1.8.0.tgz",
+ "integrity": "sha512-f9oSDik8kAl+4rhVyHqIr012P1boHFUKc7D9nzA5+lDsFoP90UQnDwpseqBdF2mTaWYju10E7h+GdH8u+7MHOQ=="
+ },
+ "@browser-bunyan/server-stream": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/@browser-bunyan/server-stream/-/server-stream-1.8.0.tgz",
+ "integrity": "sha512-Jccy9FuJbk/LW52M0SEEzHS5WbFpcP1XgsSzAvGbgSJDNw9S+6vLbVLJi/o/yABuhj6fWzLmACbvin1YJk4XJQ=="
+ },
+ "@colors/colors": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
+ "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
+ },
+ "@dabh/diagnostics": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz",
+ "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==",
+ "requires": {
+ "colorspace": "1.1.x",
+ "enabled": "2.0.x",
+ "kuler": "^2.0.0"
+ }
+ },
+ "@emoji-mart/data": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.1.2.tgz",
+ "integrity": "sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg=="
+ },
+ "@emoji-mart/react": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz",
+ "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="
+ },
+ "@emotion/babel-plugin": {
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz",
+ "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==",
+ "requires": {
+ "@babel/helper-module-imports": "^7.16.7",
+ "@babel/runtime": "^7.18.3",
+ "@emotion/hash": "^0.9.1",
+ "@emotion/memoize": "^0.8.1",
+ "@emotion/serialize": "^1.1.2",
+ "babel-plugin-macros": "^3.1.0",
+ "convert-source-map": "^1.5.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-root": "^1.1.0",
+ "source-map": "^0.5.7",
+ "stylis": "4.2.0"
+ },
+ "dependencies": {
+ "@emotion/hash": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz",
+ "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ=="
+ },
+ "@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ },
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+ }
+ }
+ },
+ "@emotion/cache": {
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz",
+ "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==",
+ "requires": {
+ "@emotion/memoize": "^0.8.1",
+ "@emotion/sheet": "^1.2.2",
+ "@emotion/utils": "^1.2.1",
+ "@emotion/weak-memoize": "^0.3.1",
+ "stylis": "4.2.0"
+ },
+ "dependencies": {
+ "@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ }
+ }
+ },
+ "@emotion/is-prop-valid": {
+ "version": "0.8.8",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
+ "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
+ "requires": {
+ "@emotion/memoize": "0.7.4"
+ }
+ },
+ "@emotion/memoize": {
+ "version": "0.7.4",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
+ "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw=="
+ },
+ "@emotion/react": {
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.0.tgz",
+ "integrity": "sha512-ZSK3ZJsNkwfjT3JpDAWJZlrGD81Z3ytNDsxw1LKq1o+xkmO5pnWfr6gmCC8gHEFf3nSSX/09YrG67jybNPxSUw==",
+ "requires": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.11.0",
+ "@emotion/cache": "^11.11.0",
+ "@emotion/serialize": "^1.1.2",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1",
+ "@emotion/utils": "^1.2.1",
+ "@emotion/weak-memoize": "^0.3.1",
+ "hoist-non-react-statics": "^3.3.1"
+ }
+ },
+ "@emotion/serialize": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.2.tgz",
+ "integrity": "sha512-zR6a/fkFP4EAcCMQtLOhIgpprZOwNmCldtpaISpvz348+DP4Mz8ZoKaGGCQpbzepNIUWbq4w6hNZkwDyKoS+HA==",
+ "requires": {
+ "@emotion/hash": "^0.9.1",
+ "@emotion/memoize": "^0.8.1",
+ "@emotion/unitless": "^0.8.1",
+ "@emotion/utils": "^1.2.1",
+ "csstype": "^3.0.2"
+ },
+ "dependencies": {
+ "@emotion/hash": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz",
+ "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ=="
+ },
+ "@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ },
+ "@emotion/unitless": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+ "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
+ }
+ }
+ },
+ "@emotion/sheet": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz",
+ "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA=="
+ },
+ "@emotion/styled": {
+ "version": "11.11.0",
+ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz",
+ "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==",
+ "requires": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.11.0",
+ "@emotion/is-prop-valid": "^1.2.1",
+ "@emotion/serialize": "^1.1.2",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1",
+ "@emotion/utils": "^1.2.1"
+ },
+ "dependencies": {
+ "@emotion/is-prop-valid": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
+ "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==",
+ "requires": {
+ "@emotion/memoize": "^0.8.1"
+ }
+ },
+ "@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ }
+ }
+ },
+ "@emotion/stylis": {
+ "version": "0.8.5",
+ "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz",
+ "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ=="
+ },
+ "@emotion/unitless": {
+ "version": "0.7.5",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
+ },
+ "@emotion/use-insertion-effect-with-fallbacks": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz",
+ "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw=="
+ },
+ "@emotion/utils": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz",
+ "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg=="
+ },
+ "@emotion/weak-memoize": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz",
+ "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww=="
+ },
+ "@eslint/eslintrc": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
+ "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==",
+ "dev": true,
+ "requires": {
+ "ajv": "^6.12.4",
+ "debug": "^4.1.1",
+ "espree": "^7.3.0",
+ "globals": "^13.9.0",
+ "ignore": "^4.0.6",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^3.13.1",
+ "minimatch": "^3.0.4",
+ "strip-json-comments": "^3.1.1"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ }
+ }
+ },
+ "@floating-ui/core": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.7.3.tgz",
+ "integrity": "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="
+ },
+ "@floating-ui/dom": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.5.4.tgz",
+ "integrity": "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg==",
+ "requires": {
+ "@floating-ui/core": "^0.7.3"
+ }
+ },
+ "@floating-ui/react-dom": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-0.7.2.tgz",
+ "integrity": "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg==",
+ "requires": {
+ "@floating-ui/dom": "^0.5.3",
+ "use-isomorphic-layout-effect": "^1.1.1"
+ }
+ },
+ "@fontsource/caveat-brush": {
+ "version": "4.5.9",
+ "resolved": "https://registry.npmjs.org/@fontsource/caveat-brush/-/caveat-brush-4.5.9.tgz",
+ "integrity": "sha512-H4uDBRSmTYX0Pman53VO8IP8JVnGd2wD4YyXHY0KTQX6jE0rs+UHuzraKGF8WTqBO7854VRCq09sPDhvLvqicQ=="
+ },
+ "@fontsource/crimson-pro": {
+ "version": "4.5.10",
+ "resolved": "https://registry.npmjs.org/@fontsource/crimson-pro/-/crimson-pro-4.5.10.tgz",
+ "integrity": "sha512-4L7B+woKIWiwOQrCvvpAQeEob3xoq5fVLTyt0YhXVafy2KWBTCcFVo/Y9n0UpDtGso9jN8pMo3SrYa3V6IayVw=="
+ },
+ "@fontsource/recursive": {
+ "version": "4.5.12",
+ "resolved": "https://registry.npmjs.org/@fontsource/recursive/-/recursive-4.5.12.tgz",
+ "integrity": "sha512-cQxQ73p9GLolyiT3DXdFakrm5pSDZuctjVrODjFTTHu16jNgYzfRoMDi+fbCckDz3ur+WN2yXswrMDnZfNpQ6g=="
+ },
+ "@fontsource/source-code-pro": {
+ "version": "4.5.12",
+ "resolved": "https://registry.npmjs.org/@fontsource/source-code-pro/-/source-code-pro-4.5.12.tgz",
+ "integrity": "sha512-6r1dykX7SH1Orm7xUh4sA8pjM1uNPKo9fV+y9/wxS+y/fwN+sMf6b1jHDUTmfEtw1OxlTaHGrr2I7dGeNqxdPA=="
+ },
+ "@fontsource/source-sans-pro": {
+ "version": "4.5.11",
+ "resolved": "https://registry.npmjs.org/@fontsource/source-sans-pro/-/source-sans-pro-4.5.11.tgz",
+ "integrity": "sha512-f7iw44q1EjBv3MNcHCGAgrW/QVyweaEouFsJzykPhTOGnZFSwFJRISToXornOmuAy7xUUGiVdqOLiykgZoYB8A=="
+ },
+ "@formatjs/ecma402-abstract": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.12.0.tgz",
+ "integrity": "sha512-0/wm9b7brUD40kx7KSE0S532T8EfH06Zc41rGlinoNyYXnuusR6ull2x63iFJgVXgwahm42hAW7dcYdZ+llZzA==",
+ "requires": {
+ "@formatjs/intl-localematcher": "0.2.31",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/fast-memoize": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.6.tgz",
+ "integrity": "sha512-9CWZ3+wCkClKHX+i5j+NyoBVqGf0pIskTo6Xl6ihGokYM2yqSSS68JIgeo+99UIHc+7vi9L3/SDSz/dWI9SNlA==",
+ "requires": {
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/icu-messageformat-parser": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.7.tgz",
+ "integrity": "sha512-KM4ikG5MloXMulqn39Js3ypuVzpPKq/DDplvl01PE2qD9rAzFO8YtaUCC9vr9j3sRXwdHPeTe8r3J/8IJgvYEQ==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "@formatjs/icu-skeleton-parser": "1.3.13",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/icu-skeleton-parser": {
+ "version": "1.3.13",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.13.tgz",
+ "integrity": "sha512-qb1kxnA4ep76rV+d9JICvZBThBpK5X+nh1dLmmIReX72QyglicsaOmKEcdcbp7/giCWfhVs6CXPVA2JJ5/ZvAw==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/intl": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-2.4.0.tgz",
+ "integrity": "sha512-6b3ex1nz9ZET+Jx/ApYhX62Q/SvZvNK81qG1XAFUKWylJUIFd/bm8hG6CMgh7QVzAeFPa3LIf1y0BkNAQ9VYEw==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "@formatjs/fast-memoize": "1.2.6",
+ "@formatjs/icu-messageformat-parser": "2.1.7",
+ "@formatjs/intl-displaynames": "6.1.2",
+ "@formatjs/intl-listformat": "7.1.2",
+ "intl-messageformat": "10.1.4",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/intl-displaynames": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-6.1.2.tgz",
+ "integrity": "sha512-JbZANoYwNXNy+6NlicVe1/tpiyp+NKJD0WTHgp14aQbMaAg66VVPNAruFmhfhkIABF4jGvc4tdKYAANmWD8W6w==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "@formatjs/intl-localematcher": "0.2.31",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/intl-listformat": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.1.2.tgz",
+ "integrity": "sha512-WfWkJ8k41jZIhXgBtC2T1SpTSKYig99g9MVqrVRco4kduv/6GUWq1eMjk84qZfbU4rwdwc8qct+/gB6DTS17+w==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "@formatjs/intl-localematcher": "0.2.31",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@formatjs/intl-localematcher": {
+ "version": "0.2.31",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.31.tgz",
+ "integrity": "sha512-9QTjdSBpQ7wHShZgsNzNig5qT3rCPvmZogS/wXZzKotns5skbXgs0I7J8cuN0PPqXyynvNVuN+iOKhNS2eb+ZA==",
+ "requires": {
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "@humanwhocodes/config-array": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
+ "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==",
+ "dev": true,
+ "requires": {
+ "@humanwhocodes/object-schema": "^1.2.0",
+ "debug": "^4.1.1",
+ "minimatch": "^3.0.4"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ }
+ }
+ },
+ "@humanwhocodes/object-schema": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+ "dev": true
+ },
+ "@jitsi/sdp-interop": {
+ "version": "0.1.14",
+ "resolved": "https://registry.npmjs.org/@jitsi/sdp-interop/-/sdp-interop-0.1.14.tgz",
+ "integrity": "sha512-v60VAtBx9LO46c9In9oMNY+Ho5993UMOLHBg6VcrcyoVTIWIeqs/9YjjmrQ3Sf4I5aMRABNl7HTby/1lHcqFJw==",
+ "requires": {
+ "sdp-transform": "2.3.0"
+ },
+ "dependencies": {
+ "sdp-transform": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.3.0.tgz",
+ "integrity": "sha1-V6lXWUIEHYV3qGnXx01MOgvYiPY="
+ }
+ }
+ },
+ "@jridgewell/gen-mapping": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+ "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+ "requires": {
+ "@jridgewell/set-array": "^1.0.1",
+ "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/trace-mapping": "^0.3.9"
+ }
+ },
+ "@jridgewell/resolve-uri": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz",
+ "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA=="
+ },
+ "@jridgewell/set-array": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+ "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw=="
+ },
+ "@jridgewell/sourcemap-codec": {
+ "version": "1.4.15",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
+ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
+ },
+ "@jridgewell/trace-mapping": {
+ "version": "0.3.20",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
+ "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
+ "requires": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "@mconf/bbb-diff": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@mconf/bbb-diff/-/bbb-diff-1.2.0.tgz",
+ "integrity": "sha512-pfrCEqS2sfw4bE6cmoTjim9wU3GFn8SPEEIwFmJHxryZU2eYznpk3IgMlYbUL8qcEfFvARutQaANX/ho/DXCGg==",
+ "requires": {
+ "diff": "^5.0.0"
+ }
+ },
+ "@mui/base": {
+ "version": "5.0.0-alpha.128",
+ "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.128.tgz",
+ "integrity": "sha512-wub3wxNN+hUp8hzilMlXX3sZrPo75vsy1cXEQpqdTfIFlE9HprP1jlulFiPg5tfPst2OKmygXr2hhmgvAKRrzQ==",
+ "requires": {
+ "@babel/runtime": "^7.21.0",
+ "@emotion/is-prop-valid": "^1.2.0",
+ "@mui/types": "^7.2.4",
+ "@mui/utils": "^5.12.3",
+ "@popperjs/core": "^2.11.7",
+ "clsx": "^1.2.1",
+ "prop-types": "^15.8.1",
+ "react-is": "^18.2.0"
+ },
+ "dependencies": {
+ "@emotion/is-prop-valid": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
+ "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==",
+ "requires": {
+ "@emotion/memoize": "^0.8.1"
+ }
+ },
+ "@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ },
+ "react-is": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+ }
+ }
+ },
+ "@mui/core-downloads-tracker": {
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.12.3.tgz",
+ "integrity": "sha512-yiJZ+knaknPHuRKhRk4L6XiwppwkAahVal3LuYpvBH7GkA2g+D9WLEXOEnNYtVFUggyKf6fWGLGnx0iqzkU5YA=="
+ },
+ "@mui/material": {
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.12.3.tgz",
+ "integrity": "sha512-xNmKlrEN4HsTaKFNLZfc7ie7CXx2YqEeO//hsXZx2p3MGtDdeMr2sV3jC4hsFs57RhQlF79weY7uVvC8xSuVbg==",
+ "requires": {
+ "@babel/runtime": "^7.21.0",
+ "@mui/base": "5.0.0-alpha.128",
+ "@mui/core-downloads-tracker": "^5.12.3",
+ "@mui/system": "^5.12.3",
+ "@mui/types": "^7.2.4",
+ "@mui/utils": "^5.12.3",
+ "@types/react-transition-group": "^4.4.5",
+ "clsx": "^1.2.1",
+ "csstype": "^3.1.2",
+ "prop-types": "^15.8.1",
+ "react-is": "^18.2.0",
+ "react-transition-group": "^4.4.5"
+ },
+ "dependencies": {
+ "dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "requires": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ },
+ "react-is": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+ },
+ "react-transition-group": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
+ "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
+ "requires": {
+ "@babel/runtime": "^7.5.5",
+ "dom-helpers": "^5.0.1",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2"
+ }
+ }
+ }
+ },
+ "@mui/private-theming": {
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.12.3.tgz",
+ "integrity": "sha512-o1e7Z1Bp27n4x2iUHhegV4/Jp6H3T6iBKHJdLivS5GbwsuAE/5l4SnZ+7+K+e5u9TuhwcAKZLkjvqzkDe8zqfA==",
+ "requires": {
+ "@babel/runtime": "^7.21.0",
+ "@mui/utils": "^5.12.3",
+ "prop-types": "^15.8.1"
+ }
+ },
+ "@mui/styled-engine": {
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.12.3.tgz",
+ "integrity": "sha512-AhZtiRyT8Bjr7fufxE/mLS+QJ3LxwX1kghIcM2B2dvJzSSg9rnIuXDXM959QfUVIM3C8U4x3mgVoPFMQJvc4/g==",
+ "requires": {
+ "@babel/runtime": "^7.21.0",
+ "@emotion/cache": "^11.10.8",
+ "csstype": "^3.1.2",
+ "prop-types": "^15.8.1"
+ }
+ },
+ "@mui/system": {
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.12.3.tgz",
+ "integrity": "sha512-JB/6sypHqeJCqwldWeQ1MKkijH829EcZAKKizxbU2MJdxGG5KSwZvTBa5D9qiJUA1hJFYYupjiuy9ZdJt6rV6w==",
+ "requires": {
+ "@babel/runtime": "^7.21.0",
+ "@mui/private-theming": "^5.12.3",
+ "@mui/styled-engine": "^5.12.3",
+ "@mui/types": "^7.2.4",
+ "@mui/utils": "^5.12.3",
+ "clsx": "^1.2.1",
+ "csstype": "^3.1.2",
+ "prop-types": "^15.8.1"
+ }
+ },
+ "@mui/types": {
+ "version": "7.2.4",
+ "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.4.tgz",
+ "integrity": "sha512-LBcwa8rN84bKF+f5sDyku42w1NTxaPgPyYKODsh01U1fVstTClbUoSA96oyRBnSNyEiAVjKm6Gwx9vjR+xyqHA=="
+ },
+ "@mui/utils": {
+ "version": "5.12.3",
+ "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.12.3.tgz",
+ "integrity": "sha512-D/Z4Ub3MRl7HiUccid7sQYclTr24TqUAQFFlxHQF8FR177BrCTQ0JJZom7EqYjZCdXhwnSkOj2ph685MSKNtIA==",
+ "requires": {
+ "@babel/runtime": "^7.21.0",
+ "@types/prop-types": "^15.7.5",
+ "@types/react-is": "^16.7.1 || ^17.0.0",
+ "prop-types": "^15.8.1",
+ "react-is": "^18.2.0"
+ },
+ "dependencies": {
+ "react-is": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+ }
+ }
+ },
+ "@popperjs/core": {
+ "version": "2.11.7",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.7.tgz",
+ "integrity": "sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw=="
+ },
+ "@radix-ui/primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.0.tgz",
+ "integrity": "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-alert-dialog": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.3.tgz",
+ "integrity": "sha512-QXFy7+bhGi0u+paF2QbJeSCHZs4gLMJIPm6sajUamyW0fro6g1CaSGc5zmc4QmK2NlSGUrq8m+UsUqJYtzvXow==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-dialog": "1.0.3",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-slot": "1.0.1"
+ }
+ },
+ "@radix-ui/react-arrow": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.0.tgz",
+ "integrity": "sha512-1MUuv24HCdepi41+qfv125EwMuxgQ+U+h0A9K3BjCO/J8nVRREKHHpkD9clwfnjEDk9hgGzCnff4aUKCPiRepw==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.0"
+ },
+ "dependencies": {
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-collection": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.0.tgz",
+ "integrity": "sha512-8i1pf5dKjnq90Z8udnnXKzdCEV3/FYrfw0n/b6NvB6piXEn3fO1bOh7HBcpG8XrnIXzxlYu2oCcR38QpyLS/mg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-slot": "1.0.0"
+ },
+ "dependencies": {
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-compose-refs": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.0.tgz",
+ "integrity": "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-context": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.0.tgz",
+ "integrity": "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-context-menu": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-1.0.0.tgz",
+ "integrity": "sha512-JkwOgdXwErwEEpsmgu0Ob8zD3gzWS1brPXnNGPyZEtR6/EYyDgruQYKiihXVsCrPCdrNUHawop9I1+6JTdXPTA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-menu": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-controllable-state": "1.0.0"
+ },
+ "dependencies": {
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-dialog": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.3.tgz",
+ "integrity": "sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-dismissable-layer": "1.0.3",
+ "@radix-ui/react-focus-guards": "1.0.0",
+ "@radix-ui/react-focus-scope": "1.0.2",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-portal": "1.0.2",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-slot": "1.0.1",
+ "@radix-ui/react-use-controllable-state": "1.0.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ }
+ },
+ "@radix-ui/react-direction": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.0.tgz",
+ "integrity": "sha512-2HV05lGUgYcA6xgLQ4BKPDmtL+QbIZYH5fCOTAOOcJ5O0QbWS3i9lKaurLzliYUDhORI2Qr3pyjhJh44lKA3rQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-dismissable-layer": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.3.tgz",
+ "integrity": "sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-escape-keydown": "1.0.2"
+ }
+ },
+ "@radix-ui/react-dropdown-menu": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-1.0.0.tgz",
+ "integrity": "sha512-Ptben3TxPWrZLbInO7zjAK73kmjYuStsxfg6ujgt+EywJyREoibhZYnsSNqC+UiOtl4PdW/MOHhxVDtew5fouQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-menu": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-use-controllable-state": "1.0.0"
+ },
+ "dependencies": {
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-focus-guards": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz",
+ "integrity": "sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-focus-scope": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.2.tgz",
+ "integrity": "sha512-spwXlNTfeIprt+kaEWE/qYuYT3ZAqJiAGjN/JgdvgVDTu8yc+HuX+WOWXrKliKnLnwck0F6JDkqIERncnih+4A==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0"
+ }
+ },
+ "@radix-ui/react-icons": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.1.1.tgz",
+ "integrity": "sha512-xc3wQC59rsFylVbSusQCrrM+6695ppF730Q6yqzhRdqDcRNWIm2R6ngpzBoSOQMcwnq4p805F+Gr7xo4fmtN1A=="
+ },
+ "@radix-ui/react-id": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.0.tgz",
+ "integrity": "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-use-layout-effect": "1.0.0"
+ }
+ },
+ "@radix-ui/react-menu": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-1.0.0.tgz",
+ "integrity": "sha512-icW4C64T6nHh3Z4Q1fxO1RlSShouFF4UpUmPV8FLaJZfphDljannKErDuALDx4ClRLihAPZ9i+PrLNPoWS2DMA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-collection": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-direction": "1.0.0",
+ "@radix-ui/react-dismissable-layer": "1.0.0",
+ "@radix-ui/react-focus-guards": "1.0.0",
+ "@radix-ui/react-focus-scope": "1.0.0",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-popper": "1.0.0",
+ "@radix-ui/react-portal": "1.0.0",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-roving-focus": "1.0.0",
+ "@radix-ui/react-slot": "1.0.0",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.4"
+ },
+ "dependencies": {
+ "@radix-ui/react-dismissable-layer": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.0.tgz",
+ "integrity": "sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-escape-keydown": "1.0.0"
+ }
+ },
+ "@radix-ui/react-focus-scope": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.0.tgz",
+ "integrity": "sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-use-callback-ref": "1.0.0"
+ }
+ },
+ "@radix-ui/react-portal": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.0.tgz",
+ "integrity": "sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.0"
+ }
+ },
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ },
+ "@radix-ui/react-use-escape-keydown": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.0.tgz",
+ "integrity": "sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-use-callback-ref": "1.0.0"
+ }
+ },
+ "react-remove-scroll": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.4.tgz",
+ "integrity": "sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==",
+ "requires": {
+ "react-remove-scroll-bar": "^2.3.3",
+ "react-style-singleton": "^2.2.1",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.0",
+ "use-sidecar": "^1.1.2"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-popover": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.0.5.tgz",
+ "integrity": "sha512-GRHZ8yD12MrN2NLobHPE8Rb5uHTxd9x372DE9PPNnBjpczAQHcZ5ne0KXG4xpf+RDdXSzdLv9ym6mYJCDTaUZg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-dismissable-layer": "1.0.3",
+ "@radix-ui/react-focus-guards": "1.0.0",
+ "@radix-ui/react-focus-scope": "1.0.2",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-popper": "1.1.1",
+ "@radix-ui/react-portal": "1.0.2",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-slot": "1.0.1",
+ "@radix-ui/react-use-controllable-state": "1.0.0",
+ "aria-hidden": "^1.1.1",
+ "react-remove-scroll": "2.5.5"
+ },
+ "dependencies": {
+ "@radix-ui/react-arrow": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.2.tgz",
+ "integrity": "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.2"
+ }
+ },
+ "@radix-ui/react-popper": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.1.tgz",
+ "integrity": "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@floating-ui/react-dom": "0.7.2",
+ "@radix-ui/react-arrow": "1.0.2",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-layout-effect": "1.0.0",
+ "@radix-ui/react-use-rect": "1.0.0",
+ "@radix-ui/react-use-size": "1.0.0",
+ "@radix-ui/rect": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-popper": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.0.0.tgz",
+ "integrity": "sha512-k2dDd+1Wl0XWAMs9ZvAxxYsB9sOsEhrFQV4CINd7IUZf0wfdye4OHen9siwxvZImbzhgVeKTJi68OQmPRvVdMg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@floating-ui/react-dom": "0.7.2",
+ "@radix-ui/react-arrow": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-use-layout-effect": "1.0.0",
+ "@radix-ui/react-use-rect": "1.0.0",
+ "@radix-ui/react-use-size": "1.0.0",
+ "@radix-ui/rect": "1.0.0"
+ },
+ "dependencies": {
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-portal": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.2.tgz",
+ "integrity": "sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.2"
+ }
+ },
+ "@radix-ui/react-presence": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.0.tgz",
+ "integrity": "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-use-layout-effect": "1.0.0"
+ }
+ },
+ "@radix-ui/react-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.2.tgz",
+ "integrity": "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.1"
+ }
+ },
+ "@radix-ui/react-roving-focus": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.0.tgz",
+ "integrity": "sha512-lHvO4MhvoWpeNbiJAoyDsEtbKqP2jkkdwsMVJ3kfqbkC71J/aXE6Th6gkZA1xHEqSku+t+UgoDjvE7Z3gsBpcg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-collection": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-direction": "1.0.0",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.0",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-controllable-state": "1.0.0"
+ },
+ "dependencies": {
+ "@radix-ui/react-primitive": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.0.tgz",
+ "integrity": "sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-slot": "1.0.0"
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.0.tgz",
+ "integrity": "sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-slot": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.1.tgz",
+ "integrity": "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-compose-refs": "1.0.0"
+ }
+ },
+ "@radix-ui/react-tooltip": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.5.tgz",
+ "integrity": "sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/primitive": "1.0.0",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-dismissable-layer": "1.0.3",
+ "@radix-ui/react-id": "1.0.0",
+ "@radix-ui/react-popper": "1.1.1",
+ "@radix-ui/react-portal": "1.0.2",
+ "@radix-ui/react-presence": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-slot": "1.0.1",
+ "@radix-ui/react-use-controllable-state": "1.0.0",
+ "@radix-ui/react-visually-hidden": "1.0.2"
+ },
+ "dependencies": {
+ "@radix-ui/react-arrow": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.2.tgz",
+ "integrity": "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.2"
+ }
+ },
+ "@radix-ui/react-popper": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.1.tgz",
+ "integrity": "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@floating-ui/react-dom": "0.7.2",
+ "@radix-ui/react-arrow": "1.0.2",
+ "@radix-ui/react-compose-refs": "1.0.0",
+ "@radix-ui/react-context": "1.0.0",
+ "@radix-ui/react-primitive": "1.0.2",
+ "@radix-ui/react-use-callback-ref": "1.0.0",
+ "@radix-ui/react-use-layout-effect": "1.0.0",
+ "@radix-ui/react-use-rect": "1.0.0",
+ "@radix-ui/react-use-size": "1.0.0",
+ "@radix-ui/rect": "1.0.0"
+ }
+ }
+ }
+ },
+ "@radix-ui/react-use-callback-ref": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz",
+ "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-use-controllable-state": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.0.tgz",
+ "integrity": "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-use-callback-ref": "1.0.0"
+ }
+ },
+ "@radix-ui/react-use-escape-keydown": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.2.tgz",
+ "integrity": "sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-use-callback-ref": "1.0.0"
+ }
+ },
+ "@radix-ui/react-use-layout-effect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.0.tgz",
+ "integrity": "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@radix-ui/react-use-rect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.0.tgz",
+ "integrity": "sha512-TB7pID8NRMEHxb/qQJpvSt3hQU4sqNPM1VCTjTRjEOa7cEop/QMuq8S6fb/5Tsz64kqSvB9WnwsDHtjnrM9qew==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/rect": "1.0.0"
+ }
+ },
+ "@radix-ui/react-use-size": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.0.tgz",
+ "integrity": "sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-use-layout-effect": "1.0.0"
+ }
+ },
+ "@radix-ui/react-visually-hidden": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.2.tgz",
+ "integrity": "sha512-qirnJxtYn73HEk1rXL12/mXnu2rwsNHDID10th2JGtdK25T9wX+mxRmGt7iPSahw512GbZOc0syZX1nLQGoEOg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10",
+ "@radix-ui/react-primitive": "1.0.2"
+ }
+ },
+ "@radix-ui/rect": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.0.tgz",
+ "integrity": "sha512-d0O68AYy/9oeEy1DdC07bz1/ZXX+DqCskRd3i4JzLSTXwefzaepQrKjXC7aNM8lTHjFLDO0pDgaEiQ7jEk+HVg==",
+ "requires": {
+ "@babel/runtime": "^7.13.10"
+ }
+ },
+ "@stitches/react": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@stitches/react/-/react-1.2.8.tgz",
+ "integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="
+ },
+ "@tldraw/core": {
+ "version": "1.23.2",
+ "resolved": "https://registry.npmjs.org/@tldraw/core/-/core-1.23.2.tgz",
+ "integrity": "sha512-cx+KfqemSHvVonNGwEolosMOsJt5cl3PGRBaXcZOOXQxnFALF22dnMtm6lbmlQQA71EfqNMP5e+qV3jCwuYaqA==",
+ "requires": {
+ "@tldraw/intersect": "^1.9.2",
+ "@tldraw/vec": "^1.9.2",
+ "@use-gesture/react": "^10.2.19",
+ "perfect-freehand": "^1.1.0"
+ }
+ },
+ "@tldraw/intersect": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@tldraw/intersect/-/intersect-1.9.2.tgz",
+ "integrity": "sha512-teUQLy+p5YT4PIKOHaL+zM0NYD1779mPp02xabP+5LGLvv7tt9VaqJ9D899EYppQbBLN6be6CJUrmibrvLtnUQ==",
+ "requires": {
+ "@tldraw/vec": "^1.9.2"
+ }
+ },
+ "@tldraw/tldraw": {
+ "version": "1.27.0",
+ "resolved": "https://registry.npmjs.org/@tldraw/tldraw/-/tldraw-1.27.0.tgz",
+ "integrity": "sha512-ukN1EzLJxDutoo3ZOPZEfEgKUOt+St5yO8P/HSpJF+/QWDEzVrEttZGXOpny02R30tb96rdjtNQEex8TzUjzIQ==",
+ "requires": {
+ "@fontsource/caveat-brush": "^4.5.9",
+ "@fontsource/crimson-pro": "^4.5.10",
+ "@fontsource/recursive": "^4.5.11",
+ "@fontsource/source-code-pro": "^4.5.12",
+ "@fontsource/source-sans-pro": "^4.5.11",
+ "@radix-ui/react-alert-dialog": "^1.0.0",
+ "@radix-ui/react-context-menu": "^1.0.0",
+ "@radix-ui/react-dialog": "^1.0.0",
+ "@radix-ui/react-dropdown-menu": "^1.0.0",
+ "@radix-ui/react-icons": "^1.1.1",
+ "@radix-ui/react-popover": "^1.0.0",
+ "@radix-ui/react-tooltip": "^1.0.0",
+ "@stitches/react": "^1.2.8",
+ "@tldraw/core": "^1.21.0",
+ "@tldraw/intersect": "^1.8.0",
+ "@tldraw/vec": "^1.8.0",
+ "browser-fs-access": "^0.31.0",
+ "idb-keyval": "^6.2.0",
+ "perfect-freehand": "^1.2.0",
+ "react-error-boundary": "^3.1.4",
+ "react-hotkeys-hook": "^3.4.7",
+ "react-intl": "^6.1.1",
+ "tslib": "^2.4.0",
+ "zustand": "^4.1.1"
+ },
+ "dependencies": {
+ "@formatjs/ecma402-abstract": {
+ "version": "1.14.3",
+ "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.14.3.tgz",
+ "integrity": "sha512-SlsbRC/RX+/zg4AApWIFNDdkLtFbkq3LNoZWXZCE/nHVKqoIJyaoQyge/I0Y38vLxowUn9KTtXgusLD91+orbg==",
+ "requires": {
+ "@formatjs/intl-localematcher": "0.2.32",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/fast-memoize": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-1.2.7.tgz",
+ "integrity": "sha512-hPeM5LXUUjtCKPybWOUAWpv8lpja8Xz+uKprFPJcg5F2Rd+/bf1E0UUsLRpaAgOReAf5HMRtoIgv/UcyPICrTQ==",
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/icu-messageformat-parser": {
+ "version": "2.1.14",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.14.tgz",
+ "integrity": "sha512-0KqeVOb72losEhUW+59vhZGGd14s1f35uThfEMVKZHKLEObvJdFTiI3ZQwvTMUCzLEMxnS6mtnYPmG4mTvwd3Q==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "@formatjs/icu-skeleton-parser": "1.3.18",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/icu-skeleton-parser": {
+ "version": "1.3.18",
+ "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.18.tgz",
+ "integrity": "sha512-ND1ZkZfmLPcHjAH1sVpkpQxA+QYfOX3py3SjKWMUVGDow18gZ0WPqz3F+pJLYQMpS2LnnQ5zYR2jPVYTbRwMpg==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/intl": {
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-2.6.3.tgz",
+ "integrity": "sha512-JaVZk14U/GypVfCZPevQ0KdruFkq16FXx7g398/Dm+YEx/W7sRiftbZeDy4wQ7WGryb45e763XycxD9o/vm9BA==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "@formatjs/fast-memoize": "1.2.7",
+ "@formatjs/icu-messageformat-parser": "2.1.14",
+ "@formatjs/intl-displaynames": "6.2.3",
+ "@formatjs/intl-listformat": "7.1.7",
+ "intl-messageformat": "10.2.5",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/intl-displaynames": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-displaynames/-/intl-displaynames-6.2.3.tgz",
+ "integrity": "sha512-teB0L68MDGM8jEKQg55w7nvFjzeLHE6e3eK/04s+iuEVYYmvjjiHJKHrthKENzcJ0F6mHf/AwXrbX+1mKxT6AQ==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "@formatjs/intl-localematcher": "0.2.32",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/intl-listformat": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-listformat/-/intl-listformat-7.1.7.tgz",
+ "integrity": "sha512-Zzf5ruPpfJnrAA2hGgf/6pMgQ3tx9oJVhpqycFDavHl3eEzrwdHddGqGdSNwhd0bB4NAFttZNQdmKDldc5iDZw==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "@formatjs/intl-localematcher": "0.2.32",
+ "tslib": "^2.4.0"
+ }
+ },
+ "@formatjs/intl-localematcher": {
+ "version": "0.2.32",
+ "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.2.32.tgz",
+ "integrity": "sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==",
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "intl-messageformat": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.2.5.tgz",
+ "integrity": "sha512-AievYMN6WLLHwBeCTv4aRKG+w3ZNyZtkObwgsKk3Q7GNTq8zDRvDbJSBQkb2OPeVCcAKcIXvak9FF/bRNavoww==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "@formatjs/fast-memoize": "1.2.7",
+ "@formatjs/icu-messageformat-parser": "2.1.14",
+ "tslib": "^2.4.0"
+ }
+ },
+ "react-intl": {
+ "version": "6.2.5",
+ "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.2.5.tgz",
+ "integrity": "sha512-nz21POTKbE0sPEuEJU4o5YTZYY7VlIYCPNJaD6D2+xKyk6Noj6DoUK0LRO9LXuQNUuQ044IZl3m6ymzZRj8XFQ==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.14.3",
+ "@formatjs/icu-messageformat-parser": "2.1.14",
+ "@formatjs/intl": "2.6.3",
+ "@formatjs/intl-displaynames": "6.2.3",
+ "@formatjs/intl-listformat": "7.1.7",
+ "@types/hoist-non-react-statics": "^3.3.1",
+ "@types/react": "16 || 17 || 18",
+ "hoist-non-react-statics": "^3.3.2",
+ "intl-messageformat": "10.2.5",
+ "tslib": "^2.4.0"
+ }
+ },
+ "tslib": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz",
+ "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA=="
+ }
+ }
+ },
+ "@tldraw/vec": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/@tldraw/vec/-/vec-1.9.2.tgz",
+ "integrity": "sha512-k9vH52MRpJHjVcaahWu6VqvhLeE9h1qL5Z2gLobS9zTMpUJ59kBQPNo0VPzPlDYBpXdS4GxuB4jYQMnKvuPAZg=="
+ },
+ "@types/hoist-non-react-statics": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
+ "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
+ "requires": {
+ "@types/react": "*",
+ "hoist-non-react-statics": "^3.3.0"
+ }
+ },
+ "@types/json5": {
+ "version": "0.0.29",
+ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
+ "dev": true
+ },
+ "@types/parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
+ },
+ "@types/prop-types": {
+ "version": "15.7.5",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
+ "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
+ },
+ "@types/react": {
+ "version": "17.0.9",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.9.tgz",
+ "integrity": "sha512-2Cw7FvevpJxQrCb+k5t6GH1KIvmadj5uBbjPaLlJB/nZWUj56e1ZqcD6zsoMFB47MsJUTFl9RJ132A7hb3QFJA==",
+ "requires": {
+ "@types/prop-types": "*",
+ "@types/scheduler": "*",
+ "csstype": "^3.0.2"
+ }
+ },
+ "@types/react-is": {
+ "version": "17.0.4",
+ "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.4.tgz",
+ "integrity": "sha512-FLzd0K9pnaEvKz4D1vYxK9JmgQPiGk1lu23o1kqGsLeT0iPbRSF7b76+S5T9fD8aRa0B8bY7I/3DebEj+1ysBA==",
+ "requires": {
+ "@types/react": "^17"
+ }
+ },
+ "@types/react-transition-group": {
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz",
+ "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==",
+ "requires": {
+ "@types/react": "*"
+ }
+ },
+ "@types/scheduler": {
+ "version": "0.16.1",
+ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.1.tgz",
+ "integrity": "sha512-EaCxbanVeyxDRTQBkdLb3Bvl/HK7PBK6UJjsSixB0iHKoWxE5uu2Q/DgtpOhPIojN0Zl1whvOd7PoHs2P0s5eA=="
+ },
+ "@use-gesture/core": {
+ "version": "10.2.26",
+ "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.2.26.tgz",
+ "integrity": "sha512-NyFpQ3iID9iFBROXyyvU1D0NK+t+dP+WAVByhCvqHUenpxLD2NlRLVRpoK3XGGwksr6mU3PvZ2Nm4q0q+gLJPA=="
+ },
+ "@use-gesture/react": {
+ "version": "10.2.26",
+ "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.2.26.tgz",
+ "integrity": "sha512-0QhaE5mhaQbFlip4MX7n1nwCX8gax6Da1LsP2fZ/BU6xW9zyEmV6NX7DPelDxq1rr2NiBJh30vx9RIp80YeA/A==",
+ "requires": {
+ "@use-gesture/core": "10.2.26"
+ }
+ },
+ "acorn": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+ "dev": true
+ },
+ "acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true
+ },
+ "aggregate-error": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+ "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+ "dev": true,
+ "requires": {
+ "clean-stack": "^2.0.0",
+ "indent-string": "^4.0.0"
+ },
+ "dependencies": {
+ "indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true
+ }
+ }
+ },
+ "ajv": {
+ "version": "6.12.6",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "ansi-colors": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+ "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+ "dev": true
+ },
+ "ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.21.3"
+ },
+ "dependencies": {
+ "type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "dev": true
+ }
+ }
+ },
+ "ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true
+ },
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ },
+ "dependencies": {
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ }
+ }
+ },
+ "argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "requires": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "aria-hidden": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.2.tgz",
+ "integrity": "sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
+ "aria-query": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
+ "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.10.2",
+ "@babel/runtime-corejs3": "^7.10.2"
+ }
+ },
+ "array-includes": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz",
+ "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "is-string": "^1.0.7"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "array.prototype.flat": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz",
+ "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "array.prototype.flatmap": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz",
+ "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.2",
+ "es-shim-unscopables": "^1.0.0"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true
+ },
+ "ast-types-flow": {
+ "version": "0.0.7",
+ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
+ "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==",
+ "dev": true
+ },
+ "astral-regex": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+ "dev": true
+ },
+ "async": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+ "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g=="
+ },
+ "asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "attr-accept": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-1.1.3.tgz",
+ "integrity": "sha512-iT40nudw8zmCweivz6j58g+RT33I4KbaIvRUhjNmDwO2WmsQUxFEZZYZ5w3vXe5x5MX9D7mfvA/XaLOZYFR9EQ==",
+ "requires": {
+ "core-js": "^2.5.0"
+ }
+ },
+ "autoprefixer": {
+ "version": "10.4.4",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.4.tgz",
+ "integrity": "sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA==",
+ "requires": {
+ "browserslist": "^4.20.2",
+ "caniuse-lite": "^1.0.30001317",
+ "fraction.js": "^4.2.0",
+ "normalize-range": "^0.1.2",
+ "picocolors": "^1.0.0",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "dependencies": {
+ "postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
+ }
+ }
+ },
+ "autosize": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/autosize/-/autosize-4.0.4.tgz",
+ "integrity": "sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ=="
+ },
+ "axe-core": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz",
+ "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==",
+ "dev": true
+ },
+ "axios": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.4.tgz",
+ "integrity": "sha512-heJnIs6N4aa1eSthhN9M5ioILu8Wi8vmQW9iHQ9NUvfkJb0lEEDUiIdQNAuBtfUt3FxReaKdpQA5DbmMOqzF/A==",
+ "requires": {
+ "follow-redirects": "^1.15.4",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "axobject-query": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
+ "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==",
+ "dev": true
+ },
+ "babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "dependencies": {
+ "cosmiconfig": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
+ "requires": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ }
+ },
+ "yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
+ }
+ }
+ },
+ "babel-plugin-styled-components": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.1.1.tgz",
+ "integrity": "sha512-c8lJlszObVQPguHkI+akXv8+Jgb9Ccujx0EetL7oIvwU100LxO6XAGe45qry37wUL40a5U9f23SYrivro2XKhA==",
+ "requires": {
+ "@babel/helper-annotate-as-pure": "^7.16.0",
+ "@babel/helper-module-imports": "^7.16.0",
+ "babel-plugin-syntax-jsx": "^6.18.0",
+ "lodash": "^4.17.21",
+ "picomatch": "^2.3.0"
+ }
+ },
+ "babel-plugin-syntax-jsx": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-jsx/-/babel-plugin-syntax-jsx-6.18.0.tgz",
+ "integrity": "sha512-qrPaCSo9c8RHNRHIotaufGbuOBN8rtdC4QrrFFc43vyWCCz7Kl7GL1PGaXtMGQZUXrkCjNEgxDfmAuAabr/rlw=="
+ },
+ "babel-runtime": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+ "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
+ "requires": {
+ "core-js": "^2.4.0",
+ "regenerator-runtime": "^0.11.0"
+ },
+ "dependencies": {
+ "regenerator-runtime": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+ "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
+ }
+ }
+ },
+ "balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true
+ },
+ "bintrees": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz",
+ "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ="
+ },
+ "bowser": {
+ "version": "2.11.0",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
+ "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA=="
+ },
+ "brace-expansion": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+ "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "requires": {
+ "fill-range": "^7.1.1"
+ },
+ "dependencies": {
+ "fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ }
+ }
+ },
+ "browser-bunyan": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/browser-bunyan/-/browser-bunyan-1.8.0.tgz",
+ "integrity": "sha512-Et1TaRUm8m2oy4OTi69g0qAM8wqpofACUgkdBnj1Kq2aC8Wpl8w+lNevebPG6zKH2w0Aq+BHiAXWwjm0/QbkaQ==",
+ "requires": {
+ "@browser-bunyan/console-formatted-stream": "^1.8.0",
+ "@browser-bunyan/console-plain-stream": "^1.8.0",
+ "@browser-bunyan/console-raw-stream": "^1.8.0",
+ "@browser-bunyan/levels": "^1.8.0"
+ }
+ },
+ "browser-fs-access": {
+ "version": "0.31.2",
+ "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.31.2.tgz",
+ "integrity": "sha512-wZSA7UgKMwR6oxddFQeSIoD7cxiNiaZT+iuVJw4/avr9t2ROwu80gxENT0YJChsLxJ7xBbLZDGHTAXfAg3Pq5Q=="
+ },
+ "browserslist": {
+ "version": "4.20.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.2.tgz",
+ "integrity": "sha512-CQOBCqp/9pDvDbx3xfMi+86pr4KXIf2FDkTTdeuYw8OxS9t898LA1Khq57gtufFILXpfgsSx5woNgsBgvGjpsA==",
+ "requires": {
+ "caniuse-lite": "^1.0.30001317",
+ "electron-to-chromium": "^1.4.84",
+ "escalade": "^3.1.1",
+ "node-releases": "^2.0.2",
+ "picocolors": "^1.0.0"
+ }
+ },
+ "call-bind": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+ "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.0.2"
+ }
+ },
+ "caller-callsite": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/caller-callsite/-/caller-callsite-2.0.0.tgz",
+ "integrity": "sha1-hH4PzgoiN1CpoCfFSzNzGtMVQTQ=",
+ "dev": true,
+ "requires": {
+ "callsites": "^2.0.0"
+ },
+ "dependencies": {
+ "callsites": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz",
+ "integrity": "sha1-BuuE8A7qQT2oav/vrL/7Ngk7PFA=",
+ "dev": true
+ }
+ }
+ },
+ "caller-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-2.0.0.tgz",
+ "integrity": "sha1-Ro+DBE42mrIBD6xfBs7uFbsssfQ=",
+ "dev": true,
+ "requires": {
+ "caller-callsite": "^2.0.0"
+ }
+ },
+ "callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
+ },
+ "camelize": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz",
+ "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs="
+ },
+ "caniuse-lite": {
+ "version": "1.0.30001332",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001332.tgz",
+ "integrity": "sha512-10T30NYOEQtN6C11YGg411yebhvpnC6Z102+B95eAsN0oB6KUs01ivE8u+G6FMIRtIrVlYXhL+LUwQ3/hXwDWw=="
+ },
+ "chai": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz",
+ "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==",
+ "dev": true,
+ "requires": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.2",
+ "deep-eql": "^3.0.1",
+ "get-func-name": "^2.0.0",
+ "pathval": "^1.1.0",
+ "type-detect": "^4.0.5"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "charenc": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+ "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
+ "dev": true
+ },
+ "check-error": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=",
+ "dev": true
+ },
+ "ci-info": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
+ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
+ "dev": true
+ },
+ "classnames": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz",
+ "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
+ },
+ "clean-stack": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+ "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+ "dev": true
+ },
+ "cli-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+ "dev": true,
+ "requires": {
+ "restore-cursor": "^3.1.0"
+ }
+ },
+ "cli-truncate": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
+ "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
+ "dev": true,
+ "requires": {
+ "slice-ansi": "^3.0.0",
+ "string-width": "^4.2.0"
+ },
+ "dependencies": {
+ "slice-ansi": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
+ "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ }
+ }
+ }
+ },
+ "clsx": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+ "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+ },
+ "color": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
+ "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
+ "requires": {
+ "color-convert": "^1.9.3",
+ "color-string": "^1.6.0"
+ }
+ },
+ "color-convert": {
+ "version": "1.9.3",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+ "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+ "requires": {
+ "color-name": "1.1.3"
+ }
+ },
+ "color-name": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+ "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+ },
+ "color-string": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz",
+ "integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==",
+ "requires": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "colorspace": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
+ "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
+ "requires": {
+ "color": "^3.1.3",
+ "text-hex": "1.0.x"
+ }
+ },
+ "combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "requires": {
+ "delayed-stream": "~1.0.0"
+ }
+ },
+ "commander": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+ "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+ "dev": true
+ },
+ "computed-style": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/computed-style/-/computed-style-0.1.4.tgz",
+ "integrity": "sha1-fzRP2FhLLkJb7cpKGvwOMAuwXXQ="
+ },
+ "concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+ "dev": true
+ },
+ "confusing-browser-globals": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.10.tgz",
+ "integrity": "sha512-gNld/3lySHwuhaVluJUKLePYirM3QNCKzVxqAdhJII9/WXKVX5PURzMVJspS1jTslSqjeuG4KMVTSouit5YPHA==",
+ "dev": true
+ },
+ "convert-source-map": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
+ },
+ "core-js": {
+ "version": "2.6.12",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
+ "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ=="
+ },
+ "core-js-pure": {
+ "version": "3.22.2",
+ "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.2.tgz",
+ "integrity": "sha512-Lb+/XT4WC4PaCWWtZpNPaXmjiNDUe5CJuUtbkMrIM1kb1T/jJoAIp+bkVP/r5lHzMr+ZAAF8XHp7+my6Ol0ysQ==",
+ "dev": true
+ },
+ "cosmiconfig": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
+ "integrity": "sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==",
+ "dev": true,
+ "requires": {
+ "import-fresh": "^2.0.0",
+ "is-directory": "^0.3.1",
+ "js-yaml": "^3.13.1",
+ "parse-json": "^4.0.0"
+ },
+ "dependencies": {
+ "import-fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-2.0.0.tgz",
+ "integrity": "sha1-2BNVwVYS04bGH53dOSLUMEgipUY=",
+ "dev": true,
+ "requires": {
+ "caller-path": "^2.0.0",
+ "resolve-from": "^3.0.0"
+ }
+ },
+ "parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
+ "dev": true,
+ "requires": {
+ "error-ex": "^1.3.1",
+ "json-parse-better-errors": "^1.0.1"
+ }
+ },
+ "resolve-from": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz",
+ "integrity": "sha1-six699nWiBvItuZTM17rywoYh0g=",
+ "dev": true
+ }
+ }
+ },
+ "cross-spawn": {
+ "version": "7.0.3",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+ "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ }
+ },
+ "crypt": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+ "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
+ "dev": true
+ },
+ "css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="
+ },
+ "css-to-react-native": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.0.0.tgz",
+ "integrity": "sha512-Ro1yETZA813eoyUp2GDBhG2j+YggidUmzO1/v9eYBKR2EHVEniE2MI/NqpTQ954BMpTPZFsGNPm46qFB9dpaPQ==",
+ "requires": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
+ "cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
+ },
+ "csstype": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
+ "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+ },
+ "custom-event-polyfill": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-0.3.0.tgz",
+ "integrity": "sha1-mYB4Ob5i7bRGtkWDLg2A6tb6GIg="
+ },
+ "damerau-levenshtein": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+ "dev": true
+ },
+ "darkreader": {
+ "version": "4.9.46",
+ "resolved": "https://registry.npmjs.org/darkreader/-/darkreader-4.9.46.tgz",
+ "integrity": "sha512-K+MG74C8mGXvrsY47geAQdAbKU2mw8zVCa/WT4vtW3UDgzYCmthCcLnY0+FR3RQRMOyY2fULxqREZhfVQ346AA=="
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "deep-eql": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz",
+ "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==",
+ "dev": true,
+ "requires": {
+ "type-detect": "^4.0.0"
+ }
+ },
+ "deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true
+ },
+ "deepmerge": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+ "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
+ },
+ "define-properties": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+ "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+ "dev": true,
+ "requires": {
+ "object-keys": "^1.0.12"
+ }
+ },
+ "delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+ },
+ "denque": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
+ "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ=="
+ },
+ "detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups="
+ },
+ "detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
+ },
+ "diff": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+ "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w=="
+ },
+ "doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "dom-helpers": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
+ "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
+ "requires": {
+ "@babel/runtime": "^7.1.2"
+ }
+ },
+ "dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ }
+ },
+ "domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="
+ },
+ "domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "requires": {
+ "domelementtype": "^2.3.0"
+ }
+ },
+ "domutils": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
+ "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
+ "requires": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ }
+ },
+ "electron-to-chromium": {
+ "version": "1.4.117",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.117.tgz",
+ "integrity": "sha512-ypZHxY+Sf/PXu7LVN+xoeanyisnJeSOy8Ki439L/oLueZb4c72FI45zXcK3gPpmTwyufh9m6NnbMLXnJh/0Fxg=="
+ },
+ "emoji-mart": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.5.2.tgz",
+ "integrity": "sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A=="
+ },
+ "emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true
+ },
+ "enabled": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
+ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="
+ },
+ "end-of-stream": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+ "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+ "dev": true,
+ "requires": {
+ "once": "^1.4.0"
+ }
+ },
+ "enquirer": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+ "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+ "dev": true,
+ "requires": {
+ "ansi-colors": "^4.1.1"
+ }
+ },
+ "entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
+ },
+ "error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "requires": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "es-abstract": {
+ "version": "1.18.3",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.3.tgz",
+ "integrity": "sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.2",
+ "is-callable": "^1.2.3",
+ "is-negative-zero": "^2.0.1",
+ "is-regex": "^1.1.3",
+ "is-string": "^1.0.6",
+ "object-inspect": "^1.10.3",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "es-shim-unscopables": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz",
+ "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "es-to-primitive": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+ "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+ "dev": true,
+ "requires": {
+ "is-callable": "^1.1.4",
+ "is-date-object": "^1.0.1",
+ "is-symbol": "^1.0.2"
+ }
+ },
+ "escalade": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+ "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+ },
+ "eslint": {
+ "version": "7.32.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz",
+ "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "7.12.11",
+ "@eslint/eslintrc": "^0.4.3",
+ "@humanwhocodes/config-array": "^0.5.0",
+ "ajv": "^6.10.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.2",
+ "debug": "^4.0.1",
+ "doctrine": "^3.0.0",
+ "enquirer": "^2.3.5",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^5.1.1",
+ "eslint-utils": "^2.1.0",
+ "eslint-visitor-keys": "^2.0.0",
+ "espree": "^7.3.1",
+ "esquery": "^1.4.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^6.0.1",
+ "functional-red-black-tree": "^1.0.1",
+ "glob-parent": "^5.1.2",
+ "globals": "^13.6.0",
+ "ignore": "^4.0.6",
+ "import-fresh": "^3.0.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "js-yaml": "^3.13.1",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "levn": "^0.4.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.0.4",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.1",
+ "progress": "^2.0.0",
+ "regexpp": "^3.1.0",
+ "semver": "^7.2.1",
+ "strip-ansi": "^6.0.0",
+ "strip-json-comments": "^3.1.0",
+ "table": "^6.0.9",
+ "text-table": "^0.2.0",
+ "v8-compile-cache": "^2.0.3"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "4.3.4",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+ "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "semver": {
+ "version": "7.5.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz",
+ "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==",
+ "dev": true,
+ "requires": {
+ "lru-cache": "^6.0.0"
+ },
+ "dependencies": {
+ "lru-cache": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+ "dev": true,
+ "requires": {
+ "yallist": "^4.0.0"
+ }
+ }
+ }
+ }
+ }
+ },
+ "eslint-config-airbnb": {
+ "version": "18.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-18.2.1.tgz",
+ "integrity": "sha512-glZNDEZ36VdlZWoxn/bUR1r/sdFKPd1mHPbqUtkctgNG4yT2DLLtJ3D+yCV+jzZCc2V1nBVkmdknOJBZ5Hc0fg==",
+ "dev": true,
+ "requires": {
+ "eslint-config-airbnb-base": "^14.2.1",
+ "object.assign": "^4.1.2",
+ "object.entries": "^1.1.2"
+ }
+ },
+ "eslint-config-airbnb-base": {
+ "version": "14.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.1.tgz",
+ "integrity": "sha512-GOrQyDtVEc1Xy20U7vsB2yAoB4nBlfH5HZJeatRXHleO+OS5Ot+MWij4Dpltw4/DyIkqUfqz1epfhVR5XWWQPA==",
+ "dev": true,
+ "requires": {
+ "confusing-browser-globals": "^1.0.10",
+ "object.assign": "^4.1.2",
+ "object.entries": "^1.1.2"
+ }
+ },
+ "eslint-import-resolver-node": {
+ "version": "0.3.6",
+ "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
+ "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==",
+ "dev": true,
+ "requires": {
+ "debug": "^3.2.7",
+ "resolve": "^1.20.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-module-utils": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz",
+ "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==",
+ "dev": true,
+ "requires": {
+ "debug": "^3.2.7",
+ "find-up": "^2.1.0"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "dev": true,
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-plugin-import": {
+ "version": "2.26.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz",
+ "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==",
+ "dev": true,
+ "requires": {
+ "array-includes": "^3.1.4",
+ "array.prototype.flat": "^1.2.5",
+ "debug": "^2.6.9",
+ "doctrine": "^2.1.0",
+ "eslint-import-resolver-node": "^0.3.6",
+ "eslint-module-utils": "^2.7.3",
+ "has": "^1.0.3",
+ "is-core-module": "^2.8.1",
+ "is-glob": "^4.0.3",
+ "minimatch": "^3.1.2",
+ "object.values": "^1.1.5",
+ "resolve": "^1.22.0",
+ "tsconfig-paths": "^3.14.1"
+ },
+ "dependencies": {
+ "doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "is-core-module": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz",
+ "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==",
+ "dev": true,
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "resolve": {
+ "version": "1.22.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",
+ "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==",
+ "dev": true,
+ "requires": {
+ "is-core-module": "^2.8.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ }
+ }
+ }
+ },
+ "eslint-plugin-jsx-a11y": {
+ "version": "6.5.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz",
+ "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.16.3",
+ "aria-query": "^4.2.2",
+ "array-includes": "^3.1.4",
+ "ast-types-flow": "^0.0.7",
+ "axe-core": "^4.3.5",
+ "axobject-query": "^2.2.0",
+ "damerau-levenshtein": "^1.0.7",
+ "emoji-regex": "^9.2.2",
+ "has": "^1.0.3",
+ "jsx-ast-utils": "^3.2.1",
+ "language-tags": "^1.0.5",
+ "minimatch": "^3.0.4"
+ },
+ "dependencies": {
+ "emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-plugin-react": {
+ "version": "7.29.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.29.4.tgz",
+ "integrity": "sha512-CVCXajliVh509PcZYRFyu/BoUEz452+jtQJq2b3Bae4v3xBUWPLCmtmBM+ZinG4MzwmxJgJ2M5rMqhqLVn7MtQ==",
+ "dev": true,
+ "requires": {
+ "array-includes": "^3.1.4",
+ "array.prototype.flatmap": "^1.2.5",
+ "doctrine": "^2.1.0",
+ "estraverse": "^5.3.0",
+ "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+ "minimatch": "^3.1.2",
+ "object.entries": "^1.1.5",
+ "object.fromentries": "^2.0.5",
+ "object.hasown": "^1.1.0",
+ "object.values": "^1.1.5",
+ "prop-types": "^15.8.1",
+ "resolve": "^2.0.0-next.3",
+ "semver": "^6.3.0",
+ "string.prototype.matchall": "^4.0.6"
+ },
+ "dependencies": {
+ "doctrine": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+ "dev": true,
+ "requires": {
+ "esutils": "^2.0.2"
+ }
+ },
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ },
+ "object.entries": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+ "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ }
+ },
+ "resolve": {
+ "version": "2.0.0-next.3",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
+ "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+ "dev": true,
+ "requires": {
+ "is-core-module": "^2.2.0",
+ "path-parse": "^1.0.6"
+ }
+ },
+ "semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-plugin-react-hooks": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.4.0.tgz",
+ "integrity": "sha512-U3RVIfdzJaeKDQKEJbz5p3NW8/L80PCATJAfuojwbaEL+gBjfGdhUcGde+WGUW46Q5sr/NgxevsIiDtNXrvZaQ==",
+ "dev": true
+ },
+ "eslint-scope": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+ "dev": true,
+ "requires": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^4.1.1"
+ }
+ },
+ "eslint-utils": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+ "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+ "dev": true,
+ "requires": {
+ "eslint-visitor-keys": "^1.1.0"
+ },
+ "dependencies": {
+ "eslint-visitor-keys": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+ "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+ "dev": true
+ }
+ }
+ },
+ "eslint-visitor-keys": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+ "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+ "dev": true
+ },
+ "espree": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
+ "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==",
+ "dev": true,
+ "requires": {
+ "acorn": "^7.4.0",
+ "acorn-jsx": "^5.3.1",
+ "eslint-visitor-keys": "^1.3.0"
+ },
+ "dependencies": {
+ "eslint-visitor-keys": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+ "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+ "dev": true
+ }
+ }
+ },
+ "esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true
+ },
+ "esquery": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+ "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.1.0"
+ },
+ "dependencies": {
+ "estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true
+ }
+ }
+ },
+ "esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "requires": {
+ "estraverse": "^5.2.0"
+ },
+ "dependencies": {
+ "estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true
+ }
+ }
+ },
+ "estraverse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+ "dev": true
+ },
+ "esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true
+ },
+ "eventemitter2": {
+ "version": "6.4.6",
+ "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.6.tgz",
+ "integrity": "sha512-OHqo4wbHX5VbvlbB6o6eDwhYmiTjrpWACjF8Pmof/GTD6rdBNdZFNck3xlhqOiQFGCOoq3uzHvA0cQpFHIGVAQ=="
+ },
+ "execa": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
+ "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^6.0.0",
+ "get-stream": "^4.0.0",
+ "is-stream": "^1.1.0",
+ "npm-run-path": "^2.0.0",
+ "p-finally": "^1.0.0",
+ "signal-exit": "^3.0.0",
+ "strip-eof": "^1.0.0"
+ },
+ "dependencies": {
+ "cross-spawn": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
+ "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
+ "dev": true,
+ "requires": {
+ "nice-try": "^1.0.4",
+ "path-key": "^2.0.1",
+ "semver": "^5.5.0",
+ "shebang-command": "^1.2.0",
+ "which": "^1.2.9"
+ }
+ },
+ "is-stream": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
+ "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
+ "dev": true
+ },
+ "path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
+ "dev": true
+ },
+ "shebang-command": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+ "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^1.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+ "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
+ "dev": true
+ },
+ "which": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+ "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ }
+ }
+ },
+ "exenv": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz",
+ "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
+ },
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true
+ },
+ "fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true
+ },
+ "fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+ "dev": true
+ },
+ "fastdom": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.10.tgz",
+ "integrity": "sha512-sbL4h358IlZn8VsTvA5TYnKVLYif46XhPEll+HTSxVtDSpqZEO/17D/QqlxE9V2K7AQ82GXeYeQLU2HWwKgk1A==",
+ "requires": {
+ "strictdom": "^1.0.1"
+ }
+ },
+ "fecha": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
+ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
+ },
+ "fibers": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/fibers/-/fibers-4.0.3.tgz",
+ "integrity": "sha512-MW5VrDtTOLpKK7lzw4qD7Z9tXaAhdOmOED5RHzg3+HjUk+ibkjVW0Py2ERtdqgTXaerLkVkBy2AEmJiT6RMyzg==",
+ "requires": {
+ "detect-libc": "^1.0.3"
+ }
+ },
+ "file-entry-cache": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+ "dev": true,
+ "requires": {
+ "flat-cache": "^3.0.4"
+ }
+ },
+ "find-root": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
+ },
+ "find-up": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+ "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
+ "dev": true,
+ "requires": {
+ "locate-path": "^2.0.0"
+ },
+ "dependencies": {
+ "locate-path": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+ "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+ "dev": true,
+ "requires": {
+ "p-locate": "^2.0.0",
+ "path-exists": "^3.0.0"
+ }
+ },
+ "p-limit": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
+ "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
+ "dev": true,
+ "requires": {
+ "p-try": "^1.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+ "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+ "dev": true,
+ "requires": {
+ "p-limit": "^1.1.0"
+ }
+ },
+ "p-try": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
+ "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
+ "dev": true
+ }
+ }
+ },
+ "flat": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+ "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="
+ },
+ "flat-cache": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+ "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+ "dev": true,
+ "requires": {
+ "flatted": "^3.1.0",
+ "rimraf": "^3.0.2"
+ }
+ },
+ "flatted": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+ "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
+ "dev": true
+ },
+ "fn.name": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
+ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
+ },
+ "focus-trap": {
+ "version": "7.5.2",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
+ "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
+ "requires": {
+ "tabbable": "^6.2.0"
+ }
+ },
+ "focus-trap-react": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
+ "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
+ "requires": {
+ "focus-trap": "^7.5.2",
+ "tabbable": "^6.2.0"
+ }
+ },
+ "follow-redirects": {
+ "version": "1.15.6",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
+ "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA=="
+ },
+ "form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "requires": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ }
+ },
+ "fraction.js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz",
+ "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA=="
+ },
+ "fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+ "dev": true
+ },
+ "function-bind": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+ },
+ "functional-red-black-tree": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+ "dev": true
+ },
+ "functions-have-names": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
+ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
+ "dev": true
+ },
+ "get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true
+ },
+ "get-intrinsic": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+ "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.1"
+ }
+ },
+ "get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="
+ },
+ "get-own-enumerable-property-symbols": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
+ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
+ "dev": true
+ },
+ "get-stream": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz",
+ "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==",
+ "dev": true,
+ "requires": {
+ "pump": "^3.0.0"
+ }
+ },
+ "get-symbol-description": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+ "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "get-intrinsic": "^1.1.1"
+ }
+ },
+ "glob": {
+ "version": "7.1.7",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+ "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+ "dev": true,
+ "requires": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.0.4",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ }
+ },
+ "glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "requires": {
+ "is-glob": "^4.0.1"
+ }
+ },
+ "globals": {
+ "version": "13.13.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz",
+ "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==",
+ "dev": true,
+ "requires": {
+ "type-fest": "^0.20.2"
+ },
+ "dependencies": {
+ "type-fest": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+ "dev": true
+ }
+ }
+ },
+ "hark": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/hark/-/hark-1.2.3.tgz",
+ "integrity": "sha512-u68vz9SCa38ESiFJSDjqK8XbXqWzyot7Cj6Y2b6jk2NJ+II3MY2dIrLMg/kjtIAun4Y1DHF/20hfx4rq1G5GMg==",
+ "requires": {
+ "wildemitter": "^1.2.0"
+ }
+ },
+ "has": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+ "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+ "requires": {
+ "function-bind": "^1.1.1"
+ }
+ },
+ "has-bigints": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+ "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+ },
+ "has-symbols": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
+ "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==",
+ "dev": true
+ },
+ "has-tostringtag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+ "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+ "dev": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "requires": {
+ "react-is": "^16.7.0"
+ }
+ },
+ "hosted-git-info": {
+ "version": "2.8.9",
+ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+ "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+ "dev": true
+ },
+ "hotkeys-js": {
+ "version": "3.9.4",
+ "resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.9.4.tgz",
+ "integrity": "sha512-2zuLt85Ta+gIyvs4N88pCYskNrxf1TFv3LR9t5mdAZIX8BcgQQ48F2opUptvHa6m8zsy5v/a0i9mWzTrlNWU0Q=="
+ },
+ "html-to-image": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.9.0.tgz",
+ "integrity": "sha512-9gaDCIYg62Ek07F2pBk76AHgYZ2gxq2YALU7rK3gNCqXuhu6cWzsOQqM7qGbjZiOzxGzrU1deDqZpAod2NEwbA=="
+ },
+ "htmlparser2": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
+ "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
+ "requires": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1",
+ "entities": "^4.4.0"
+ }
+ },
+ "human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true
+ },
+ "husky": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/husky/-/husky-1.3.1.tgz",
+ "integrity": "sha512-86U6sVVVf4b5NYSZ0yvv88dRgBSSXXmHaiq5pP4KDj5JVzdwKgBjEtUPOm8hcoytezFwbU+7gotXNhpHdystlg==",
+ "dev": true,
+ "requires": {
+ "cosmiconfig": "^5.0.7",
+ "execa": "^1.0.0",
+ "find-up": "^3.0.0",
+ "get-stdin": "^6.0.0",
+ "is-ci": "^2.0.0",
+ "pkg-dir": "^3.0.0",
+ "please-upgrade-node": "^3.1.1",
+ "read-pkg": "^4.0.1",
+ "run-node": "^1.0.0",
+ "slash": "^2.0.0"
+ },
+ "dependencies": {
+ "find-up": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz",
+ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==",
+ "dev": true,
+ "requires": {
+ "locate-path": "^3.0.0"
+ }
+ },
+ "get-stdin": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz",
+ "integrity": "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==",
+ "dev": true
+ },
+ "parse-json": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz",
+ "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=",
+ "dev": true,
+ "requires": {
+ "error-ex": "^1.3.1",
+ "json-parse-better-errors": "^1.0.1"
+ }
+ },
+ "pify": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
+ "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
+ "dev": true
+ },
+ "pkg-dir": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz",
+ "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==",
+ "dev": true,
+ "requires": {
+ "find-up": "^3.0.0"
+ }
+ },
+ "read-pkg": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-4.0.1.tgz",
+ "integrity": "sha1-ljYlN48+HE1IyFhytabsfV0JMjc=",
+ "dev": true,
+ "requires": {
+ "normalize-package-data": "^2.3.2",
+ "parse-json": "^4.0.0",
+ "pify": "^3.0.0"
+ }
+ }
+ }
+ },
+ "iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "requires": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ }
+ },
+ "idb-keyval": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.0.tgz",
+ "integrity": "sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng==",
+ "requires": {
+ "safari-14-idb-fix": "^3.0.0"
+ }
+ },
+ "ignore": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+ "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+ "dev": true
+ },
+ "immutability-helper": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-2.8.1.tgz",
+ "integrity": "sha512-8AVB5EUpRBUdXqfe4cFsFECsOIZ9hX/Arl8B8S9/tmwpYv3UWvOsXUPOjkuZIMaVxfSWkxCzkng1rjmEoSWrxQ==",
+ "requires": {
+ "invariant": "^2.2.0"
+ }
+ },
+ "import-fresh": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+ "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+ "requires": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ }
+ },
+ "imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+ "dev": true
+ },
+ "inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+ "dev": true,
+ "requires": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+ },
+ "internal-slot": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+ "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+ "dev": true,
+ "requires": {
+ "get-intrinsic": "^1.1.0",
+ "has": "^1.0.3",
+ "side-channel": "^1.0.4"
+ }
+ },
+ "intl-messageformat": {
+ "version": "10.1.4",
+ "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.1.4.tgz",
+ "integrity": "sha512-tXCmWCXhbeHOF28aIf5b9ce3kwdwGyIiiSXVZsyDwksMiGn5Tp0MrMvyeuHuz4uN1UL+NfGOztHmE+6aLFp1wQ==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "@formatjs/fast-memoize": "1.2.6",
+ "@formatjs/icu-messageformat-parser": "2.1.7",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "requires": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
+ },
+ "is-bigint": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.2.tgz",
+ "integrity": "sha512-0JV5+SOCQkIdzjBK9buARcV804Ddu7A0Qet6sHi3FimE9ne6m4BGQZfRn+NZiXbBk4F4XmHfDZIipLj9pX8dSA==",
+ "dev": true
+ },
+ "is-boolean-object": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.1.tgz",
+ "integrity": "sha512-bXdQWkECBUIAcCkeH1unwJLIpZYaa5VvuygSyS/c2lf719mTKZDU5UdDRlpd01UjADgmW8RfqaP+mRaVPdr/Ng==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "is-callable": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz",
+ "integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==",
+ "dev": true
+ },
+ "is-ci": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
+ "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==",
+ "dev": true,
+ "requires": {
+ "ci-info": "^2.0.0"
+ }
+ },
+ "is-core-module": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.4.0.tgz",
+ "integrity": "sha512-6A2fkfq1rfeQZjxrZJGerpLCTHRNEBiSgnu0+obeJpEPZRUooHgsizvzv0ZjJwOz3iWIHdJtVWJ/tmPr3D21/A==",
+ "requires": {
+ "has": "^1.0.3"
+ }
+ },
+ "is-date-object": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.4.tgz",
+ "integrity": "sha512-/b4ZVsG7Z5XVtIxs/h9W8nvfLgSAyKYdtGWQLbqy6jA1icmgjf8WCoTKgeS4wy5tYaPePouzFMANbnj94c2Z+A==",
+ "dev": true
+ },
+ "is-directory": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz",
+ "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=",
+ "dev": true
+ },
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+ "dev": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "is-negative-zero": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz",
+ "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==",
+ "dev": true
+ },
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true
+ },
+ "is-number-object": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.5.tgz",
+ "integrity": "sha512-RU0lI/n95pMoUKu9v1BZP5MBcZuNSVJkMkAG2dJqC4z2GlkGUNeH68SuHuBKBD/XFe+LHZ+f9BKkLET60Niedw==",
+ "dev": true
+ },
+ "is-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+ "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=",
+ "dev": true
+ },
+ "is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="
+ },
+ "is-regex": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz",
+ "integrity": "sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "is-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+ "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=",
+ "dev": true
+ },
+ "is-shared-array-buffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz",
+ "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "is-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz",
+ "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw=="
+ },
+ "is-string": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.6.tgz",
+ "integrity": "sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==",
+ "dev": true
+ },
+ "is-symbol": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+ "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+ "dev": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "is-weakref": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+ "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2"
+ }
+ },
+ "isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+ "dev": true
+ },
+ "js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
+ "js-yaml": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+ "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "dev": true,
+ "requires": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ }
+ },
+ "jsesc": {
+ "version": "2.5.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
+ },
+ "json-parse-better-errors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+ "dev": true
+ },
+ "json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
+ },
+ "json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true
+ },
+ "json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+ "dev": true
+ },
+ "json5": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
+ "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
+ "dev": true,
+ "requires": {
+ "minimist": "^1.2.0"
+ }
+ },
+ "jsx-ast-utils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.2.tgz",
+ "integrity": "sha512-HDAyJ4MNQBboGpUnHAVUNJs6X0lh058s6FuixsFGP7MgJYpD6Vasd6nzSG5iIfXu1zAYlHJ/zsOKNlrenTUBnw==",
+ "dev": true,
+ "requires": {
+ "array-includes": "^3.1.4",
+ "object.assign": "^4.1.2"
+ }
+ },
+ "kuler": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
+ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
+ },
+ "langmap": {
+ "version": "0.0.16",
+ "resolved": "https://registry.npmjs.org/langmap/-/langmap-0.0.16.tgz",
+ "integrity": "sha512-AtYvBK7BsDvWwnSfmO7CfgeUy7GUT1wK3QX8eKH/Ey/eXodqoHuAtvdQ82hmWD9QVFVKnuiNjym9fGY4qSJeLA=="
+ },
+ "language-subtag-registry": {
+ "version": "0.3.21",
+ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz",
+ "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==",
+ "dev": true
+ },
+ "language-tags": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz",
+ "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==",
+ "dev": true,
+ "requires": {
+ "language-subtag-registry": "~0.3.2"
+ }
+ },
+ "levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ }
+ },
+ "line-height": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/line-height/-/line-height-0.3.1.tgz",
+ "integrity": "sha1-SxIF7d4YKHKl76PI9iCzGHqcVMk=",
+ "requires": {
+ "computed-style": "~0.1.3"
+ }
+ },
+ "lines-and-columns": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
+ "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="
+ },
+ "lint-staged": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-11.2.0.tgz",
+ "integrity": "sha512-0KIcRuO4HQS2Su7qWtjrfTXgSklvyIb9Fk9qVWRZkGHa5S81Vj6WBbs+ogQBvHUwLJYq1eQ4R+H82GSak4OM7w==",
+ "dev": true,
+ "requires": {
+ "cli-truncate": "2.1.0",
+ "colorette": "^1.4.0",
+ "commander": "^8.2.0",
+ "cosmiconfig": "^7.0.1",
+ "debug": "^4.3.2",
+ "enquirer": "^2.3.6",
+ "execa": "^5.1.1",
+ "listr2": "^3.12.2",
+ "micromatch": "^4.0.4",
+ "normalize-path": "^3.0.0",
+ "please-upgrade-node": "^3.2.0",
+ "string-argv": "0.3.1",
+ "stringify-object": "3.3.0",
+ "supports-color": "8.1.1"
+ },
+ "dependencies": {
+ "colorette": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+ "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+ "dev": true
+ },
+ "cosmiconfig": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+ "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+ "dev": true,
+ "requires": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "dependencies": {
+ "yaml": {
+ "version": "1.10.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+ "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+ "dev": true
+ }
+ }
+ },
+ "debug": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.3.tgz",
+ "integrity": "sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==",
+ "dev": true,
+ "requires": {
+ "ms": "2.1.2"
+ }
+ },
+ "execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "requires": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ }
+ },
+ "get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "ms": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "dev": true
+ },
+ "npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "requires": {
+ "path-key": "^3.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "listr2": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
+ "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==",
+ "dev": true,
+ "requires": {
+ "cli-truncate": "^2.1.0",
+ "colorette": "^2.0.16",
+ "log-update": "^4.0.0",
+ "p-map": "^4.0.0",
+ "rfdc": "^1.3.0",
+ "rxjs": "^7.5.1",
+ "through": "^2.3.8",
+ "wrap-ansi": "^7.0.0"
+ },
+ "dependencies": {
+ "colorette": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.16.tgz",
+ "integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==",
+ "dev": true
+ }
+ }
+ },
+ "load-script": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
+ "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA=="
+ },
+ "locate-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz",
+ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==",
+ "dev": true,
+ "requires": {
+ "p-locate": "^3.0.0",
+ "path-exists": "^3.0.0"
+ },
+ "dependencies": {
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+ "dev": true
+ }
+ }
+ },
+ "lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+ },
+ "lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
+ },
+ "lodash.truncate": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+ "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+ "dev": true
+ },
+ "log-update": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
+ "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
+ "dev": true,
+ "requires": {
+ "ansi-escapes": "^4.3.0",
+ "cli-cursor": "^3.1.0",
+ "slice-ansi": "^4.0.0",
+ "wrap-ansi": "^6.2.0"
+ },
+ "dependencies": {
+ "wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ }
+ }
+ },
+ "logform": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz",
+ "integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==",
+ "requires": {
+ "@colors/colors": "1.5.0",
+ "fecha": "^4.2.0",
+ "ms": "^2.1.1",
+ "safe-stable-stringify": "^2.3.1",
+ "triple-beam": "^1.3.0"
+ },
+ "dependencies": {
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ }
+ }
+ },
+ "loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "requires": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ }
+ },
+ "makeup-screenreader-trap": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/makeup-screenreader-trap/-/makeup-screenreader-trap-0.0.5.tgz",
+ "integrity": "sha512-I2rb0Prijbz3eyko3sZY10tjF5ZLUol2mpbRbl/XJCLUybMZCr5c0iKg4N+pGsCONPWITvcfIS/qtEGQUOYUmQ==",
+ "requires": {
+ "custom-event-polyfill": "~0.3"
+ }
+ },
+ "memoize-one": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
+ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
+ },
+ "merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true
+ },
+ "meteor-node-stubs": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/meteor-node-stubs/-/meteor-node-stubs-1.2.9.tgz",
+ "integrity": "sha512-EKRezc1/PblYtYiK4BOT3h5geWDo9AFBBSYNamPNh8AC5msUbVCcg8kekzAa7r7JPzBX8nZWaXEQVar4t8q/Hg==",
+ "requires": {
+ "@meteorjs/crypto-browserify": "^3.12.1",
+ "assert": "^2.1.0",
+ "browserify-zlib": "^0.2.0",
+ "buffer": "^5.7.1",
+ "console-browserify": "^1.2.0",
+ "constants-browserify": "^1.0.0",
+ "domain-browser": "^4.23.0",
+ "elliptic": "^6.5.4",
+ "events": "^3.3.0",
+ "https-browserify": "^1.0.0",
+ "os-browserify": "^0.3.0",
+ "path-browserify": "^1.0.1",
+ "process": "^0.11.10",
+ "punycode": "^1.4.1",
+ "querystring-es3": "^0.2.1",
+ "readable-stream": "^3.6.2",
+ "stream-browserify": "^3.0.0",
+ "stream-http": "^3.2.0",
+ "string_decoder": "^1.3.0",
+ "timers-browserify": "^2.0.12",
+ "tty-browserify": "0.0.1",
+ "url": "^0.11.3",
+ "util": "^0.12.5",
+ "vm-browserify": "^1.1.2"
+ },
+ "dependencies": {
+ "@meteorjs/crypto-browserify": {
+ "version": "3.12.1",
+ "bundled": true,
+ "requires": {
+ "browserify-cipher": "^1.0.1",
+ "browserify-sign": "^4.2.3",
+ "create-ecdh": "^4.0.4",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "diffie-hellman": "^5.0.3",
+ "hash-base": "~3.0.4",
+ "inherits": "^2.0.4",
+ "pbkdf2": "^3.1.2",
+ "public-encrypt": "^4.0.3",
+ "randombytes": "^2.1.0",
+ "randomfill": "^1.0.4"
+ },
+ "dependencies": {
+ "hash-base": {
+ "version": "3.0.4",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ }
+ }
+ },
+ "asn1.js": {
+ "version": "4.10.1",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "bundled": true
+ }
+ }
+ },
+ "assert": {
+ "version": "2.1.0",
+ "bundled": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "is-nan": "^1.3.2",
+ "object-is": "^1.1.5",
+ "object.assign": "^4.1.4",
+ "util": "^0.12.5"
+ }
+ },
+ "available-typed-arrays": {
+ "version": "1.0.5",
+ "bundled": true
+ },
+ "base64-js": {
+ "version": "1.5.1",
+ "bundled": true
+ },
+ "bn.js": {
+ "version": "5.2.0",
+ "bundled": true
+ },
+ "brorand": {
+ "version": "1.1.0",
+ "bundled": true
+ },
+ "browserify-aes": {
+ "version": "1.2.0",
+ "bundled": true,
+ "requires": {
+ "buffer-xor": "^1.0.3",
+ "cipher-base": "^1.0.0",
+ "create-hash": "^1.1.0",
+ "evp_bytestokey": "^1.0.3",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "browserify-cipher": {
+ "version": "1.0.1",
+ "bundled": true,
+ "requires": {
+ "browserify-aes": "^1.0.4",
+ "browserify-des": "^1.0.0",
+ "evp_bytestokey": "^1.0.0"
+ }
+ },
+ "browserify-des": {
+ "version": "1.0.2",
+ "bundled": true,
+ "requires": {
+ "cipher-base": "^1.0.1",
+ "des.js": "^1.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "browserify-rsa": {
+ "version": "4.1.0",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^5.0.0",
+ "randombytes": "^2.0.1"
+ }
+ },
+ "browserify-sign": {
+ "version": "4.2.3",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^5.2.1",
+ "browserify-rsa": "^4.1.0",
+ "create-hash": "^1.2.0",
+ "create-hmac": "^1.1.7",
+ "elliptic": "^6.5.5",
+ "hash-base": "~3.0",
+ "inherits": "^2.0.4",
+ "parse-asn1": "^5.1.7",
+ "readable-stream": "^2.3.8",
+ "safe-buffer": "^5.2.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "5.2.1",
+ "bundled": true
+ },
+ "hash-base": {
+ "version": "3.0.4",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "readable-stream": {
+ "version": "2.3.8",
+ "bundled": true,
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.1.2",
+ "bundled": true
+ }
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "bundled": true,
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ },
+ "dependencies": {
+ "safe-buffer": {
+ "version": "5.1.2",
+ "bundled": true
+ }
+ }
+ }
+ }
+ },
+ "browserify-zlib": {
+ "version": "0.2.0",
+ "bundled": true,
+ "requires": {
+ "pako": "~1.0.5"
+ }
+ },
+ "buffer": {
+ "version": "5.7.1",
+ "bundled": true,
+ "requires": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "buffer-xor": {
+ "version": "1.0.3",
+ "bundled": true
+ },
+ "builtin-status-codes": {
+ "version": "3.0.0",
+ "bundled": true
+ },
+ "call-bind": {
+ "version": "1.0.5",
+ "bundled": true,
+ "requires": {
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.1",
+ "set-function-length": "^1.1.1"
+ }
+ },
+ "cipher-base": {
+ "version": "1.0.4",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "console-browserify": {
+ "version": "1.2.0",
+ "bundled": true
+ },
+ "constants-browserify": {
+ "version": "1.0.0",
+ "bundled": true
+ },
+ "core-util-is": {
+ "version": "1.0.3",
+ "bundled": true
+ },
+ "create-ecdh": {
+ "version": "4.0.4",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "elliptic": "^6.5.3"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "bundled": true
+ }
+ }
+ },
+ "create-hash": {
+ "version": "1.2.0",
+ "bundled": true,
+ "requires": {
+ "cipher-base": "^1.0.1",
+ "inherits": "^2.0.1",
+ "md5.js": "^1.3.4",
+ "ripemd160": "^2.0.1",
+ "sha.js": "^2.4.0"
+ }
+ },
+ "create-hmac": {
+ "version": "1.1.7",
+ "bundled": true,
+ "requires": {
+ "cipher-base": "^1.0.3",
+ "create-hash": "^1.1.0",
+ "inherits": "^2.0.1",
+ "ripemd160": "^2.0.0",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "define-data-property": {
+ "version": "1.1.1",
+ "bundled": true,
+ "requires": {
+ "get-intrinsic": "^1.2.1",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.0"
+ }
+ },
+ "define-properties": {
+ "version": "1.2.1",
+ "bundled": true,
+ "requires": {
+ "define-data-property": "^1.0.1",
+ "has-property-descriptors": "^1.0.0",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "des.js": {
+ "version": "1.0.1",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0"
+ }
+ },
+ "diffie-hellman": {
+ "version": "5.0.3",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "miller-rabin": "^4.0.0",
+ "randombytes": "^2.0.0"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "bundled": true
+ }
+ }
+ },
+ "domain-browser": {
+ "version": "4.23.0",
+ "bundled": true
+ },
+ "elliptic": {
+ "version": "6.5.5",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^4.11.9",
+ "brorand": "^1.1.0",
+ "hash.js": "^1.0.0",
+ "hmac-drbg": "^1.0.1",
+ "inherits": "^2.0.4",
+ "minimalistic-assert": "^1.0.1",
+ "minimalistic-crypto-utils": "^1.0.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "bundled": true
+ }
+ }
+ },
+ "events": {
+ "version": "3.3.0",
+ "bundled": true
+ },
+ "evp_bytestokey": {
+ "version": "1.0.3",
+ "bundled": true,
+ "requires": {
+ "md5.js": "^1.3.4",
+ "safe-buffer": "^5.1.1"
+ }
+ },
+ "for-each": {
+ "version": "0.3.3",
+ "bundled": true,
+ "requires": {
+ "is-callable": "^1.1.3"
+ }
+ },
+ "function-bind": {
+ "version": "1.1.2",
+ "bundled": true
+ },
+ "get-intrinsic": {
+ "version": "1.2.2",
+ "bundled": true,
+ "requires": {
+ "function-bind": "^1.1.2",
+ "has-proto": "^1.0.1",
+ "has-symbols": "^1.0.3",
+ "hasown": "^2.0.0"
+ }
+ },
+ "gopd": {
+ "version": "1.0.1",
+ "bundled": true,
+ "requires": {
+ "get-intrinsic": "^1.1.3"
+ }
+ },
+ "has-property-descriptors": {
+ "version": "1.0.1",
+ "bundled": true,
+ "requires": {
+ "get-intrinsic": "^1.2.2"
+ }
+ },
+ "has-proto": {
+ "version": "1.0.1",
+ "bundled": true
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "bundled": true
+ },
+ "has-tostringtag": {
+ "version": "1.0.0",
+ "bundled": true,
+ "requires": {
+ "has-symbols": "^1.0.2"
+ }
+ },
+ "hash-base": {
+ "version": "3.1.0",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "safe-buffer": "^5.2.0"
+ }
+ },
+ "hash.js": {
+ "version": "1.1.7",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "hasown": {
+ "version": "2.0.0",
+ "bundled": true,
+ "requires": {
+ "function-bind": "^1.1.2"
+ }
+ },
+ "hmac-drbg": {
+ "version": "1.0.1",
+ "bundled": true,
+ "requires": {
+ "hash.js": "^1.0.3",
+ "minimalistic-assert": "^1.0.0",
+ "minimalistic-crypto-utils": "^1.0.1"
+ }
+ },
+ "https-browserify": {
+ "version": "1.0.0",
+ "bundled": true
+ },
+ "ieee754": {
+ "version": "1.2.1",
+ "bundled": true
+ },
+ "inherits": {
+ "version": "2.0.4",
+ "bundled": true
+ },
+ "is-arguments": {
+ "version": "1.1.1",
+ "bundled": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-callable": {
+ "version": "1.2.7",
+ "bundled": true
+ },
+ "is-generator-function": {
+ "version": "1.0.10",
+ "bundled": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-nan": {
+ "version": "1.3.2",
+ "bundled": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "define-properties": "^1.1.3"
+ }
+ },
+ "is-typed-array": {
+ "version": "1.1.12",
+ "bundled": true,
+ "requires": {
+ "which-typed-array": "^1.1.11"
+ }
+ },
+ "isarray": {
+ "version": "1.0.0",
+ "bundled": true
+ },
+ "md5.js": {
+ "version": "1.3.5",
+ "bundled": true,
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ }
+ },
+ "miller-rabin": {
+ "version": "4.0.1",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^4.0.0",
+ "brorand": "^1.0.1"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "bundled": true
+ }
+ }
+ },
+ "minimalistic-assert": {
+ "version": "1.0.1",
+ "bundled": true
+ },
+ "minimalistic-crypto-utils": {
+ "version": "1.0.1",
+ "bundled": true
+ },
+ "object-inspect": {
+ "version": "1.13.1",
+ "bundled": true
+ },
+ "object-is": {
+ "version": "1.1.5",
+ "bundled": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3"
+ }
+ },
+ "object-keys": {
+ "version": "1.1.1",
+ "bundled": true
+ },
+ "object.assign": {
+ "version": "4.1.4",
+ "bundled": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.4",
+ "has-symbols": "^1.0.3",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "os-browserify": {
+ "version": "0.3.0",
+ "bundled": true
+ },
+ "pako": {
+ "version": "1.0.11",
+ "bundled": true
+ },
+ "parse-asn1": {
+ "version": "5.1.7",
+ "bundled": true,
+ "requires": {
+ "asn1.js": "^4.10.1",
+ "browserify-aes": "^1.2.0",
+ "evp_bytestokey": "^1.0.3",
+ "hash-base": "~3.0",
+ "pbkdf2": "^3.1.2",
+ "safe-buffer": "^5.2.1"
+ },
+ "dependencies": {
+ "hash-base": {
+ "version": "3.0.4",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ }
+ }
+ },
+ "path-browserify": {
+ "version": "1.0.1",
+ "bundled": true
+ },
+ "pbkdf2": {
+ "version": "3.1.2",
+ "bundled": true,
+ "requires": {
+ "create-hash": "^1.1.2",
+ "create-hmac": "^1.1.4",
+ "ripemd160": "^2.0.1",
+ "safe-buffer": "^5.0.1",
+ "sha.js": "^2.4.8"
+ }
+ },
+ "process": {
+ "version": "0.11.10",
+ "bundled": true
+ },
+ "process-nextick-args": {
+ "version": "2.0.1",
+ "bundled": true
+ },
+ "public-encrypt": {
+ "version": "4.0.3",
+ "bundled": true,
+ "requires": {
+ "bn.js": "^4.1.0",
+ "browserify-rsa": "^4.0.0",
+ "create-hash": "^1.1.0",
+ "parse-asn1": "^5.0.0",
+ "randombytes": "^2.0.1",
+ "safe-buffer": "^5.1.2"
+ },
+ "dependencies": {
+ "bn.js": {
+ "version": "4.12.0",
+ "bundled": true
+ }
+ }
+ },
+ "punycode": {
+ "version": "1.4.1",
+ "bundled": true
+ },
+ "qs": {
+ "version": "6.11.2",
+ "bundled": true,
+ "requires": {
+ "side-channel": "^1.0.4"
+ }
+ },
+ "querystring-es3": {
+ "version": "0.2.1",
+ "bundled": true
+ },
+ "randombytes": {
+ "version": "2.1.0",
+ "bundled": true,
+ "requires": {
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "randomfill": {
+ "version": "1.0.4",
+ "bundled": true,
+ "requires": {
+ "randombytes": "^2.0.5",
+ "safe-buffer": "^5.1.0"
+ }
+ },
+ "readable-stream": {
+ "version": "3.6.2",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ },
+ "ripemd160": {
+ "version": "2.0.2",
+ "bundled": true,
+ "requires": {
+ "hash-base": "^3.0.0",
+ "inherits": "^2.0.1"
+ }
+ },
+ "safe-buffer": {
+ "version": "5.2.1",
+ "bundled": true
+ },
+ "set-function-length": {
+ "version": "1.1.1",
+ "bundled": true,
+ "requires": {
+ "define-data-property": "^1.1.1",
+ "get-intrinsic": "^1.2.1",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.0"
+ }
+ },
+ "setimmediate": {
+ "version": "1.0.5",
+ "bundled": true
+ },
+ "sha.js": {
+ "version": "2.4.11",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "side-channel": {
+ "version": "1.0.4",
+ "bundled": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ }
+ },
+ "stream-browserify": {
+ "version": "3.0.0",
+ "bundled": true,
+ "requires": {
+ "inherits": "~2.0.4",
+ "readable-stream": "^3.5.0"
+ }
+ },
+ "stream-http": {
+ "version": "3.2.0",
+ "bundled": true,
+ "requires": {
+ "builtin-status-codes": "^3.0.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.6.0",
+ "xtend": "^4.0.2"
+ }
+ },
+ "string_decoder": {
+ "version": "1.3.0",
+ "bundled": true,
+ "requires": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "timers-browserify": {
+ "version": "2.0.12",
+ "bundled": true,
+ "requires": {
+ "setimmediate": "^1.0.4"
+ }
+ },
+ "tty-browserify": {
+ "version": "0.0.1",
+ "bundled": true
+ },
+ "url": {
+ "version": "0.11.3",
+ "bundled": true,
+ "requires": {
+ "punycode": "^1.4.1",
+ "qs": "^6.11.2"
+ }
+ },
+ "util": {
+ "version": "0.12.5",
+ "bundled": true,
+ "requires": {
+ "inherits": "^2.0.3",
+ "is-arguments": "^1.0.4",
+ "is-generator-function": "^1.0.7",
+ "is-typed-array": "^1.1.3",
+ "which-typed-array": "^1.1.2"
+ }
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "bundled": true
+ },
+ "vm-browserify": {
+ "version": "1.1.2",
+ "bundled": true
+ },
+ "which-typed-array": {
+ "version": "1.1.13",
+ "bundled": true,
+ "requires": {
+ "available-typed-arrays": "^1.0.5",
+ "call-bind": "^1.0.4",
+ "for-each": "^0.3.3",
+ "gopd": "^1.0.1",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "xtend": {
+ "version": "4.0.2",
+ "bundled": true
+ }
+ }
+ },
+ "micromatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz",
+ "integrity": "sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==",
+ "dev": true,
+ "requires": {
+ "braces": "^3.0.1",
+ "picomatch": "^2.2.3"
+ }
+ },
+ "mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+ },
+ "mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "requires": {
+ "mime-db": "1.52.0"
+ }
+ },
+ "mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true
+ },
+ "minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^1.1.7"
+ }
+ },
+ "minimist": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+ "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
+ "dev": true
+ },
+ "mobx": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.4.2.tgz",
+ "integrity": "sha512-b4xQJYiH8sb0sEbfq/Ws3N77DEJtSihUFD1moeiz2jNoJ5B+mqJutt54ouO9iEfkp7Wk4jQDsVUOh7DPEW3wEw=="
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ },
+ "natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+ "dev": true
+ },
+ "needle": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
+ "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
+ "requires": {
+ "debug": "^3.2.6",
+ "iconv-lite": "^0.4.4",
+ "sax": "^1.2.4"
+ },
+ "dependencies": {
+ "debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "requires": {
+ "ms": "^2.1.1"
+ }
+ },
+ "ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ }
+ }
+ },
+ "nice-try": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
+ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==",
+ "dev": true
+ },
+ "node-releases": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.3.tgz",
+ "integrity": "sha512-maHFz6OLqYxz+VQyCAtA3PTX4UP/53pa05fyDNc9CwjvJ0yEh6+xBwKsgCxMNhS8taUKBFYxfuiaD9U/55iFaw=="
+ },
+ "normalize-package-data": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+ "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+ "dev": true,
+ "requires": {
+ "hosted-git-info": "^2.1.4",
+ "resolve": "^1.10.0",
+ "semver": "2 || 3 || 4 || 5",
+ "validate-npm-package-license": "^3.0.1"
+ }
+ },
+ "normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true
+ },
+ "normalize-range": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
+ "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="
+ },
+ "npm-run-path": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
+ "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=",
+ "dev": true,
+ "requires": {
+ "path-key": "^2.0.0"
+ },
+ "dependencies": {
+ "path-key": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz",
+ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=",
+ "dev": true
+ }
+ }
+ },
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
+ "object-inspect": {
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
+ "integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==",
+ "dev": true
+ },
+ "object-keys": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+ "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+ "dev": true
+ },
+ "object.assign": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+ "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "define-properties": "^1.1.3",
+ "has-symbols": "^1.0.1",
+ "object-keys": "^1.1.1"
+ }
+ },
+ "object.entries": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.4.tgz",
+ "integrity": "sha512-h4LWKWE+wKQGhtMjZEBud7uLGhqyLwj8fpHOarZhD2uY3C9cRtk57VQ89ke3moByLXMedqs3XCHzyb4AmA2DjA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.18.2"
+ }
+ },
+ "object.fromentries": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+ "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "object.hasown": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.0.tgz",
+ "integrity": "sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg==",
+ "dev": true,
+ "requires": {
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "object.values": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+ "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+ "dev": true,
+ "requires": {
+ "wrappy": "1"
+ }
+ },
+ "one-time": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
+ "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
+ "requires": {
+ "fn.name": "1.x.x"
+ }
+ },
+ "onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "requires": {
+ "mimic-fn": "^2.1.0"
+ }
+ },
+ "optionator": {
+ "version": "0.9.1",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+ "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+ "dev": true,
+ "requires": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.3"
+ }
+ },
+ "p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
+ "dev": true
+ },
+ "p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "requires": {
+ "p-try": "^2.0.0"
+ }
+ },
+ "p-locate": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz",
+ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==",
+ "dev": true,
+ "requires": {
+ "p-limit": "^2.0.0"
+ }
+ },
+ "p-map": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+ "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+ "dev": true,
+ "requires": {
+ "aggregate-error": "^3.0.0"
+ }
+ },
+ "p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true
+ },
+ "parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "requires": {
+ "callsites": "^3.0.0"
+ }
+ },
+ "parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "requires": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ }
+ },
+ "parse-srcset": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
+ "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
+ },
+ "path-exists": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+ "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+ "dev": true
+ },
+ "path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+ "dev": true
+ },
+ "path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true
+ },
+ "path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
+ },
+ "path-type": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
+ },
+ "pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true
+ },
+ "perfect-freehand": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.0.tgz",
+ "integrity": "sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw=="
+ },
+ "picocolors": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+ "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+ },
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
+ },
+ "please-upgrade-node": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz",
+ "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==",
+ "dev": true,
+ "requires": {
+ "semver-compare": "^1.0.0"
+ }
+ },
+ "postcss": {
+ "version": "8.4.40",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz",
+ "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==",
+ "requires": {
+ "nanoid": "^3.3.7",
+ "picocolors": "^1.0.1",
+ "source-map-js": "^1.2.0"
+ },
+ "dependencies": {
+ "nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
+ },
+ "picocolors": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
+ "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
+ },
+ "source-map-js": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg=="
+ }
+ }
+ },
+ "postcss-nested": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz",
+ "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==",
+ "requires": {
+ "postcss-selector-parser": "^6.0.6"
+ }
+ },
+ "postcss-selector-parser": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz",
+ "integrity": "sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg==",
+ "requires": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ }
+ },
+ "postcss-value-parser": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz",
+ "integrity": "sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ=="
+ },
+ "prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true
+ },
+ "probe-image-size": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz",
+ "integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==",
+ "requires": {
+ "lodash.merge": "^4.6.2",
+ "needle": "^2.5.2",
+ "stream-parser": "~0.3.1"
+ }
+ },
+ "progress": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+ "dev": true
+ },
+ "prom-client": {
+ "version": "13.2.0",
+ "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-13.2.0.tgz",
+ "integrity": "sha512-wGr5mlNNdRNzEhRYXgboUU2LxHWIojxscJKmtG3R8f4/KiWqyYgXTLHs0+Ted7tG3zFT7pgHJbtomzZ1L0ARaQ==",
+ "requires": {
+ "tdigest": "^0.1.1"
+ }
+ },
+ "prop-types": {
+ "version": "15.8.1",
+ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+ "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+ "requires": {
+ "loose-envify": "^1.4.0",
+ "object-assign": "^4.1.1",
+ "react-is": "^16.13.1"
+ }
+ },
+ "proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ },
+ "pump": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+ "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+ "dev": true,
+ "requires": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "punycode": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+ "dev": true
+ },
+ "queue": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
+ "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
+ "requires": {
+ "inherits": "~2.0.3"
+ }
+ },
+ "radash": {
+ "version": "10.8.1",
+ "resolved": "https://registry.npmjs.org/radash/-/radash-10.8.1.tgz",
+ "integrity": "sha512-NzYo3XgM9Tzjf5iFPIMG2l5+LSOCi2H7Axe3Ry/1PrhlvuqxUoiLsmcTBtw4CfKtzy5Fzo79STiEj9JZWMfDQg=="
+ },
+ "re-resizable": {
+ "version": "4.11.0",
+ "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-4.11.0.tgz",
+ "integrity": "sha512-dye+7rERqNf/6mDT1iwps+4Gf42420xuZgygF33uX178DxffqcyeuHbBuJ382FIcB5iP6mMZOhfW7kI0uXwb/Q=="
+ },
+ "react": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "react-autosize-textarea": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/react-autosize-textarea/-/react-autosize-textarea-5.0.1.tgz",
+ "integrity": "sha512-VHYFObl0XtI4xy2pEMqKDIFCOs1e4JEaDaq2WgwZAZdXrahspnzJAX+Zy4V9eKZIhY+lUKA2MlVZxtqexx/7YA==",
+ "requires": {
+ "autosize": "^4.0.2",
+ "line-height": "^0.3.1",
+ "prop-types": "^15.5.6"
+ }
+ },
+ "react-colorful": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
+ "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="
+ },
+ "react-dom": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.0"
+ },
+ "dependencies": {
+ "scheduler": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "requires": {
+ "loose-envify": "^1.1.0"
+ }
+ }
+ }
+ },
+ "react-draggable": {
+ "version": "4.4.5",
+ "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.5.tgz",
+ "integrity": "sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g==",
+ "requires": {
+ "clsx": "^1.1.1",
+ "prop-types": "^15.8.1"
+ }
+ },
+ "react-dropzone": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-7.0.1.tgz",
+ "integrity": "sha512-J4rbzhFZPVW7k7K9CVb0OcwSOJGLWa0y+0rvtB4rBLVkvq0agH/o3kPJ0DCkd6ZVzL2K1NFqIOvtQkwQKpmJBA==",
+ "requires": {
+ "attr-accept": "^1.1.3",
+ "prop-types": "^15.6.2"
+ }
+ },
+ "react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "requires": {
+ "@babel/runtime": "^7.12.5"
+ }
+ },
+ "react-fast-compare": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
+ "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
+ },
+ "react-hotkeys-hook": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-3.4.7.tgz",
+ "integrity": "sha512-+bbPmhPAl6ns9VkXkNNyxlmCAIyDAcWbB76O4I0ntr3uWCRuIQf/aRLartUahe9chVMPj+OEzzfk3CQSjclUEQ==",
+ "requires": {
+ "hotkeys-js": "3.9.4"
+ }
+ },
+ "react-intl": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.1.0.tgz",
+ "integrity": "sha512-aNy7wX/ZfKgpTv2x27E1sio50fnTrSWD96yfQdVfUfvZrIed6rVPccVxfAtLnIK/M9L1TGrckne3kwFj3Ipikg==",
+ "requires": {
+ "@formatjs/ecma402-abstract": "1.12.0",
+ "@formatjs/icu-messageformat-parser": "2.1.7",
+ "@formatjs/intl": "2.4.0",
+ "@formatjs/intl-displaynames": "6.1.2",
+ "@formatjs/intl-listformat": "7.1.2",
+ "@types/hoist-non-react-statics": "^3.3.1",
+ "@types/react": "16 || 17 || 18",
+ "hoist-non-react-statics": "^3.3.2",
+ "intl-messageformat": "10.1.4",
+ "tslib": "2.4.0"
+ },
+ "dependencies": {
+ "tslib": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz",
+ "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
+ }
+ }
+ },
+ "react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
+ "react-lifecycles-compat": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
+ "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
+ },
+ "react-loading-skeleton": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.0.3.tgz",
+ "integrity": "sha512-HPkEqQGwmbg1ImcYA9n4hDiLC3u92xUdU+sSfrv/9l3lNBChAubcl1azjV2WakapdkbA3gko+hPzZkZGIb/9xA=="
+ },
+ "react-modal": {
+ "version": "3.15.1",
+ "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.15.1.tgz",
+ "integrity": "sha512-duB9bxOaYg7Zt6TMFldIFxQRtSP+Dg3F1ZX3FXxSUn+3tZZ/9JCgeAQKDg7rhZSAqopq8TFRw3yIbnx77gyFTw==",
+ "requires": {
+ "exenv": "^1.2.0",
+ "prop-types": "^15.7.2",
+ "react-lifecycles-compat": "^3.0.0",
+ "warning": "^4.0.3"
+ }
+ },
+ "react-player": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/react-player/-/react-player-2.10.0.tgz",
+ "integrity": "sha512-PccIqea9nxSHAdai6R+Yj9lp6tb2lyXWbaF6YVHi5uO4FiXYMKKr9rMXJrivwV5vXwQa65rYKBmwebsBmRTT3w==",
+ "requires": {
+ "deepmerge": "^4.0.0",
+ "load-script": "^1.0.0",
+ "memoize-one": "^5.1.1",
+ "prop-types": "^15.7.2",
+ "react-fast-compare": "^3.0.1"
+ }
+ },
+ "react-remove-scroll": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz",
+ "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==",
+ "requires": {
+ "react-remove-scroll-bar": "^2.3.3",
+ "react-style-singleton": "^2.2.1",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.0",
+ "use-sidecar": "^1.1.2"
+ }
+ },
+ "react-remove-scroll-bar": {
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz",
+ "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==",
+ "requires": {
+ "react-style-singleton": "^2.2.1",
+ "tslib": "^2.0.0"
+ }
+ },
+ "react-style-singleton": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
+ "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==",
+ "requires": {
+ "get-nonce": "^1.0.0",
+ "invariant": "^2.2.4",
+ "tslib": "^2.0.0"
+ }
+ },
+ "react-tabs": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-4.2.1.tgz",
+ "integrity": "sha512-nQcEN3KrAsSry6f9Jz2oyMQsnh+sLEy31YjlskL/mnI3KU/c7BeyD1VzHZmmcJ15UEFu12pYOXYkdTzZ0uyIbw==",
+ "requires": {
+ "clsx": "^1.1.0",
+ "prop-types": "^15.5.0"
+ }
+ },
+ "react-tether": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/react-tether/-/react-tether-2.0.8.tgz",
+ "integrity": "sha512-k5imIcN7shOy0z59WKLljvBwmGqzAcqsjHtBk8h03F3aFn+Wdz4p56ecOLCqvBb/gpQp9WEFv+Saclro6GZQCQ==",
+ "requires": {
+ "prop-types": "^15.6.2",
+ "tether": "^1.4.5"
+ }
+ },
+ "react-toastify": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-4.5.2.tgz",
+ "integrity": "sha512-KymDDhkcX5EvFht17nO0MCsegM/Kdhyfxhi+WQl2tE3IxJrueOhY6TUnALTfvz7eDRUjPYBGb+ywWqWrGyvBnw==",
+ "requires": {
+ "classnames": "^2.2.6",
+ "prop-types": "^15.6.0",
+ "react-transition-group": "^2.4.0"
+ }
+ },
+ "react-toggle": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/react-toggle/-/react-toggle-4.1.2.tgz",
+ "integrity": "sha512-4Ohw31TuYQdhWfA6qlKafeXx3IOH7t4ZHhmRdwsm1fQREwOBGxJT+I22sgHqR/w8JRdk+AeMCJXPImEFSrNXow==",
+ "requires": {
+ "classnames": "^2.2.5"
+ }
+ },
+ "react-transition-group": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz",
+ "integrity": "sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==",
+ "requires": {
+ "dom-helpers": "^3.4.0",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.6.2",
+ "react-lifecycles-compat": "^3.0.4"
+ }
+ },
+ "react-virtualized": {
+ "version": "9.22.4",
+ "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.22.4.tgz",
+ "integrity": "sha512-HYOQEK1OWWYKt8C3KnjbEOST224kv5D8USPSdo/huAamDttWzQLTtoi/IvWfqwFOr9cCnwUYzqOTStwPrdkqqQ==",
+ "requires": {
+ "@babel/runtime": "^7.7.2",
+ "clsx": "^1.0.4",
+ "dom-helpers": "^5.1.3",
+ "loose-envify": "^1.4.0",
+ "prop-types": "^15.7.2",
+ "react-lifecycles-compat": "^3.0.4"
+ },
+ "dependencies": {
+ "dom-helpers": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+ "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+ "requires": {
+ "@babel/runtime": "^7.8.7",
+ "csstype": "^3.0.2"
+ }
+ }
+ }
+ },
+ "reconnecting-websocket": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
+ "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
+ },
+ "redis": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz",
+ "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==",
+ "requires": {
+ "denque": "^1.5.0",
+ "redis-commands": "^1.7.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0"
+ }
+ },
+ "redis-commands": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
+ "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
+ },
+ "redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
+ },
+ "redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
+ "requires": {
+ "redis-errors": "^1.0.0"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
+ },
+ "regexp.prototype.flags": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz",
+ "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "functions-have-names": "^1.2.2"
+ }
+ },
+ "regexpp": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+ "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+ "dev": true
+ },
+ "require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true
+ },
+ "resolve": {
+ "version": "1.20.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
+ "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
+ "requires": {
+ "is-core-module": "^2.2.0",
+ "path-parse": "^1.0.6"
+ }
+ },
+ "resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
+ },
+ "restore-cursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+ "dev": true,
+ "requires": {
+ "onetime": "^5.1.0",
+ "signal-exit": "^3.0.2"
+ }
+ },
+ "rfdc": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.0.tgz",
+ "integrity": "sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==",
+ "dev": true
+ },
+ "rimraf": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+ "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+ "dev": true,
+ "requires": {
+ "glob": "^7.1.3"
+ }
+ },
+ "run-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/run-node/-/run-node-1.0.0.tgz",
+ "integrity": "sha512-kc120TBlQ3mih1LSzdAJXo4xn/GWS2ec0l3S+syHDXP9uRr0JAT8Qd3mdMuyjqCzeZktgP3try92cEgf9Nks8A==",
+ "dev": true
+ },
+ "rxjs": {
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.4.tgz",
+ "integrity": "sha512-h5M3Hk78r6wAheJF0a5YahB1yRQKCsZ4MsGdZ5O9ETbVtjPcScGfrMmoOq7EBsCRzd4BDkvDJ7ogP8Sz5tTFiQ==",
+ "dev": true,
+ "requires": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "safari-14-idb-fix": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz",
+ "integrity": "sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog=="
+ },
+ "safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+ },
+ "safe-stable-stringify": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz",
+ "integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg=="
+ },
+ "safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "sanitize-html": {
+ "version": "2.12.1",
+ "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.12.1.tgz",
+ "integrity": "sha512-Plh+JAn0UVDpBRP/xEjsk+xDCoOvMBwQUf/K+/cBAVuTbtX8bj2VB7S1sL1dssVpykqp0/KPSesHrqXtokVBpA==",
+ "requires": {
+ "deepmerge": "^4.2.2",
+ "escape-string-regexp": "^4.0.0",
+ "htmlparser2": "^8.0.0",
+ "is-plain-object": "^5.0.0",
+ "parse-srcset": "^1.0.2",
+ "postcss": "^8.3.11"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+ }
+ }
+ },
+ "sax": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
+ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
+ },
+ "scheduler": {
+ "version": "0.20.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
+ "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+ "requires": {
+ "loose-envify": "^1.1.0",
+ "object-assign": "^4.1.1"
+ }
+ },
+ "sdp": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.0.3.tgz",
+ "integrity": "sha512-8EkfckS+XZQaPLyChu4ey7PghrdcraCVNpJe2Gfdi2ON1ylQ7OasuKX+b37R9slnRChwIAiQgt+oj8xXGD8x+A=="
+ },
+ "sdp-transform": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.7.0.tgz",
+ "integrity": "sha512-v8/zfKeDKBEXKMf4UT91owHoXM6O+s8gEVtCVSvrAd2eEtuv5u4TKcTXX4a9qrQM3T2Ycr9rLyU+Ww3ZiCiaGQ=="
+ },
+ "semver": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "dev": true
+ },
+ "semver-compare": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
+ "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
+ "dev": true
+ },
+ "sha1": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz",
+ "integrity": "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg=",
+ "dev": true,
+ "requires": {
+ "charenc": ">= 0.0.1",
+ "crypt": ">= 0.0.1"
+ }
+ },
+ "shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
+ },
+ "shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "requires": {
+ "shebang-regex": "^3.0.0"
+ }
+ },
+ "shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true
+ },
+ "side-channel": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+ "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.0",
+ "get-intrinsic": "^1.0.2",
+ "object-inspect": "^1.9.0"
+ }
+ },
+ "signal-exit": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
+ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
+ "dev": true
+ },
+ "simple-swizzle": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
+ "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
+ "requires": {
+ "is-arrayish": "^0.3.1"
+ },
+ "dependencies": {
+ "is-arrayish": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
+ "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
+ }
+ }
+ },
+ "slash": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
+ "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
+ "dev": true
+ },
+ "slice-ansi": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+ "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true
+ }
+ }
+ },
+ "smile2emoji": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/smile2emoji/-/smile2emoji-3.8.3.tgz",
+ "integrity": "sha512-j1UZSmk6V4cQ7F2M969tD31QoVKHbPyM1bEaWRcqMW+f7LcBAPppgzVrF1lGf9sga4I0+H5yCZfyfZ1LStrFGg=="
+ },
+ "source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="
+ },
+ "spdx-correct": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+ "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+ "dev": true,
+ "requires": {
+ "spdx-expression-parse": "^3.0.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "spdx-exceptions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+ "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+ "dev": true
+ },
+ "spdx-expression-parse": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+ "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+ "dev": true,
+ "requires": {
+ "spdx-exceptions": "^2.1.0",
+ "spdx-license-ids": "^3.0.0"
+ }
+ },
+ "spdx-license-ids": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz",
+ "integrity": "sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ==",
+ "dev": true
+ },
+ "sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+ "dev": true
+ },
+ "stack-trace": {
+ "version": "0.0.10",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
+ "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="
+ },
+ "stream-parser": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
+ "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==",
+ "requires": {
+ "debug": "2"
+ }
+ },
+ "strictdom": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz",
+ "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg=="
+ },
+ "string-argv": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz",
+ "integrity": "sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg==",
+ "dev": true
+ },
+ "string-hash": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz",
+ "integrity": "sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs="
+ },
+ "string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "requires": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ }
+ },
+ "string.prototype.matchall": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+ "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3",
+ "es-abstract": "^1.19.1",
+ "get-intrinsic": "^1.1.1",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "regexp.prototype.flags": "^1.4.1",
+ "side-channel": "^1.0.4"
+ },
+ "dependencies": {
+ "es-abstract": {
+ "version": "1.19.5",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.5.tgz",
+ "integrity": "sha512-Aa2G2+Rd3b6kxEUKTF4TaW67czBLyAv3z7VOhYRU50YBx+bbsYZ9xQP4lMNazePuFlybXI0V4MruPos7qUo5fA==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "es-to-primitive": "^1.2.1",
+ "function-bind": "^1.1.1",
+ "get-intrinsic": "^1.1.1",
+ "get-symbol-description": "^1.0.0",
+ "has": "^1.0.3",
+ "has-symbols": "^1.0.3",
+ "internal-slot": "^1.0.3",
+ "is-callable": "^1.2.4",
+ "is-negative-zero": "^2.0.2",
+ "is-regex": "^1.1.4",
+ "is-shared-array-buffer": "^1.0.2",
+ "is-string": "^1.0.7",
+ "is-weakref": "^1.0.2",
+ "object-inspect": "^1.12.0",
+ "object-keys": "^1.1.1",
+ "object.assign": "^4.1.2",
+ "string.prototype.trimend": "^1.0.4",
+ "string.prototype.trimstart": "^1.0.4",
+ "unbox-primitive": "^1.0.1"
+ }
+ },
+ "has-symbols": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+ "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+ "dev": true
+ },
+ "is-callable": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+ "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+ "dev": true
+ },
+ "is-negative-zero": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+ "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+ "dev": true
+ },
+ "is-regex": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+ "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "is-string": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+ "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+ "dev": true,
+ "requires": {
+ "has-tostringtag": "^1.0.0"
+ }
+ },
+ "object-inspect": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+ "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+ "dev": true
+ }
+ }
+ },
+ "string.prototype.trimend": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+ "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3"
+ }
+ },
+ "string.prototype.trimstart": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+ "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.2",
+ "define-properties": "^1.1.3"
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "stringify-object": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
+ "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
+ "dev": true,
+ "requires": {
+ "get-own-enumerable-property-symbols": "^3.0.0",
+ "is-obj": "^1.0.1",
+ "is-regexp": "^1.0.0"
+ }
+ },
+ "strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "requires": {
+ "ansi-regex": "^5.0.1"
+ }
+ },
+ "strip-bom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+ "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==",
+ "dev": true
+ },
+ "strip-eof": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz",
+ "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=",
+ "dev": true
+ },
+ "strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true
+ },
+ "strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true
+ },
+ "styled-components": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.3.tgz",
+ "integrity": "sha512-++4iHwBM7ZN+x6DtPPWkCI4vdtwumQ+inA/DdAsqYd4SVgUKJie5vXyzotA00ttcFdQkCng7zc6grwlfIfw+lw==",
+ "requires": {
+ "@babel/helper-module-imports": "^7.0.0",
+ "@babel/traverse": "^7.4.5",
+ "@emotion/is-prop-valid": "^0.8.8",
+ "@emotion/stylis": "^0.8.4",
+ "@emotion/unitless": "^0.7.4",
+ "babel-plugin-styled-components": ">= 1.12.0",
+ "css-to-react-native": "^3.0.0",
+ "hoist-non-react-statics": "^3.0.0",
+ "shallowequal": "^1.1.0",
+ "supports-color": "^5.5.0"
+ },
+ "dependencies": {
+ "supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "requires": {
+ "has-flag": "^3.0.0"
+ }
+ }
+ }
+ },
+ "stylis": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ },
+ "dependencies": {
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ }
+ }
+ },
+ "supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true
+ },
+ "tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
+ "table": {
+ "version": "6.8.0",
+ "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
+ "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==",
+ "dev": true,
+ "requires": {
+ "ajv": "^8.0.1",
+ "lodash.truncate": "^4.4.2",
+ "slice-ansi": "^4.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1"
+ },
+ "dependencies": {
+ "ajv": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+ "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+ "dev": true,
+ "requires": {
+ "fast-deep-equal": "^3.1.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2",
+ "uri-js": "^4.2.2"
+ }
+ },
+ "json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true
+ }
+ }
+ },
+ "tdigest": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz",
+ "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=",
+ "requires": {
+ "bintrees": "1.0.1"
+ }
+ },
+ "tether": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/tether/-/tether-1.4.7.tgz",
+ "integrity": "sha512-Z0J1aExjoFU8pybVkQAo/vD2wfSO63r+XOPfWQMC5qtf1bI7IWqNk4MiyBcgvvnY8kqnY06dVdvwTK2S3PU/Fw=="
+ },
+ "text-hex": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
+ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="
+ },
+ "text-table": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+ "dev": true
+ },
+ "through": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+ "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+ "dev": true
+ },
+ "tippy.js": {
+ "version": "6.3.7",
+ "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
+ "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
+ "requires": {
+ "@popperjs/core": "^2.9.0"
+ }
+ },
+ "to-fast-properties": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+ "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog=="
+ },
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ },
+ "triple-beam": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz",
+ "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw=="
+ },
+ "tsconfig-paths": {
+ "version": "3.14.1",
+ "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",
+ "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==",
+ "dev": true,
+ "requires": {
+ "@types/json5": "^0.0.29",
+ "json5": "^1.0.1",
+ "minimist": "^1.2.6",
+ "strip-bom": "^3.0.0"
+ }
+ },
+ "tslib": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+ "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+ },
+ "type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "requires": {
+ "prelude-ls": "^1.2.1"
+ }
+ },
+ "type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true
+ },
+ "unbox-primitive": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+ "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+ "dev": true,
+ "requires": {
+ "function-bind": "^1.1.1",
+ "has-bigints": "^1.0.1",
+ "has-symbols": "^1.0.2",
+ "which-boxed-primitive": "^1.0.2"
+ }
+ },
+ "uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "requires": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "use-callback-ref": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz",
+ "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==",
+ "requires": {
+ "tslib": "^2.0.0"
+ }
+ },
+ "use-context-selector": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.3.7.tgz",
+ "integrity": "sha512-O94hcN9UDAPTC4Fsm3p6Og5PVlhTEeKqxJX3HuBbVSuevOSPLDZxowFUmx49/fnu9jpgY83Nd3TALJVDRtYzdQ=="
+ },
+ "use-isomorphic-layout-effect": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz",
+ "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA=="
+ },
+ "use-sidecar": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz",
+ "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==",
+ "requires": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ }
+ },
+ "use-sync-external-store": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
+ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="
+ },
+ "util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+ },
+ "v8-compile-cache": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+ "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+ "dev": true
+ },
+ "validate-npm-package-license": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+ "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+ "dev": true,
+ "requires": {
+ "spdx-correct": "^3.0.0",
+ "spdx-expression-parse": "^3.0.0"
+ }
+ },
+ "warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "requires": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "wasm-feature-detect": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.5.1.tgz",
+ "integrity": "sha512-GHr23qmuehNXHY4902/hJ6EV5sUANIJC3R/yMfQ7hWDg3nfhlcJfnIL96R2ohpIwa62araN6aN4bLzzzq5GXkg=="
+ },
+ "webrtc-adapter": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.1.1.tgz",
+ "integrity": "sha512-1yXevP7TeZGmklEXkvQVrZp3fOSJlLeXNGCA7NovQokxgP3/e2T3EVGL0eKU87S9vKppWjvRWqnJeSANEspOBg==",
+ "requires": {
+ "sdp": "^3.0.2"
+ }
+ },
+ "which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "requires": {
+ "isexe": "^2.0.0"
+ }
+ },
+ "which-boxed-primitive": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+ "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+ "dev": true,
+ "requires": {
+ "is-bigint": "^1.0.1",
+ "is-boolean-object": "^1.1.0",
+ "is-number-object": "^1.0.4",
+ "is-string": "^1.0.5",
+ "is-symbol": "^1.0.3"
+ }
+ },
+ "wildemitter": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/wildemitter/-/wildemitter-1.2.1.tgz",
+ "integrity": "sha512-UMmSUoIQSir+XbBpTxOTS53uJ8s/lVhADCkEbhfRjUGFDPme/XGOb0sBWLx5sTz7Wx/2+TlAw1eK9O5lw5PiEw=="
+ },
+ "winston": {
+ "version": "3.7.2",
+ "resolved": "https://registry.npmjs.org/winston/-/winston-3.7.2.tgz",
+ "integrity": "sha512-QziIqtojHBoyzUOdQvQiar1DH0Xp9nF1A1y7NVy2DGEsz82SBDtOalS0ulTRGVT14xPX3WRWkCsdcJKqNflKng==",
+ "requires": {
+ "@dabh/diagnostics": "^2.0.2",
+ "async": "^3.2.3",
+ "is-stream": "^2.0.0",
+ "logform": "^2.4.0",
+ "one-time": "^1.0.0",
+ "readable-stream": "^3.4.0",
+ "safe-stable-stringify": "^2.3.1",
+ "stack-trace": "0.0.x",
+ "triple-beam": "^1.3.0",
+ "winston-transport": "^4.5.0"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ }
+ }
+ },
+ "winston-transport": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz",
+ "integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==",
+ "requires": {
+ "logform": "^2.3.2",
+ "readable-stream": "^3.6.0",
+ "triple-beam": "^1.3.0"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
+ "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
+ "requires": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ }
+ }
+ }
+ },
+ "word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true
+ },
+ "wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ }
+ },
+ "wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+ "dev": true
+ },
+ "yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "dev": true
+ },
+ "yaml": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
+ "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA=="
+ },
+ "zustand": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.2.tgz",
+ "integrity": "sha512-rd4haDmlwMTVWVqwvgy00ny8rtti/klRoZjFbL/MAcDnmD5qSw/RZc+Vddstdv90M5Lv6RPgWvm1Hivyn0QgJw==",
+ "requires": {
+ "use-sync-external-store": "1.2.0"
+ }
+ }
+ }
+}
diff --git a/src/2.7.12/package.json b/src/2.7.12/package.json
new file mode 100644
index 00000000..70ccbe8c
--- /dev/null
+++ b/src/2.7.12/package.json
@@ -0,0 +1,126 @@
+{
+ "name": "bbb-html5-client",
+ "description": "BigBlueButton HTML5 Client",
+ "license": "LGPL-3.0",
+ "scripts": {
+ "start": "if test \"$NODE_ENV\" = \"production\" ; then npm run start:prod; else npm run start:dev; fi",
+ "start:prod": "if test -z \"$ROOT_URL\" ; then export ROOT_URL=http://127.0.0.1/html5client; fi; meteor reset && meteor run --production --port=4100",
+ "start:dev": "if test -z \"$ROOT_URL\" ; then export ROOT_URL=http://127.0.0.1/html5client; fi;meteor run --port=4100",
+ "start:dev-fast-mongo": "if test -z \"$ROOT_URL\" ; then export ROOT_URL=http://127.0.0.1/html5client; fi; env MONGO_OPLOG_URL=mongodb://127.0.1.1/local MONGO_URL=mongodb://127.0.1.1/meteor ROOT_URL=http://127.0.0.1/html5client NODE_ENV=development meteor run --port=4100",
+ "test": "export WITH_RECORD=false;export REGRESSION_TESTING=false;env $(cat ../bigbluebutton-tests/puppeteer/.env | xargs) jest all.test.js --color --detectOpenHandles --forceExit",
+ "test:recording": "export WITH_RECORD=true;export REGRESSION_TESTING=false;env $(cat ../bigbluebutton-tests/puppeteer/.env | xargs) jest all.test.js --color --detectOpenHandles --forceExit",
+ "test-visual-regression": "export REGRESSION_TESTING=true;env $(cat ../bigbluebutton-tests/puppeteer/.env | xargs) jest all.test.js --color --detectOpenHandles --forceExit",
+ "test-visual-regression:recording": "export WITH_RECORD=true;export REGRESSION_TESTING=true;env $(cat ../bigbluebutton-tests/puppeteer/.env | xargs) jest all.test.js --color --detectOpenHandles --forceExit",
+ "lint": "eslint . --ext .jsx,.js",
+ "lint:file": "eslint",
+ "preinstall": "npx npm-force-resolutions",
+ "postinstall": "mkdir -p public/files; cp node_modules/@fontsource/*/files/*.woff public/files/; cp node_modules/@fontsource/*/files/*.woff2 public/files/"
+ },
+ "meteor": {
+ "mainModule": {
+ "web.browser": "client/main.jsx",
+ "legacy": "client/legacy.jsx",
+ "server": "server/main.js"
+ }
+ },
+ "lint-staged": {
+ "*.{js,jsx}": [
+ "eslint --fix"
+ ]
+ },
+ "dependencies": {
+ "@babel/runtime": "^7.17.9",
+ "@browser-bunyan/server-stream": "^1.8.0",
+ "@emoji-mart/data": "^1.1.2",
+ "@emoji-mart/react": "^1.1.1",
+ "@emotion/react": "^11.10.8",
+ "@emotion/styled": "^11.10.8",
+ "@jitsi/sdp-interop": "0.1.14",
+ "@mconf/bbb-diff": "^1.2.0",
+ "@mui/material": "^5.12.2",
+ "@mui/system": "^5.12.3",
+ "@tldraw/tldraw": "^1.27.0",
+ "autoprefixer": "^10.4.4",
+ "axios": "^1.6.4",
+ "babel-runtime": "~6.26.0",
+ "bowser": "^2.11.0",
+ "browser-bunyan": "^1.8.0",
+ "classnames": "^2.2.6",
+ "darkreader": "^4.9.46",
+ "emoji-mart": "^5.5.2",
+ "eventemitter2": "~6.4.6",
+ "fastdom": "^1.0.10",
+ "fibers": "^4.0.2",
+ "flat": "^5.0.2",
+ "focus-trap-react": "^10.2.1",
+ "hark": "^1.2.3",
+ "html-to-image": "^1.9.0",
+ "immutability-helper": "~2.8.1",
+ "langmap": "0.0.16",
+ "makeup-screenreader-trap": "0.0.5",
+ "meteor-node-stubs": "^1.2.9",
+ "mobx": "6.4.2",
+ "postcss-nested": "^5.0.6",
+ "probe-image-size": "^7.2.3",
+ "prom-client": "^13.2.0",
+ "prop-types": "^15.8.1",
+ "queue": "^6.0.2",
+ "radash": "^10.8.1",
+ "re-resizable": "^4.11.0",
+ "react": "^18.2.0",
+ "react-autosize-textarea": "^5.0.1",
+ "react-colorful": "^5.6.1",
+ "react-dom": "^18.2.0",
+ "react-draggable": "^4.4.5",
+ "react-dropzone": "^7.0.1",
+ "react-intl": "^6.1.0",
+ "react-loading-skeleton": "^3.0.3",
+ "react-modal": "^3.15.1",
+ "react-player": "^2.10.0",
+ "react-tabs": "^4.2.1",
+ "react-tether": "^2.0.7",
+ "react-toastify": "^4.5.2",
+ "react-toggle": "^4.1.2",
+ "react-transition-group": "^2.9.0",
+ "react-virtualized": "^9.22.4",
+ "reconnecting-websocket": "~v4.4.0",
+ "redis": "^3.1.2",
+ "sanitize-html": "2.12.1",
+ "scheduler": "^0.20.2",
+ "sdp-transform": "2.7.0",
+ "smile2emoji": "^3.8.3",
+ "string-hash": "~1.1.3",
+ "styled-components": "^5.3.3",
+ "tippy.js": "^6.3.7",
+ "use-context-selector": "^1.3.7",
+ "wasm-feature-detect": "^1.5.1",
+ "webrtc-adapter": "^8.1.1",
+ "winston": "^3.7.2",
+ "yaml": "^2.2.2"
+ },
+ "devDependencies": {
+ "chai": "~4.2.0",
+ "eslint": "^7.32.0",
+ "eslint-config-airbnb": "^18.2.1",
+ "eslint-config-airbnb-base": "^14.2.1",
+ "eslint-plugin-import": "^2.26.0",
+ "eslint-plugin-jsx-a11y": "^6.5.1",
+ "eslint-plugin-react": "^7.29.4",
+ "eslint-plugin-react-hooks": "^4.4.0",
+ "husky": "^1.3.1",
+ "lint-staged": "11.2.0",
+ "sha1": "^1.1.1"
+ },
+ "resolutions": {
+ "trim-newlines": "^4.0.1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/bigbluebutton/bigbluebutton.git"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "lint-staged"
+ }
+ }
+}
diff --git a/src/2.7.12/private/config/fallbackLocales.json b/src/2.7.12/private/config/fallbackLocales.json
new file mode 100644
index 00000000..d5414d45
--- /dev/null
+++ b/src/2.7.12/private/config/fallbackLocales.json
@@ -0,0 +1,30 @@
+{
+ "dv": {
+ "englishName": "Dhivehi",
+ "nativeName": "ދިވެހި"
+ },
+ "hy": {
+ "englishName": "Armenian",
+ "nativeName": "Հայերեն"
+ },
+ "ka": {
+ "englishName": "Georgian",
+ "nativeName": "ქართული"
+ },
+ "kk": {
+ "englishName": "Kazakh",
+ "nativeName": "қазақ"
+ },
+ "lo-LA": {
+ "englishName": "Lao",
+ "nativeName": "ລາວ"
+ },
+ "oc": {
+ "englishName": "Occitan",
+ "nativeName": "Occitan"
+ },
+ "uz@Cyrl": {
+ "englishName": "Uzbek (Cyrillic)",
+ "nativeName": "ўзбек тили"
+ }
+}
diff --git a/src/2.7.12/private/config/settings.yml b/src/2.7.12/private/config/settings.yml
new file mode 100644
index 00000000..2d1dd4b9
--- /dev/null
+++ b/src/2.7.12/private/config/settings.yml
@@ -0,0 +1,1064 @@
+public:
+ app:
+ mobileFontSize: 16px
+ desktopFontSize: 14px
+ audioChatNotification: false
+ # Shows the audio modal when user joins the room. The audio modal prompts
+ # user to select an option ("Microphone" and/or "Listen only") for joining
+ # audio
+ autoJoin: true
+ # Disables the listen only option in audio modal.
+ listenOnlyMode: true
+ forceListenOnly: false
+ # Skips the echo test when connecting with microphone.
+ skipCheck: false
+ # Skips the echo test when connecting with microphone right after user
+ # joins the room the first time. Subsequent joins to microphone won't
+ # have echo test skipped, for example if user leaves and joins the mic
+ # again or reloading page and joining mic again.
+ # This setting won't have effect if skipCheck = true
+ skipCheckOnJoin: false
+ #
+ # Allow users to change microphone/speaker dynamically
+ # The device is changed immediately, without the need to rejoin
+ # audio. Default value is true
+ # Firefox users: if no output devices is shown, you may set the flag
+ # "media.setsinkid.enabled" to make it work properly
+ # enableDynamicAudioDeviceSelection: true
+ #
+ clientTitle: BigBlueButton
+ appName: BigBlueButton HTML5 Client
+ bbbServerVersion: HTML5_FULL_BBB_VERSION
+ displayBbbServerVersion: true
+ copyright: '©2024 BigBlueButton Inc.'
+ html5ClientBuild: HTML5_CLIENT_VERSION
+ helpLink: https://bigbluebutton.org/html5/
+ delayForUnmountOfSharedNote: 120000
+ bbbTabletApp:
+ enabled: true
+ iosAppStoreUrl: 'https://apps.apple.com/us/app/bigbluebutton-tablet/id1641156756'
+ iosAppUrlScheme: 'bigbluebutton-tablet'
+ lockOnJoin: true
+ cdn: ''
+ basename: '/html5client'
+ # the base location of the BBB API. If you use a cluster setup with a load
+ # balancer which hides the individual nodes, then this should be
+ # https://bbb-host/bigbluebutton
+ # If you run a traditional setup of multiple nodes behind scalelite and the
+ # users see the hostnames of the individual nodes, you can leave this at the
+ # default setting
+ bbbWebBase: '/bigbluebutton'
+ # If you run a cluster setup with a load balancer which hides the individual
+ # nodes, then this should be set to https://bbb-host/learning-analytics-dashboard
+ learningDashboardBase: '/learning-analytics-dashboard'
+ # Use https URL of CSS file. Example: https://docs.bigbluebutton.org/administration/customize
+ customStyleUrl: null
+ darkTheme:
+ enabled: true
+ askForFeedbackOnLogout: false
+ # the default logoutUrl matches window.location.origin i.e. bigbluebutton.org for demo.bigbluebutton.org
+ # in some cases we want only custom logoutUrl to be used when provided on meeting create. Default value: true
+ askForConfirmationOnLeave: true
+ wakeLock:
+ enabled: true
+ allowDefaultLogoutUrl: true
+ allowUserLookup: false
+ dynamicGuestPolicy: true
+ enableGuestLobbyMessage: true
+ guestPolicyExtraAllowOptions: false
+ alwaysShowWaitingRoomUI: true
+ enableLimitOfViewersInWebcam: false
+ enableMultipleCameras: true
+ enableCameraAsContent: true
+ # Allow users to open webcam video modal/preview when video is already
+ # active. This also allows to change virtual backgrounds without
+ # restarting webcam.
+ enableWebcamSelectorButton: true
+ enableTalkingIndicator: true
+ enableCameraBrightness: true
+ mirrorOwnWebcam: false
+ viewersInWebcam: 8
+ ipv4FallbackDomain: ''
+ allowLogout: true
+ allowFullscreen: true
+ preloadNextSlides: 2
+ warnAboutUnsavedContentOnMeetingEnd: false
+ # Allows users to enable automatic transcription when joining a meeting.
+ # Automatic transcription requires the browser to support the web
+ # speech API, which involves sending voice data to third-party servers!
+ # https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API/Using_the_Web_Speech_API#speech_recognition
+ audioCaptions:
+ enabled: false
+ alwaysVisible: false
+ # mobile: - controls speech transcription availability on mobile
+ mobile: false
+ # provider: [webspeech, vosk, gladia]
+ provider: webspeech
+ language:
+ # Available languages will depend on the transcription service
+ # Google: https://cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages
+ # Gladia: https://docs-v1.gladia.io/reference/supported-languages
+ available:
+ # - ca-ES
+ # - de-DE
+ - en-US
+ - es-ES
+ - fr-FR
+ # - hi-ID
+ # - it-IT
+ # - ja-JP
+ - pt-BR
+ # - ru-RU
+ # - zh-CN
+ # If true, automatically uses the below locale field content as transcription language
+ # and the language selector in audio modal won't show up!
+ forceLocale: false
+ # If true, the default selected value for language selector in audio modal
+ # is the below locale field content
+ defaultSelectLocale: true
+ # Possible Values:
+ # browserLanguage: to set browser language
+ # [en-US, es-ES, pt-BR,...]: to set a specific locale
+ # disabled: to set disabled
+ locale: disabled
+ mutedAlert:
+ enabled: true
+ interval: 200
+ threshold: -50
+ duration: 4000
+ remainingTimeThreshold: 30
+ remainingTimeAlertThresholdArray: [1,5]
+ enableDebugWindow: true
+ # Warning: increasing the limit of breakout rooms per meeting
+ # can generate excessive overhead to the server. We recommend
+ # this value to be kept under 16.
+ breakouts:
+ allowUserChooseRoomByDefault: false
+ captureWhiteboardByDefault: false
+ captureSharedNotesByDefault: false
+ sendInvitationToAssignedModeratorsByDefault: false
+ breakoutRoomLimit: 16
+ allowPresentationManagementInBreakouts: true
+ # https://github.com/bigbluebutton/bigbluebutton/pull/10826
+ customHeartbeat: false
+ customHeartbeatUseDataFrames: true
+ showAllAvailableLocales: true
+ # Show "Audio Filters for Microphone" option in settings menu.
+ # When set to true, users are able to enable/disable microphone constraints,
+ # otherwise default values for 'microphoneConstraints' option
+ # are used.
+ # For more info, see 'microphoneConstraints' option in this config.
+ # If not set, default value is true.
+ showAudioFilters: true
+ raiseHandActionButton:
+ # Enables the old raiseHand icon
+ enabled: false
+ # If true, positions the icon next to the screenshare button, if false positions it where BBB had raisedHand button up to BBB 2.6 (right-hand bottom corner in LRT)
+ centered: true
+ reactionsButton:
+ # Enables the new raiseHand icon inside of the reaction menu (introduced in BBB 2.7)
+ # If both reactionsButton and raiseHandActionButton are enabled, reactionsButton takes precedence.
+ enabled: true
+ emojiRain:
+ # If true, new reactions will be activated
+ enabled: false
+ # Can set the throttle, the number of emojis that will be displayed and their size
+ intervalEmojis: 2000
+ numberOfEmojis: 5
+ # emojiSize: size of the emoji in 'em' units
+ emojiSize: 2
+ # If enabled, before joining microphone the client will perform a trickle
+ # ICE against Kurento and use the information about successfull
+ # candidate-pairs to filter out local candidates in SIP.js's SDP.
+ # Try enabling this setting in scenarios where the listenonly mode works,
+ # but microphone doesn't (for example, when using VPN).
+ # For compatibility check "Browser compatbility" section in:
+ # https://developer.mozilla.org/en-US/docs/Web/API/RTCDtlsTransport/iceTransport
+ # This is an EXPERIMENTAL setting and the default value is false
+ # experimentalUseKmsTrickleIceForMicrophone: false
+ #
+ # Shows stats about download and upload rates, audio jitter, lost packets
+ # and turn information
+ enableNetworkStats: true
+ # Enable the button to allow users to copy network stats to clipboard
+ enableCopyNetworkStatsButton: true
+ # where should client settings be stored? if you run a single BBB server or
+ # a cluster with a reverse proxy in front of it, you may set this to 'local'
+ # See See https://docs.bigbluebutton.org/administration/cluster-proxy
+ # allowed values:
+ # 'session' -> settings are stored in browser sessionStorage
+ # 'local' -> settings are stored in browser localStorage
+ userSettingsStorage: session
+ defaultSettings:
+ application:
+ selectedLayout: 'custom'
+ animations: true
+ chatAudioAlerts: false
+ chatPushAlerts: false
+ userJoinAudioAlerts: false
+ userJoinPushAlerts: false
+ userLeaveAudioAlerts: false
+ userLeavePushAlerts: false
+ raiseHandAudioAlerts: true
+ raiseHandPushAlerts: true
+ guestWaitingAudioAlerts: true
+ guestWaitingPushAlerts: true
+ wakeLock: true
+ paginationEnabled: true
+ whiteboardToolbarAutoHide: false
+ autoCloseReactionsBar: true
+ directLeaveButton: false
+ darkTheme: false
+ # fallbackLocale: if the locale the client is loaded in does not have a
+ # translation a string, it will use the translation from the locale
+ # specified in fallbackLocale. Note that fallbackLocale should be a
+ # 100% translated locale for best user experience
+ fallbackLocale: en
+ # overrideLocale (default is null): if set (for example to 'de') will
+ # force all clients to display the German translations of the strings.
+ # Users can individually set their preferred locale through Settings,
+ # but on first page load overrideLocale will trump the browser's
+ # preferred locale
+ overrideLocale: null
+ #Audio constraints for microphone. Use this to control browser's
+ #filters, such as AGC (Auto Gain Control) , Echo Cancellation,
+ #Noise Supression, etc.
+ #For more deails, see:
+ # https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
+ #Currently, google chrome sets {ideal: true} for autoGainControl,
+ #echoCancellation and noiseSuppression, if not set.
+ #The accepted value for each constraint is an object of type
+ #https://developer.mozilla.org/en-US/docs/Web/API/ConstrainBoolean
+ #These values are used as initial constraints for every new participant,
+ #and can be changed by user in: Settings > Application > Microphone
+ #Audio Filters.
+ # microphoneConstraints:
+ # autoGainControl:
+ # ideal: true
+ # echoCancellation:
+ # ideal: true
+ # noiseSuppression:
+ # ideal: true
+ audio:
+ inputDeviceId: undefined
+ outputDeviceId: undefined
+ dataSaving:
+ viewParticipantsWebcams: true
+ viewScreenshare: true
+ # Options that are sent to the transcription backed (only Gladia is supported for now)
+ transcription:
+ # Indicates if the transcription backend should include partial results
+ partialUtterances: true
+ # The minumum length (in seconds) an utterance has to have for we to use it
+ minUtteranceLength: 1
+ shortcuts:
+ openOptions:
+ accesskey: O
+ descId: openOptions
+ toggleUserList:
+ accesskey: U
+ descId: toggleUserList
+ toggleMute:
+ accesskey: M
+ descId: toggleMute
+ joinAudio:
+ accesskey: J
+ descId: joinAudio
+ leaveAudio:
+ accesskey: L
+ descId: leaveAudio
+ togglePublicChat:
+ accesskey: P
+ descId: togglePublicChat
+ hidePrivateChat:
+ accesskey: H
+ descId: hidePrivateChat
+ closePrivateChat:
+ accesskey: G
+ descId: closePrivateChat
+ raiseHand:
+ accesskey: R
+ descId: raiseHand
+ openActions:
+ accesskey: A
+ descId: openActions
+ openDebugWindow:
+ accesskey: K
+ descId: openDebugWindow
+ branding:
+ displayBrandingArea: true
+ connectionTimeout: 60000
+ showHelpButton: true
+ effectiveConnection:
+ - critical
+ - danger
+ - warning
+ # Whether the fallback mechanism should be used
+ # when the locale string is empty. If false, the empty
+ # string will be returned.
+ fallbackOnEmptyLocaleString: true
+ disableWebsocketFallback: true
+ externalVideoPlayer:
+ enabled: true
+ kurento:
+ wsUrl: HOST
+ cameraWsOptions:
+ # Valid for video-provider. Time (ms) before its WS connection times out
+ # and tries to reconnect.
+ wsConnectionTimeout: 4000
+ # maxRetries: max reconnection retries
+ maxRetries: 7
+ # debug: console trace logging for video-provider's ws
+ debug: false
+ heartbeat:
+ interval: 15000
+ delay: 3000
+ reconnectOnFailure: true
+ # Time in milis to wait for the browser to return a gUM call (used in video-preview)
+ gUMTimeout: 20000
+ # Controls whether ICE candidates should be signaled to bbb-webrtc-sfu.
+ # Enable this if you want to use Kurento as the media server.
+ signalCandidates: false
+ # traceLogs: - enable trace logs in SFU peers
+ traceLogs: false
+ cameraTimeouts:
+ # Base camera timeout: used as the camera *sharing* timeout and
+ # as the minimum camera subscribe reconnection timeout
+ baseTimeout: 30000
+ # Max timeout: used as the max camera subscribe reconnection timeout. Each
+ # subscribe reattempt increases the reconnection timer up to this
+ maxTimeout: 60000
+ screenshare:
+ # Whether volume control should be allowed if screen sharing has audio
+ enableVolumeControl: true
+ # Experimental. True is the canonical behavior. Flip to false to reverse
+ # the negotiation flow for subscribers.
+ subscriberOffering: false
+ # Experimental. Server wide configuration to choose which bbb-webrtc-sfu
+ # media server adapter should be used for screen sharing.
+ # Default is undefined, which means the default setting in bbb-webrtc-sfu
+ # prevails (screenshareMediaServer).
+ #mediaServer: Kurento
+ bitrate: 1500
+ mediaTimeouts:
+ maxConnectionAttempts: 2
+ # Base screen media timeout (send|recv) - first connections
+ baseTimeout: 20000
+ # Base screen media timeout (send|recv) - re-connections
+ baseReconnectionTimeout: 8000
+ # Max timeout: used as the max camera subscribe connection timeout. Each
+ # subscribe reattempt increases the reconnection timer up to this
+ maxTimeout: 25000
+ timeoutIncreaseFactor: 1.5
+ constraints:
+ video:
+ frameRate:
+ ideal: 5
+ max: 10
+ width:
+ max: 2560
+ height:
+ max: 1600
+ audio: true
+ # cameraProfiles is an array of:
+ # - id: profile identifier
+ # name: human-readable profile name
+ # bitrate
+ # hidden: whether this profile will be hidden in the video preview dropdown
+ # constraints: a video media constraints dictionary (without the video key)
+ cameraProfiles:
+ # id: unique identifier of the profile
+ # name: name of the profile visible to users
+ # default: if this is the default profile which is pre-selected
+ # bitrate: the average bitrate for used for a webcam stream
+ # constraints:
+ # # Optional constraints put on the requested video a browser MAY honor
+ # # For a detailed list on possible values see:
+ # # https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
+ # # Examples:
+ # width: requested width of the camera stream
+ # frameRate: requested framerate
+ - id: low-u30
+ name: low-u30
+ bitrate: 30
+ hidden: true
+ - id: low-u25
+ name: low-u25
+ bitrate: 40
+ hidden: true
+ - id: low-u20
+ name: low-u20
+ bitrate: 50
+ hidden: true
+ - id: low-u15
+ name: low-u15
+ bitrate: 70
+ hidden: true
+ - id: low-u12
+ name: low-u12
+ bitrate: 90
+ hidden: true
+ - id: low-u8
+ name: low-u8
+ bitrate: 100
+ hidden: true
+ - id: low
+ name: Low
+ default: false
+ bitrate: 100
+ - id: medium
+ name: Medium
+ default: true
+ bitrate: 200
+ - id: high
+ name: High
+ default: false
+ bitrate: 500
+ constraints:
+ width: 1280
+ height: 720
+ frameRate: 15
+ - id: hd
+ name: High definition
+ default: false
+ bitrate: 800
+ constraints:
+ width: 1280
+ height: 720
+ frameRate: 30
+ - id: fhd
+ name: Camera as content
+ hidden: true
+ default: false
+ bitrate: 1500
+ constraints:
+ width: 1920
+ height: 1080
+ enableScreensharing: true
+ enableVideo: true
+ enableVideoMenu: true
+ enableVideoPin: true
+ # Experimental. Server wide configuration to choose which bbb-webrtc-sfu
+ # media server adapter should be used for listen only.
+ # Default is undefined, which means the default setting in bbb-webrtc-sfu
+ # prevails (listenOnlyMediaServer).
+ #listenOnlyMediaServer: mediasoup
+ # Experimental. Server wide configuration to choose which bbb-webrtc-sfu
+ # media server adapter should be used for webcams.
+ # Default is undefined, which means the default setting in bbb-webrtc-sfu
+ # prevails (videoMediaServer).
+ #videoMediaServer: Kurento
+ autoShareWebcam: false
+ skipVideoPreview: false
+ skipVideoPreviewOnFirstJoin: false
+ # cameraSortingModes.paginationSorting: sorting mode to be applied when pagination is active
+ # cameraSortingModes.defaultSorting: sorting mode when pagination is not active (full mesh)
+ # Current implemented modes are:
+ # 'LOCAL_ALPHABETICAL' | 'VOICE_ACTIVITY_LOCAL' | 'LOCAL_VOICE_ACTIVITY' | 'LOCAL_PRESENTER_ALPHABETICAL'
+ # The algorithm names are self-explanatory.
+ cameraSortingModes:
+ defaultSorting: LOCAL_ALPHABETICAL
+ paginationSorting: VOICE_ACTIVITY_LOCAL
+ # Entry `thresholds` is an array of:
+ # - threshold: minimum number of cameras being shared for profile to applied
+ # profile: a camera profile id from the cameraProfiles configuration array
+ # that will be applied to all cameras when threshold is hit
+ cameraQualityThresholds:
+ enabled: true
+ # applyConstraints: whether profile constraints should be applied on profile changes
+ applyConstraints: false
+ # privilegedStreams: whether cameras should revert to their original profile on
+ # certain actions (eg floor changes, pin)
+ privilegedStreams: true
+ debounceTime: 2500
+ thresholds:
+ - threshold: 8
+ profile: low-u8
+ - threshold: 12
+ profile: low-u12
+ - threshold: 15
+ profile: low-u15
+ - threshold: 20
+ profile: low-u20
+ - threshold: 25
+ profile: low-u25
+ - threshold: 30
+ profile: low-u30
+ pagination:
+ # WARNING: the pagination.enabled setting has moved to
+ # public.app.defaultSettings.application.paginationEnabled
+ # paginationToggleEnabled: show a pagination toggle in settings for the
+ # user to enable/disable it
+ paginationToggleEnabled: true
+ # how long (in ms) the negotiation will be debounced after a page change.
+ pageChangeDebounceTime: 2500
+ # video page sizes for DESKTOP endpoints. It stands for the number of SUBSCRIBER streams.
+ # PUBLISHERS aren't accounted for .
+ # A page size of 0 (zero) means that the page size is unlimited (disabled).
+ desktopPageSizes:
+ moderator: 0
+ viewer: 5
+ # video page sizes for MOBILE endpoints
+ mobilePageSizes:
+ moderator: 2
+ viewer: 2
+ # grid size for DESKTOP endpoints
+ desktopGridSizes:
+ moderator: 48
+ viewer: 48
+ # grid size for MOBILE endpoints
+ mobileGridSizes:
+ moderator: 14
+ viewer: 14
+ paginationThresholds:
+ enabled: false
+ thresholds:
+ - users: 30
+ desktopPageSizes:
+ moderator: 25
+ viewer: 25
+ - users: 40
+ desktopPageSizes:
+ moderator: 20
+ viewer: 20
+ - users: 50
+ desktopPageSizes:
+ moderator: 16
+ viewer: 16
+ - users: 60
+ desktopPageSizes:
+ moderator: 14
+ viewer: 12
+ - users: 70
+ desktopPageSizes:
+ moderator: 12
+ viewer: 10
+ - users: 80
+ desktopPageSizes:
+ moderator: 10
+ viewer: 8
+ - users: 90
+ desktopPageSizes:
+ moderator: 8
+ viewer: 6
+ - users: 100
+ desktopPageSizes:
+ moderator: 6
+ viewer: 4
+ syncUsersWithConnectionManager:
+ enabled: false
+ syncInterval: 60000
+ poll:
+ enabled: true
+ allowCustomResponseInput: true
+ maxCustom: 5
+ maxTypedAnswerLength: 45
+ chatMessage: true
+ captions:
+ enabled: true
+ showButton: false
+ id: captions
+ dictation: false
+ # Default pad which will store automatically generated captions
+ defaultPad: en
+ background: '#000000'
+ font:
+ color: '#ffffff'
+ family: Calibri
+ size: 24px
+ # maximum number of simultaneous captions on screen
+ captionLimit: 3
+ # maximum size of a caption line in characters
+ lineLimit: 60
+ # maximum number of lines a caption can have on screen
+ lines: 2
+ # time the captions will hang on screen after last updated
+ time: 5000
+ timer:
+ enabled: true
+ alarm: true
+ music:
+ enabled: false
+ volume: 0.4
+ track1: "RelaxingMusic"
+ track2: "CalmMusic"
+ track3: "aristocratDrums"
+ interval:
+ clock: 100
+ offset: 60000
+ time: 5
+ tabIndicator: false
+ chat:
+ enabled: true
+ itemsPerPage: 100
+ timeBetweenFetchs: 1000
+ enableSaveAndCopyPublicChat: true
+ bufferChatInsertsMs: 0
+ startClosed: false
+ min_message_length: 1
+ max_message_length: 5000
+ grouping_messages_window: 10000
+ type_system: SYSTEM_MESSAGE
+ type_public: PUBLIC_ACCESS
+ type_private: PRIVATE_ACCESS
+ system_userid: SYSTEM_MESSAGE
+ system_username: SYSTEM_MESSAGE
+ public_id: public
+ public_group_id: MAIN-PUBLIC-GROUP-CHAT
+ public_userid: public_chat_userid
+ public_username: public_chat_username
+ storage_key: UNREAD_CHATS
+ system_messages_keys:
+ chat_clear: PUBLIC_CHAT_CLEAR
+ chat_poll_result: PUBLIC_CHAT_POLL_RESULT
+ chat_exported_presentation: PUBLIC_CHAT_EXPORTED_PRESENTATION
+ chat_status_message: PUBLIC_CHAT_STATUS
+ typingIndicator:
+ enabled: true
+ showNames: true
+ moderatorChatEmphasized: true
+ autoConvertEmoji: true
+ emojiPicker:
+ enable: false
+ # e.g.: disableEmojis: ['grin','laughing']
+ disableEmojis: []
+ userReaction:
+ enabled: true
+ expire: 60
+ reactions:
+ - id: 'smiley'
+ native: '😃'
+ - id: 'neutral_face'
+ native: '😐'
+ - id: 'slightly_frowning_face'
+ native: '🙁'
+ - id: '+1'
+ native: '👍'
+ - id: '-1'
+ native: '👎'
+ - id: 'clap'
+ native: '👏'
+ userStatus:
+ enabled: false
+ notes:
+ enabled: true
+ id: notes
+ pinnable: true
+ layout:
+ hidePresentationOnJoin: false
+ showParticipantsOnLogin: true
+ showPushLayoutButton: true
+ showPushLayoutToggle: true
+ pads:
+ url: ETHERPAD_HOST
+ cookie:
+ path: /
+ sameSite: None
+ secure: true
+ media:
+ audio:
+ #
+ #
+ # Default bridge to be used by full audio mechanism.
+ # This is the bridge's name as contained in 'bridges' array
+ defaultFullAudioBridge: fullaudio
+ #
+ #
+ # Server wide configuration to choose which bbb-webrtc-sfu
+ # media server adapter should be used for fullaudio.
+ # Default is undefined, which means the default setting in bbb-webrtc-sfu
+ # prevails (fullAudioMediaServer).
+ #fullAudioMediaServer: mediasoup
+ #
+ #
+ # Default bridge to be used by listen only mechanism.
+ defaultListenOnlyBridge: fullaudio
+ #
+ #
+ # Bridge array, here's where we list our bridges.
+ # To add new bridges, simply add the corresponding .js file in
+ # /imports/api/audio/client/bridge/ and add it to this list.
+ #
+ # Each bridge in this list, must have a name and path attribute.
+ # The name is the desired name/string you can set for your bridge, while
+ # the path specifies the file path, relative to
+ # '/imports/api/audio/client' dir.
+ #
+ bridges:
+ # name: The name of the bridge
+ - name: sipjs
+ # path: the bridge file path, relative to /imports/api/audio/client
+ path: 'bridge/sip'
+ - name: fullaudio
+ path: 'bridge/sfu-audio-bridge'
+ # Forces a retry with iceTransportPolicy = 'relay' if the first attempt
+ # fails with a few selected errors codes (eg 1007, 1010)
+ retryThroughRelay: false
+ stunTurnServersFetchAddress: '/bigbluebutton/api/stuns'
+ cacheStunTurnServers: true
+ fallbackStunServer: ''
+ # Forces relay usage on all browsers, environments and media modules.
+ # If true, supersedes public.kurento.forceRelayOnFirefox
+ forceRelay: false
+ # Firefox has a buggy ICE implementation. With mediasoup this leads to
+ # connection problems unless all traffic is relayed through a turn server.
+ forceRelayOnFirefox: true
+ mediaTag: '#remote-media'
+ callTransferTimeout: 5000
+ callHangupTimeout: 2000
+ callHangupMaximumRetries: 10
+ echoTestNumber: 'echo'
+ listenOnlyCallTimeout: 15000
+ # Experimental: enables a new audio mechanism that has a server-side,
+ # transparent listen only mode. See issue #14021.
+ # bbb_userdata-transparent-listen-only supersedes this setting
+ transparentListenOnly: false
+ # Dev flag. Controls the WebRTC SDP negotiation role for SFU full audio.
+ fullAudioOffering: true
+ # Dev flag. Controls the WebRTC negotiation role for listen only.
+ listenOnlyOffering: false
+ #Timeout (ms) for gathering ICE candidates. When this timeout expires
+ #the SDP is sent to the server with the candidates the browser gathered
+ #so far. Increasing this value might help avoiding 1004 error when
+ #user activates microphone.
+ iceGatheringTimeout: 5000
+ # Timeout (ms) for connecting to the audio's signaling websocket.
+ audioConnectionTimeout: 5000
+ # Delay (ms) between each reconnection attempt of the audio's signaling
+ # websocket.
+ audioReconnectionDelay: 5000
+ # Number of reconnection attempts of the signaling websocket, before
+ # showing to the user there's an audio error.
+ audioReconnectionAttempts: 3
+ sipjsHackViaWs: false
+ # sipjsAllowMdns: whether mDNS candidates should be allowed in local SDPs.
+ # Default is false since FreeSWITCH doesn't resolve mDNS by default.
+ sipjsAllowMdns: false
+ # the fqdn of this host.
+ # If you run a traditional setup of multiple nodes behind scalelite and the users see the hostnames of the
+ # individual nodes, you can leave this at the default setting
+ sip_ws_host: ''
+ # Mute/umute toggle throttle time
+ toggleMuteThrottleTime: 300
+ #Websocket keepAlive interval (seconds). You may set this to prevent
+ #websocket disconnection in some environments. When set, BBB will send
+ #'\r\n\r\n' string through SIP.js's websocket. If not set, default value
+ #is 0.
+ websocketKeepAliveInterval: 30
+ #Debounce time (seconds) for sending SIP.js's websocket keep alive message.
+ #If not set, default value is 10.
+ websocketKeepAliveDebounce: 10
+ #Trace sip/audio messages in browser. If not set, default value is false.
+ traceSip: false
+ # SDP semantics: plan-b|unified-plan
+ sdpSemantics: 'unified-plan'
+ # localEchoTest:
+ # enabled: Boolean => enables an experimental, simplified echo test mode
+ # initialHearingState: Boolean => whether users should hear themselves firsthand
+ # useRtcLoopbackInChromium: Boolean => whether a local RTC loopback should
+ # be used in Chromium browsers. Works around the fact that Chromium has no
+ # echo cancellation in non-rtc audio streams
+ localEchoTest:
+ enabled: true
+ initialHearingState: true
+ useRtcLoopbackInChromium: true
+ # delay: delay (seconds) to be added to the audio feedback return
+ delay:
+ enabled: true
+ delayTime: 0.5
+ maxDelayTime: 2
+ # showVolumeMeter: shows an energy bar for microphones in the AudioSettings view
+ showVolumeMeter: true
+ # networkPriorities: DSCP markings for each media type. Chromium only, applies
+ # to sender flows. See https://datatracker.ietf.org/doc/html/rfc8837#section-5
+ # for further info.
+ #networkPriorities:
+ # audio: high
+ # webcam: medium
+ # screenshare: medium
+ #
+ # audioTroubleshootingLinks: links to help users troubleshoot audio issues
+ # If no link is provided, the audio troubleshooting button will not be shown.
+ # Index is the error code:
+ # - 7: permission denied error code
+ # - 0: unknown error
+ #audioTroubleshootingLinks:
+ # 7: 'https://link.bigbluebutton.org/perm'
+ # 0: 'https://link.bigbluebutton.org/unk'
+ stats:
+ enabled: true
+ interval: 10000
+ timeout: 30000
+ log: true
+ notification:
+ warning: false
+ error: true
+ jitter:
+ - 10
+ - 20
+ - 30
+ loss:
+ - 0.05
+ - 0.1
+ - 0.2
+ rtt:
+ - 500
+ - 1000
+ - 2000
+ level:
+ - warning
+ - danger
+ - critical
+ help: STATS_HELP_URL
+ presentation:
+ allowDownloadOriginal: true
+ allowDownloadWithAnnotations: true
+ allowSnapshotOfCurrentSlide: true
+ panZoomThrottle: 32
+ restoreOnUpdate: true
+ uploadEndpoint: '/bigbluebutton/presentation/upload'
+ fileUploadConstraintsHint: false
+ # mirroredFromBBBCore are values controlled in bbb-web properties file. We include a copy here for notification purposes
+ mirroredFromBBBCore:
+ uploadSizeMax: 30000000
+ uploadPagesMax: 200
+ # If the following mime-types list is changed, please, make sure to also change:
+ # bbb-common-web/src/main/java/org/bigbluebutton/presentation/MimeTypeUtils.java: L34 (and related files.)
+ # docs/docs/development/api.md: L1222
+ uploadValidMimeTypes:
+ - extension: .pdf
+ mime: application/pdf
+ - extension: .doc
+ mime: application/msword
+ - extension: .docx
+ mime: application/vnd.openxmlformats-officedocument.wordprocessingml.document
+ - extension: .xls
+ mime: application/vnd.ms-excel
+ - extension: .xlsx
+ mime: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
+ - extension: .ppt
+ mime: application/vnd.ms-powerpoint
+ - extension: .pptx
+ mime: application/vnd.openxmlformats-officedocument.presentationml.presentation
+ - extension: .txt
+ mime: text/plain
+ - extension: .rtf
+ mime: application/rtf
+ - extension: .odt
+ mime: application/vnd.oasis.opendocument.text
+ - extension: .ods
+ mime: application/vnd.oasis.opendocument.spreadsheet
+ - extension: .odp
+ mime: application/vnd.oasis.opendocument.presentation
+ - extension: .odg
+ mime: application/vnd.oasis.opendocument.graphics
+ - extension: .jpg
+ mime: image/jpeg
+ - extension: .jpeg
+ mime: image/jpeg
+ - extension: .png
+ mime: image/png
+ - extension: .webp
+ mime: image/webp
+ - extension: .svg
+ mime: image/svg+xm
+ selectRandomUser:
+ enabled: true
+ countdown: false
+ user:
+ role_moderator: MODERATOR
+ role_viewer: VIEWER
+ label:
+ moderator: false
+ mobile: true
+ guest: true
+ sharingWebcam: true
+ whiteboard:
+ annotationsQueueProcessInterval: 60
+ cursorInterval: 150
+ pointerDiameter: 5
+ maxStickyNoteLength: 1000
+ # limit number of annotations per slide
+ maxNumberOfAnnotations: 300
+ annotations:
+ status:
+ start: DRAW_START
+ update: DRAW_UPDATE
+ end: DRAW_END
+ styles:
+ text:
+ # Initial font family.
+ # family: mono|sans|script|serif
+ family: script
+ toolbar:
+ multiUserPenOnly: false
+ colors:
+ - label: black
+ value: '#000000'
+ - label: white
+ value: '#ffffff'
+ - label: red
+ value: '#ff0000'
+ - label: orange
+ value: '#ff8800'
+ - label: eletricLime
+ value: '#ccff00'
+ - label: Lime
+ value: '#00ff00'
+ - label: Cyan
+ value: '#00ffff'
+ - label: dodgerBlue
+ value: '#0088ff'
+ - label: blue
+ value: '#0000ff'
+ - label: violet
+ value: '#8800ff'
+ - label: magenta
+ value: '#ff00ff'
+ - label: silver
+ value: '#c0c0c0'
+ thickness:
+ - value: 14
+ - value: 12
+ - value: 10
+ - value: 8
+ - value: 6
+ - value: 4
+ - value: 2
+ - value: 1
+ font_sizes:
+ - value: 36
+ - value: 32
+ - value: 28
+ - value: 24
+ - value: 20
+ - value: 16
+ tools:
+ - icon: text_tool
+ value: text
+ - icon: line_tool
+ value: line
+ - icon: circle_tool
+ value: ellipse
+ - icon: triangle_tool
+ value: triangle
+ - icon: rectangle_tool
+ value: rectangle
+ - icon: pen_tool
+ value: pencil
+ - icon: hand
+ value: hand
+ presenterTools:
+ - text
+ - line
+ - ellipse
+ - triangle
+ - rectangle
+ - pencil
+ - hand
+ multiUserTools:
+ - text
+ - line
+ - ellipse
+ - triangle
+ - rectangle
+ - pencil
+ clientLog:
+ server:
+ enabled: false
+ level: info
+ console:
+ enabled: true
+ level: debug
+ external:
+ enabled: false
+ level: info
+ url: https://LOG_HOST/html5Log
+ method: POST
+ throttleInterval: 400
+ flushOnClose: true
+ logTag: ''
+ virtualBackgrounds:
+ enabled: true
+ enableVirtualBackgroundUpload: true
+ storedOnBBB: true
+ showThumbnails: true
+ imagesPath: /resources/images/virtual-backgrounds/
+ thumbnailsPath: /resources/images/virtual-backgrounds/thumbnails/
+ fileNames:
+ - home.jpg
+ - coffeeshop.jpg
+ - board.jpg
+private:
+ analytics:
+ includeChat: true
+ app:
+ host: 127.0.0.1
+ localesUrl: /locale-list
+ pencilChunkLength: 100
+ loadSlidesFromHttpAlways: false
+ redis:
+ host: 127.0.0.1
+ port: '6379'
+ timeout: 5000
+ password: null
+ debug: false
+ metrics:
+ queueMetrics: false
+ metricsDumpIntervalMs: 60000
+ metricsFolderPath: METRICS_FOLDER
+ removeMeetingOnEnd: true
+ channels:
+ toAkkaApps: to-akka-apps-redis-channel
+ toThirdParty: to-third-party-redis-channel
+ subscribeTo:
+ - to-html5-redis-channel
+ - from-akka-apps-[^f]*
+ - from-third-party-redis-channel
+ async:
+ - from-akka-apps-wb-redis-channel
+ ignored:
+ - CheckAlivePongSysMsg
+ - DoLatencyTracerMsg
+ serverLog:
+ level: info
+ streamerLog: false
+ includeServerInfo: true
+ healthChecker:
+ enable: true
+ intervalMs: 30000
+ minBrowserVersions:
+ - browser: chrome
+ version: 72
+ - browser: chromeMobileIOS
+ version: 94
+ - browser: firefox
+ version: 68
+ - browser: firefoxMobile
+ version: 68
+ - browser: edge
+ version: 79
+ - browser: ie
+ version: Infinity
+ - browser: safari
+ version: [13, 1]
+ - browser: mobileSafari
+ version: [13, 4]
+ - browser: opera
+ version: 50
+ - browser: electron
+ version: [0, 36]
+ - browser: SamsungInternet
+ version: 10
+ - browser: YandexBrowser
+ version: 19
+ # Direct Prometheus instrumentation.
+ # EXPERIMENTAL, so disabled by default.
+ prometheus:
+ enabled: false
+ # Metrics endpoint path
+ path: '/metrics'
+ # Whether default metrics for Node.js processes should be exported
+ collectDefaultMetrics: false
+ # Whether redis metrics should be exported
+ collectRedisMetrics: false
diff --git a/src/2.7.12/private/static/guest-wait/guest-wait.html b/src/2.7.12/private/static/guest-wait/guest-wait.html
new file mode 100644
index 00000000..e4c1f899
--- /dev/null
+++ b/src/2.7.12/private/static/guest-wait/guest-wait.html
@@ -0,0 +1,402 @@
+
+
+
+
+ BigBlueButton - Guest Lobby
+
+
+
+
+
+
+
+
+
BigBlueButton - Guest Lobby
+
+
Please wait for a moderator to approve you joining the meeting.
+
+
Calculating position in waiting queue
+
+
+
+
+
diff --git a/src/2.7.12/public/compatibility/sip.js b/src/2.7.12/public/compatibility/sip.js
new file mode 100644
index 00000000..9f67d804
--- /dev/null
+++ b/src/2.7.12/public/compatibility/sip.js
@@ -0,0 +1,20751 @@
+/*!
+ *
+ * SIP version 0.17.1
+ * Copyright (c) 2014-2020 Junction Networks, Inc
+ * Homepage: https://sipjs.com
+ * License: https://sipjs.com/license/
+ *
+ *
+ * ~~~SIP.js contains substantial portions of JsSIP under the following license~~~
+ * Homepage: http://jssip.net
+ * Copyright (c) 2012-2013 José Luis Millán - Versatica
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+ * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+ * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+ * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * ~~~ end JsSIP license ~~~
+ *
+ *
+ *
+ *
+ */
+(function webpackUniversalModuleDefinition(root, factory) {
+ if(typeof exports === 'object' && typeof module === 'object')
+ module.exports = factory();
+ else if(typeof define === 'function' && define.amd)
+ define([], factory);
+ else if(typeof exports === 'object')
+ exports["SIP"] = factory();
+ else
+ root["SIP"] = factory();
+})(this, function() {
+return /******/ (function(modules) { // webpackBootstrap
+/******/ // The module cache
+/******/ var installedModules = {};
+/******/
+/******/ // The require function
+/******/ function __webpack_require__(moduleId) {
+/******/
+/******/ // Check if module is in cache
+/******/ if(installedModules[moduleId]) {
+/******/ return installedModules[moduleId].exports;
+/******/ }
+/******/ // Create a new module (and put it into the cache)
+/******/ var module = installedModules[moduleId] = {
+/******/ i: moduleId,
+/******/ l: false,
+/******/ exports: {}
+/******/ };
+/******/
+/******/ // Execute the module function
+/******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
+/******/
+/******/ // Flag the module as loaded
+/******/ module.l = true;
+/******/
+/******/ // Return the exports of the module
+/******/ return module.exports;
+/******/ }
+/******/
+/******/
+/******/ // expose the modules object (__webpack_modules__)
+/******/ __webpack_require__.m = modules;
+/******/
+/******/ // expose the module cache
+/******/ __webpack_require__.c = installedModules;
+/******/
+/******/ // define getter function for harmony exports
+/******/ __webpack_require__.d = function(exports, name, getter) {
+/******/ if(!__webpack_require__.o(exports, name)) {
+/******/ Object.defineProperty(exports, name, { enumerable: true, get: getter });
+/******/ }
+/******/ };
+/******/
+/******/ // define __esModule on exports
+/******/ __webpack_require__.r = function(exports) {
+/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
+/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
+/******/ }
+/******/ Object.defineProperty(exports, '__esModule', { value: true });
+/******/ };
+/******/
+/******/ // create a fake namespace object
+/******/ // mode & 1: value is a module id, require it
+/******/ // mode & 2: merge all properties of value into the ns
+/******/ // mode & 4: return value when already ns object
+/******/ // mode & 8|1: behave like require
+/******/ __webpack_require__.t = function(value, mode) {
+/******/ if(mode & 1) value = __webpack_require__(value);
+/******/ if(mode & 8) return value;
+/******/ if((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;
+/******/ var ns = Object.create(null);
+/******/ __webpack_require__.r(ns);
+/******/ Object.defineProperty(ns, 'default', { enumerable: true, value: value });
+/******/ if(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));
+/******/ return ns;
+/******/ };
+/******/
+/******/ // getDefaultExport function for compatibility with non-harmony modules
+/******/ __webpack_require__.n = function(module) {
+/******/ var getter = module && module.__esModule ?
+/******/ function getDefault() { return module['default']; } :
+/******/ function getModuleExports() { return module; };
+/******/ __webpack_require__.d(getter, 'a', getter);
+/******/ return getter;
+/******/ };
+/******/
+/******/ // Object.prototype.hasOwnProperty.call
+/******/ __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };
+/******/
+/******/ // __webpack_public_path__
+/******/ __webpack_require__.p = "";
+/******/
+/******/
+/******/ // Load entry module and return exports
+/******/ return __webpack_require__(__webpack_require__.s = 0);
+/******/ })
+/************************************************************************/
+/******/ ([
+/* 0 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "name", function() { return name; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "version", function() { return version; });
+/* harmony import */ var _version__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(1);
+/* harmony import */ var _api__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(2);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _api__WEBPACK_IMPORTED_MODULE_1__) if(["name","version","Core","Web","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _api__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _grammar__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(9);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Grammar", function() { return _grammar__WEBPACK_IMPORTED_MODULE_2__["Grammar"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NameAddrHeader", function() { return _grammar__WEBPACK_IMPORTED_MODULE_2__["NameAddrHeader"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Parameters", function() { return _grammar__WEBPACK_IMPORTED_MODULE_2__["Parameters"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "URI", function() { return _grammar__WEBPACK_IMPORTED_MODULE_2__["URI"]; });
+
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5);
+/* harmony reexport (module object) */ __webpack_require__.d(__webpack_exports__, "Core", function() { return _core__WEBPACK_IMPORTED_MODULE_3__; });
+/* harmony import */ var _platform_web__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(174);
+/* harmony reexport (module object) */ __webpack_require__.d(__webpack_exports__, "Web", function() { return _platform_web__WEBPACK_IMPORTED_MODULE_4__; });
+// Helpful name and version exports
+
+const version = _version__WEBPACK_IMPORTED_MODULE_0__["LIBRARY_VERSION"];
+const name = "sip.js";
+
+// Export api
+
+// Export grammar
+
+// Export namespaced core
+
+
+// Export namespaced web
+
+
+
+
+/***/ }),
+/* 1 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LIBRARY_VERSION", function() { return LIBRARY_VERSION; });
+const LIBRARY_VERSION = "0.17.1";
+
+
+/***/ }),
+/* 2 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(3);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ContentTypeUnsupportedError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_0__["ContentTypeUnsupportedError"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RequestPendingError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_0__["RequestPendingError"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionDescriptionHandlerError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_0__["SessionDescriptionHandlerError"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionTerminatedError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_0__["SessionTerminatedError"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "StateTransitionError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_0__["StateTransitionError"]; });
+
+/* harmony import */ var _bye__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(107);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Bye", function() { return _bye__WEBPACK_IMPORTED_MODULE_1__["Bye"]; });
+
+/* harmony import */ var _emitter__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(108);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "EmitterImpl", function() { return _emitter__WEBPACK_IMPORTED_MODULE_2__["EmitterImpl"]; });
+
+/* harmony import */ var _info__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(109);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Info", function() { return _info__WEBPACK_IMPORTED_MODULE_3__["Info"]; });
+
+/* harmony import */ var _invitation_accept_options__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(110);
+/* harmony import */ var _invitation_accept_options__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_invitation_accept_options__WEBPACK_IMPORTED_MODULE_4__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _invitation_accept_options__WEBPACK_IMPORTED_MODULE_4__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _invitation_accept_options__WEBPACK_IMPORTED_MODULE_4__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _invitation_progress_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(111);
+/* harmony import */ var _invitation_progress_options__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_invitation_progress_options__WEBPACK_IMPORTED_MODULE_5__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _invitation_progress_options__WEBPACK_IMPORTED_MODULE_5__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _invitation_progress_options__WEBPACK_IMPORTED_MODULE_5__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _invitation_reject_options__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(112);
+/* harmony import */ var _invitation_reject_options__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_invitation_reject_options__WEBPACK_IMPORTED_MODULE_6__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _invitation_reject_options__WEBPACK_IMPORTED_MODULE_6__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _invitation_reject_options__WEBPACK_IMPORTED_MODULE_6__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _invitation__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(113);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Invitation", function() { return _invitation__WEBPACK_IMPORTED_MODULE_7__["Invitation"]; });
+
+/* harmony import */ var _inviter_cancel_options__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(120);
+/* harmony import */ var _inviter_cancel_options__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(_inviter_cancel_options__WEBPACK_IMPORTED_MODULE_8__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _inviter_cancel_options__WEBPACK_IMPORTED_MODULE_8__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _inviter_cancel_options__WEBPACK_IMPORTED_MODULE_8__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _inviter_invite_options__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(121);
+/* harmony import */ var _inviter_invite_options__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(_inviter_invite_options__WEBPACK_IMPORTED_MODULE_9__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _inviter_invite_options__WEBPACK_IMPORTED_MODULE_9__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _inviter_invite_options__WEBPACK_IMPORTED_MODULE_9__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _inviter_options__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(122);
+/* harmony import */ var _inviter_options__WEBPACK_IMPORTED_MODULE_10___default = /*#__PURE__*/__webpack_require__.n(_inviter_options__WEBPACK_IMPORTED_MODULE_10__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _inviter_options__WEBPACK_IMPORTED_MODULE_10__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _inviter_options__WEBPACK_IMPORTED_MODULE_10__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _inviter__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(123);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Inviter", function() { return _inviter__WEBPACK_IMPORTED_MODULE_11__["Inviter"]; });
+
+/* harmony import */ var _message__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(115);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Message", function() { return _message__WEBPACK_IMPORTED_MODULE_12__["Message"]; });
+
+/* harmony import */ var _messager_message_options__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(124);
+/* harmony import */ var _messager_message_options__WEBPACK_IMPORTED_MODULE_13___default = /*#__PURE__*/__webpack_require__.n(_messager_message_options__WEBPACK_IMPORTED_MODULE_13__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _messager_message_options__WEBPACK_IMPORTED_MODULE_13__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _messager_message_options__WEBPACK_IMPORTED_MODULE_13__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _messager_options__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(125);
+/* harmony import */ var _messager_options__WEBPACK_IMPORTED_MODULE_14___default = /*#__PURE__*/__webpack_require__.n(_messager_options__WEBPACK_IMPORTED_MODULE_14__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _messager_options__WEBPACK_IMPORTED_MODULE_14__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _messager_options__WEBPACK_IMPORTED_MODULE_14__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _messager__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(126);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Messager", function() { return _messager__WEBPACK_IMPORTED_MODULE_15__["Messager"]; });
+
+/* harmony import */ var _notification__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(116);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Notification", function() { return _notification__WEBPACK_IMPORTED_MODULE_16__["Notification"]; });
+
+/* harmony import */ var _publisher_options__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(127);
+/* harmony import */ var _publisher_options__WEBPACK_IMPORTED_MODULE_17___default = /*#__PURE__*/__webpack_require__.n(_publisher_options__WEBPACK_IMPORTED_MODULE_17__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _publisher_options__WEBPACK_IMPORTED_MODULE_17__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _publisher_options__WEBPACK_IMPORTED_MODULE_17__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _publisher_publish_options__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(128);
+/* harmony import */ var _publisher_publish_options__WEBPACK_IMPORTED_MODULE_18___default = /*#__PURE__*/__webpack_require__.n(_publisher_publish_options__WEBPACK_IMPORTED_MODULE_18__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _publisher_publish_options__WEBPACK_IMPORTED_MODULE_18__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _publisher_publish_options__WEBPACK_IMPORTED_MODULE_18__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _publisher_state__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(129);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PublisherState", function() { return _publisher_state__WEBPACK_IMPORTED_MODULE_19__["PublisherState"]; });
+
+/* harmony import */ var _publisher_unpublish_options__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(130);
+/* harmony import */ var _publisher_unpublish_options__WEBPACK_IMPORTED_MODULE_20___default = /*#__PURE__*/__webpack_require__.n(_publisher_unpublish_options__WEBPACK_IMPORTED_MODULE_20__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _publisher_unpublish_options__WEBPACK_IMPORTED_MODULE_20__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _publisher_unpublish_options__WEBPACK_IMPORTED_MODULE_20__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _publisher__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(131);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Publisher", function() { return _publisher__WEBPACK_IMPORTED_MODULE_21__["Publisher"]; });
+
+/* harmony import */ var _referral__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(117);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Referral", function() { return _referral__WEBPACK_IMPORTED_MODULE_22__["Referral"]; });
+
+/* harmony import */ var _registerer_options__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(132);
+/* harmony import */ var _registerer_options__WEBPACK_IMPORTED_MODULE_23___default = /*#__PURE__*/__webpack_require__.n(_registerer_options__WEBPACK_IMPORTED_MODULE_23__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _registerer_options__WEBPACK_IMPORTED_MODULE_23__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _registerer_options__WEBPACK_IMPORTED_MODULE_23__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _registerer_register_options__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(133);
+/* harmony import */ var _registerer_register_options__WEBPACK_IMPORTED_MODULE_24___default = /*#__PURE__*/__webpack_require__.n(_registerer_register_options__WEBPACK_IMPORTED_MODULE_24__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _registerer_register_options__WEBPACK_IMPORTED_MODULE_24__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _registerer_register_options__WEBPACK_IMPORTED_MODULE_24__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _registerer_state__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(134);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RegistererState", function() { return _registerer_state__WEBPACK_IMPORTED_MODULE_25__["RegistererState"]; });
+
+/* harmony import */ var _registerer_unregister_options__WEBPACK_IMPORTED_MODULE_26__ = __webpack_require__(135);
+/* harmony import */ var _registerer_unregister_options__WEBPACK_IMPORTED_MODULE_26___default = /*#__PURE__*/__webpack_require__.n(_registerer_unregister_options__WEBPACK_IMPORTED_MODULE_26__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _registerer_unregister_options__WEBPACK_IMPORTED_MODULE_26__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _registerer_unregister_options__WEBPACK_IMPORTED_MODULE_26__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _registerer__WEBPACK_IMPORTED_MODULE_27__ = __webpack_require__(136);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Registerer", function() { return _registerer__WEBPACK_IMPORTED_MODULE_27__["Registerer"]; });
+
+/* harmony import */ var _session_bye_options__WEBPACK_IMPORTED_MODULE_28__ = __webpack_require__(137);
+/* harmony import */ var _session_bye_options__WEBPACK_IMPORTED_MODULE_28___default = /*#__PURE__*/__webpack_require__.n(_session_bye_options__WEBPACK_IMPORTED_MODULE_28__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_bye_options__WEBPACK_IMPORTED_MODULE_28__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_bye_options__WEBPACK_IMPORTED_MODULE_28__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_delegate__WEBPACK_IMPORTED_MODULE_29__ = __webpack_require__(138);
+/* harmony import */ var _session_delegate__WEBPACK_IMPORTED_MODULE_29___default = /*#__PURE__*/__webpack_require__.n(_session_delegate__WEBPACK_IMPORTED_MODULE_29__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_delegate__WEBPACK_IMPORTED_MODULE_29__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_delegate__WEBPACK_IMPORTED_MODULE_29__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_30__ = __webpack_require__(139);
+/* harmony import */ var _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_30___default = /*#__PURE__*/__webpack_require__.n(_session_description_handler_factory__WEBPACK_IMPORTED_MODULE_30__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_30__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_30__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_31__ = __webpack_require__(140);
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_31___default = /*#__PURE__*/__webpack_require__.n(_session_description_handler__WEBPACK_IMPORTED_MODULE_31__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler__WEBPACK_IMPORTED_MODULE_31__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler__WEBPACK_IMPORTED_MODULE_31__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_info_options__WEBPACK_IMPORTED_MODULE_32__ = __webpack_require__(141);
+/* harmony import */ var _session_info_options__WEBPACK_IMPORTED_MODULE_32___default = /*#__PURE__*/__webpack_require__.n(_session_info_options__WEBPACK_IMPORTED_MODULE_32__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_info_options__WEBPACK_IMPORTED_MODULE_32__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_info_options__WEBPACK_IMPORTED_MODULE_32__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_invite_options__WEBPACK_IMPORTED_MODULE_33__ = __webpack_require__(142);
+/* harmony import */ var _session_invite_options__WEBPACK_IMPORTED_MODULE_33___default = /*#__PURE__*/__webpack_require__.n(_session_invite_options__WEBPACK_IMPORTED_MODULE_33__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_invite_options__WEBPACK_IMPORTED_MODULE_33__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_invite_options__WEBPACK_IMPORTED_MODULE_33__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_message_options__WEBPACK_IMPORTED_MODULE_34__ = __webpack_require__(143);
+/* harmony import */ var _session_message_options__WEBPACK_IMPORTED_MODULE_34___default = /*#__PURE__*/__webpack_require__.n(_session_message_options__WEBPACK_IMPORTED_MODULE_34__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_message_options__WEBPACK_IMPORTED_MODULE_34__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_message_options__WEBPACK_IMPORTED_MODULE_34__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_options__WEBPACK_IMPORTED_MODULE_35__ = __webpack_require__(144);
+/* harmony import */ var _session_options__WEBPACK_IMPORTED_MODULE_35___default = /*#__PURE__*/__webpack_require__.n(_session_options__WEBPACK_IMPORTED_MODULE_35__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_options__WEBPACK_IMPORTED_MODULE_35__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_options__WEBPACK_IMPORTED_MODULE_35__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_refer_options__WEBPACK_IMPORTED_MODULE_36__ = __webpack_require__(145);
+/* harmony import */ var _session_refer_options__WEBPACK_IMPORTED_MODULE_36___default = /*#__PURE__*/__webpack_require__.n(_session_refer_options__WEBPACK_IMPORTED_MODULE_36__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_refer_options__WEBPACK_IMPORTED_MODULE_36__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_refer_options__WEBPACK_IMPORTED_MODULE_36__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_state__WEBPACK_IMPORTED_MODULE_37__ = __webpack_require__(118);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionState", function() { return _session_state__WEBPACK_IMPORTED_MODULE_37__["SessionState"]; });
+
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_38__ = __webpack_require__(114);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Session", function() { return _session__WEBPACK_IMPORTED_MODULE_38__["Session"]; });
+
+/* harmony import */ var _subscriber_options__WEBPACK_IMPORTED_MODULE_39__ = __webpack_require__(146);
+/* harmony import */ var _subscriber_options__WEBPACK_IMPORTED_MODULE_39___default = /*#__PURE__*/__webpack_require__.n(_subscriber_options__WEBPACK_IMPORTED_MODULE_39__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscriber_options__WEBPACK_IMPORTED_MODULE_39__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscriber_options__WEBPACK_IMPORTED_MODULE_39__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscriber_subscribe_options__WEBPACK_IMPORTED_MODULE_40__ = __webpack_require__(147);
+/* harmony import */ var _subscriber_subscribe_options__WEBPACK_IMPORTED_MODULE_40___default = /*#__PURE__*/__webpack_require__.n(_subscriber_subscribe_options__WEBPACK_IMPORTED_MODULE_40__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscriber_subscribe_options__WEBPACK_IMPORTED_MODULE_40__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscriber_subscribe_options__WEBPACK_IMPORTED_MODULE_40__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscriber__WEBPACK_IMPORTED_MODULE_41__ = __webpack_require__(148);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Subscriber", function() { return _subscriber__WEBPACK_IMPORTED_MODULE_41__["Subscriber"]; });
+
+/* harmony import */ var _subscription_delegate__WEBPACK_IMPORTED_MODULE_42__ = __webpack_require__(151);
+/* harmony import */ var _subscription_delegate__WEBPACK_IMPORTED_MODULE_42___default = /*#__PURE__*/__webpack_require__.n(_subscription_delegate__WEBPACK_IMPORTED_MODULE_42__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscription_delegate__WEBPACK_IMPORTED_MODULE_42__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","Subscriber","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscription_delegate__WEBPACK_IMPORTED_MODULE_42__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscription_options__WEBPACK_IMPORTED_MODULE_43__ = __webpack_require__(152);
+/* harmony import */ var _subscription_options__WEBPACK_IMPORTED_MODULE_43___default = /*#__PURE__*/__webpack_require__.n(_subscription_options__WEBPACK_IMPORTED_MODULE_43__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscription_options__WEBPACK_IMPORTED_MODULE_43__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","Subscriber","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscription_options__WEBPACK_IMPORTED_MODULE_43__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscription_state__WEBPACK_IMPORTED_MODULE_44__ = __webpack_require__(150);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscriptionState", function() { return _subscription_state__WEBPACK_IMPORTED_MODULE_44__["SubscriptionState"]; });
+
+/* harmony import */ var _subscription_subscribe_options__WEBPACK_IMPORTED_MODULE_45__ = __webpack_require__(153);
+/* harmony import */ var _subscription_subscribe_options__WEBPACK_IMPORTED_MODULE_45___default = /*#__PURE__*/__webpack_require__.n(_subscription_subscribe_options__WEBPACK_IMPORTED_MODULE_45__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscription_subscribe_options__WEBPACK_IMPORTED_MODULE_45__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","Subscriber","SubscriptionState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscription_subscribe_options__WEBPACK_IMPORTED_MODULE_45__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscription_unsubscribe_options__WEBPACK_IMPORTED_MODULE_46__ = __webpack_require__(154);
+/* harmony import */ var _subscription_unsubscribe_options__WEBPACK_IMPORTED_MODULE_46___default = /*#__PURE__*/__webpack_require__.n(_subscription_unsubscribe_options__WEBPACK_IMPORTED_MODULE_46__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscription_unsubscribe_options__WEBPACK_IMPORTED_MODULE_46__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","Subscriber","SubscriptionState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscription_unsubscribe_options__WEBPACK_IMPORTED_MODULE_46__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscription__WEBPACK_IMPORTED_MODULE_47__ = __webpack_require__(149);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Subscription", function() { return _subscription__WEBPACK_IMPORTED_MODULE_47__["Subscription"]; });
+
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_48__ = __webpack_require__(155);
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_48___default = /*#__PURE__*/__webpack_require__.n(_transport__WEBPACK_IMPORTED_MODULE_48__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _transport__WEBPACK_IMPORTED_MODULE_48__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","Subscriber","SubscriptionState","Subscription","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _transport__WEBPACK_IMPORTED_MODULE_48__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _transport_state__WEBPACK_IMPORTED_MODULE_49__ = __webpack_require__(156);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TransportState", function() { return _transport_state__WEBPACK_IMPORTED_MODULE_49__["TransportState"]; });
+
+/* harmony import */ var _user_agent_delegate__WEBPACK_IMPORTED_MODULE_50__ = __webpack_require__(157);
+/* harmony import */ var _user_agent_delegate__WEBPACK_IMPORTED_MODULE_50___default = /*#__PURE__*/__webpack_require__.n(_user_agent_delegate__WEBPACK_IMPORTED_MODULE_50__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _user_agent_delegate__WEBPACK_IMPORTED_MODULE_50__) if(["ContentTypeUnsupportedError","RequestPendingError","SessionDescriptionHandlerError","SessionTerminatedError","StateTransitionError","Bye","EmitterImpl","Info","Invitation","Inviter","Message","Messager","Notification","PublisherState","Publisher","Referral","RegistererState","Registerer","SessionState","Session","Subscriber","SubscriptionState","Subscription","TransportState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _user_agent_delegate__WEBPACK_IMPORTED_MODULE_50__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _user_agent_options__WEBPACK_IMPORTED_MODULE_51__ = __webpack_require__(119);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SIPExtension", function() { return _user_agent_options__WEBPACK_IMPORTED_MODULE_51__["SIPExtension"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentRegisteredOptionTags", function() { return _user_agent_options__WEBPACK_IMPORTED_MODULE_51__["UserAgentRegisteredOptionTags"]; });
+
+/* harmony import */ var _user_agent_state__WEBPACK_IMPORTED_MODULE_52__ = __webpack_require__(158);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentState", function() { return _user_agent_state__WEBPACK_IMPORTED_MODULE_52__["UserAgentState"]; });
+
+/* harmony import */ var _user_agent__WEBPACK_IMPORTED_MODULE_53__ = __webpack_require__(159);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgent", function() { return _user_agent__WEBPACK_IMPORTED_MODULE_53__["UserAgent"]; });
+
+/**
+ * A simple yet powerful API which takes care of SIP signaling and WebRTC media sessions for you.
+ * @packageDocumentation
+ */
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+/***/ }),
+/* 3 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _content_type_unsupported__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(4);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ContentTypeUnsupportedError", function() { return _content_type_unsupported__WEBPACK_IMPORTED_MODULE_0__["ContentTypeUnsupportedError"]; });
+
+/* harmony import */ var _request_pending__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(103);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RequestPendingError", function() { return _request_pending__WEBPACK_IMPORTED_MODULE_1__["RequestPendingError"]; });
+
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(104);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionDescriptionHandlerError", function() { return _session_description_handler__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandlerError"]; });
+
+/* harmony import */ var _session_terminated__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(105);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionTerminatedError", function() { return _session_terminated__WEBPACK_IMPORTED_MODULE_3__["SessionTerminatedError"]; });
+
+/* harmony import */ var _state_transition__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(106);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "StateTransitionError", function() { return _state_transition__WEBPACK_IMPORTED_MODULE_4__["StateTransitionError"]; });
+
+
+
+
+
+
+
+
+/***/ }),
+/* 4 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ContentTypeUnsupportedError", function() { return ContentTypeUnsupportedError; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * An exception indicating an unsupported content type prevented execution.
+ * @public
+ */
+class ContentTypeUnsupportedError extends _core__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ constructor(message) {
+ super(message ? message : "Unsupported content type.");
+ }
+}
+
+
+/***/ }),
+/* 5 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _dialogs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(6);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _dialogs__WEBPACK_IMPORTED_MODULE_0__) if(__WEBPACK_IMPORT_KEY__ !== 'default') (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _dialogs__WEBPACK_IMPORTED_MODULE_0__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(51);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Exception", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_1__["Exception"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TransactionStateError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_1__["TransactionStateError"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TransportError", function() { return _exceptions__WEBPACK_IMPORTED_MODULE_1__["TransportError"]; });
+
+/* harmony import */ var _log__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(84);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Levels", function() { return _log__WEBPACK_IMPORTED_MODULE_2__["Levels"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "LoggerFactory", function() { return _log__WEBPACK_IMPORTED_MODULE_2__["LoggerFactory"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Logger", function() { return _log__WEBPACK_IMPORTED_MODULE_2__["Logger"]; });
+
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(8);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _messages__WEBPACK_IMPORTED_MODULE_3__) if(["Exception","TransactionStateError","TransportError","Levels","LoggerFactory","Logger","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _messages__WEBPACK_IMPORTED_MODULE_3__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(44);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session__WEBPACK_IMPORTED_MODULE_4__) if(["Exception","TransactionStateError","TransportError","Levels","LoggerFactory","Logger","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session__WEBPACK_IMPORTED_MODULE_4__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscription__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(79);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscription__WEBPACK_IMPORTED_MODULE_5__) if(["Exception","TransactionStateError","TransportError","Levels","LoggerFactory","Logger","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscription__WEBPACK_IMPORTED_MODULE_5__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(48);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _transactions__WEBPACK_IMPORTED_MODULE_6__) if(["Exception","TransactionStateError","TransportError","Levels","LoggerFactory","Logger","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _transactions__WEBPACK_IMPORTED_MODULE_6__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _user_agent_core__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(88);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _user_agent_core__WEBPACK_IMPORTED_MODULE_7__) if(["Exception","TransactionStateError","TransportError","Levels","LoggerFactory","Logger","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _user_agent_core__WEBPACK_IMPORTED_MODULE_7__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _user_agents__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(90);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ByeUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ByeUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ByeUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ByeUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "CancelUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["CancelUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InfoUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["InfoUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InfoUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["InfoUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InviteUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["InviteUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InviteUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["InviteUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "MessageUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["MessageUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "MessageUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["MessageUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NotifyUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["NotifyUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NotifyUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["NotifyUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PublishUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["PublishUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PrackUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["PrackUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PrackUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["PrackUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReInviteUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ReInviteUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReInviteUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ReInviteUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReSubscribeUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ReSubscribeUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReSubscribeUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ReSubscribeUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReferUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ReferUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReferUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["ReferUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RegisterUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["RegisterUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RegisterUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["RegisterUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscribeUserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["SubscribeUserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscribeUserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["SubscribeUserAgentServer"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentClient", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["UserAgentClient"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentServer", function() { return _user_agents__WEBPACK_IMPORTED_MODULE_8__["UserAgentServer"]; });
+
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(47);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Timers", function() { return _timers__WEBPACK_IMPORTED_MODULE_9__["Timers"]; });
+
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(102);
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_10___default = /*#__PURE__*/__webpack_require__.n(_transport__WEBPACK_IMPORTED_MODULE_10__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _transport__WEBPACK_IMPORTED_MODULE_10__) if(["Exception","TransactionStateError","TransportError","Levels","LoggerFactory","Logger","ByeUserAgentClient","ByeUserAgentServer","CancelUserAgentClient","InfoUserAgentClient","InfoUserAgentServer","InviteUserAgentClient","InviteUserAgentServer","MessageUserAgentClient","MessageUserAgentServer","NotifyUserAgentClient","NotifyUserAgentServer","PublishUserAgentClient","PrackUserAgentClient","PrackUserAgentServer","ReInviteUserAgentClient","ReInviteUserAgentServer","ReSubscribeUserAgentClient","ReSubscribeUserAgentServer","ReferUserAgentClient","ReferUserAgentServer","RegisterUserAgentClient","RegisterUserAgentServer","SubscribeUserAgentClient","SubscribeUserAgentServer","UserAgentClient","UserAgentServer","Timers","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _transport__WEBPACK_IMPORTED_MODULE_10__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/**
+ * A core library implementing low level SIP protocol elements.
+ * @packageDocumentation
+ */
+// Directories
+
+
+
+
+
+
+
+
+
+// Files
+
+
+
+
+/***/ }),
+/* 6 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _dialog__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Dialog", function() { return _dialog__WEBPACK_IMPORTED_MODULE_0__["Dialog"]; });
+
+/* harmony import */ var _dialog_state__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(42);
+/* harmony import */ var _dialog_state__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_dialog_state__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _dialog_state__WEBPACK_IMPORTED_MODULE_1__) if(["Dialog","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _dialog_state__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_dialog__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(43);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionDialog", function() { return _session_dialog__WEBPACK_IMPORTED_MODULE_2__["SessionDialog"]; });
+
+/* harmony import */ var _subscription_dialog__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(78);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscriptionDialog", function() { return _subscription_dialog__WEBPACK_IMPORTED_MODULE_3__["SubscriptionDialog"]; });
+
+
+
+
+
+
+
+/***/ }),
+/* 7 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Dialog", function() { return Dialog; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+
+/**
+ * Dialog.
+ * @remarks
+ * A key concept for a user agent is that of a dialog. A dialog
+ * represents a peer-to-peer SIP relationship between two user agents
+ * that persists for some time. The dialog facilitates sequencing of
+ * messages between the user agents and proper routing of requests
+ * between both of them. The dialog represents a context in which to
+ * interpret SIP messages.
+ * https://tools.ietf.org/html/rfc3261#section-12
+ * @public
+ */
+class Dialog {
+ /**
+ * Dialog constructor.
+ * @param core - User agent core.
+ * @param dialogState - Initial dialog state.
+ */
+ constructor(core, dialogState) {
+ this.core = core;
+ this.dialogState = dialogState;
+ this.core.dialogs.set(this.id, this);
+ }
+ /**
+ * When a UAC receives a response that establishes a dialog, it
+ * constructs the state of the dialog. This state MUST be maintained
+ * for the duration of the dialog.
+ * https://tools.ietf.org/html/rfc3261#section-12.1.2
+ * @param outgoingRequestMessage - Outgoing request message for dialog.
+ * @param incomingResponseMessage - Incoming response message creating dialog.
+ */
+ static initialDialogStateForUserAgentClient(outgoingRequestMessage, incomingResponseMessage) {
+ // If the request was sent over TLS, and the Request-URI contained a
+ // SIPS URI, the "secure" flag is set to TRUE.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ const secure = false; // FIXME: Currently no support for TLS.
+ // The route set MUST be set to the list of URIs in the Record-Route
+ // header field from the response, taken in reverse order and preserving
+ // all URI parameters. If no Record-Route header field is present in
+ // the response, the route set MUST be set to the empty set. This route
+ // set, even if empty, overrides any pre-existing route set for future
+ // requests in this dialog. The remote target MUST be set to the URI
+ // from the Contact header field of the response.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ const routeSet = incomingResponseMessage.getHeaders("record-route").reverse();
+ // When a UAS responds to a request with a response that establishes a
+ // dialog (such as a 2xx to INVITE), the UAS MUST copy all Record-Route
+ // header field values from the request into the response (including the
+ // URIs, URI parameters, and any Record-Route header field parameters,
+ // whether they are known or unknown to the UAS) and MUST maintain the
+ // order of those values. The UAS MUST add a Contact header field to
+ // the response.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const contact = incomingResponseMessage.parseHeader("contact");
+ if (!contact) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("Contact undefined.");
+ }
+ if (!(contact instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"])) {
+ throw new Error("Contact not instance of NameAddrHeader.");
+ }
+ const remoteTarget = contact.uri;
+ // The local sequence number MUST be set to the value of the sequence
+ // number in the CSeq header field of the request. The remote sequence
+ // number MUST be empty (it is established when the remote UA sends a
+ // request within the dialog). The call identifier component of the
+ // dialog ID MUST be set to the value of the Call-ID in the request.
+ // The local tag component of the dialog ID MUST be set to the tag in
+ // the From field in the request, and the remote tag component of the
+ // dialog ID MUST be set to the tag in the To field of the response. A
+ // UAC MUST be prepared to receive a response without a tag in the To
+ // field, in which case the tag is considered to have a value of null.
+ //
+ // This is to maintain backwards compatibility with RFC 2543, which
+ // did not mandate To tags.
+ //
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ const localSequenceNumber = outgoingRequestMessage.cseq;
+ const remoteSequenceNumber = undefined;
+ const callId = outgoingRequestMessage.callId;
+ const localTag = outgoingRequestMessage.fromTag;
+ const remoteTag = incomingResponseMessage.toTag;
+ if (!callId) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("Call id undefined.");
+ }
+ if (!localTag) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("From tag undefined.");
+ }
+ if (!remoteTag) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("To tag undefined."); // FIXME: No backwards compatibility with RFC 2543
+ }
+ // The remote URI MUST be set to the URI in the To field, and the local
+ // URI MUST be set to the URI in the From field.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ if (!outgoingRequestMessage.from) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("From undefined.");
+ }
+ if (!outgoingRequestMessage.to) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("To undefined.");
+ }
+ const localURI = outgoingRequestMessage.from.uri;
+ const remoteURI = outgoingRequestMessage.to.uri;
+ // A dialog can also be in the "early" state, which occurs when it is
+ // created with a provisional response, and then transition to the
+ // "confirmed" state when a 2xx final response arrives.
+ // https://tools.ietf.org/html/rfc3261#section-12
+ if (!incomingResponseMessage.statusCode) {
+ throw new Error("Incoming response status code undefined.");
+ }
+ const early = incomingResponseMessage.statusCode < 200 ? true : false;
+ const dialogState = {
+ id: callId + localTag + remoteTag,
+ early,
+ callId,
+ localTag,
+ remoteTag,
+ localSequenceNumber,
+ remoteSequenceNumber,
+ localURI,
+ remoteURI,
+ remoteTarget,
+ routeSet,
+ secure
+ };
+ return dialogState;
+ }
+ /**
+ * The UAS then constructs the state of the dialog. This state MUST be
+ * maintained for the duration of the dialog.
+ * https://tools.ietf.org/html/rfc3261#section-12.1.1
+ * @param incomingRequestMessage - Incoming request message creating dialog.
+ * @param toTag - Tag in the To field in the response to the incoming request.
+ */
+ static initialDialogStateForUserAgentServer(incomingRequestMessage, toTag, early = false) {
+ // If the request arrived over TLS, and the Request-URI contained a SIPS
+ // URI, the "secure" flag is set to TRUE.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const secure = false; // FIXME: Currently no support for TLS.
+ // The route set MUST be set to the list of URIs in the Record-Route
+ // header field from the request, taken in order and preserving all URI
+ // parameters. If no Record-Route header field is present in the
+ // request, the route set MUST be set to the empty set. This route set,
+ // even if empty, overrides any pre-existing route set for future
+ // requests in this dialog. The remote target MUST be set to the URI
+ // from the Contact header field of the request.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const routeSet = incomingRequestMessage.getHeaders("record-route");
+ const contact = incomingRequestMessage.parseHeader("contact");
+ if (!contact) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("Contact undefined.");
+ }
+ if (!(contact instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"])) {
+ throw new Error("Contact not instance of NameAddrHeader.");
+ }
+ const remoteTarget = contact.uri;
+ // The remote sequence number MUST be set to the value of the sequence
+ // number in the CSeq header field of the request. The local sequence
+ // number MUST be empty. The call identifier component of the dialog ID
+ // MUST be set to the value of the Call-ID in the request. The local
+ // tag component of the dialog ID MUST be set to the tag in the To field
+ // in the response to the request (which always includes a tag), and the
+ // remote tag component of the dialog ID MUST be set to the tag from the
+ // From field in the request. A UAS MUST be prepared to receive a
+ // request without a tag in the From field, in which case the tag is
+ // considered to have a value of null.
+ //
+ // This is to maintain backwards compatibility with RFC 2543, which
+ // did not mandate From tags.
+ //
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const remoteSequenceNumber = incomingRequestMessage.cseq;
+ const localSequenceNumber = undefined;
+ const callId = incomingRequestMessage.callId;
+ const localTag = toTag;
+ const remoteTag = incomingRequestMessage.fromTag;
+ // The remote URI MUST be set to the URI in the From field, and the
+ // local URI MUST be set to the URI in the To field.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const remoteURI = incomingRequestMessage.from.uri;
+ const localURI = incomingRequestMessage.to.uri;
+ const dialogState = {
+ id: callId + localTag + remoteTag,
+ early,
+ callId,
+ localTag,
+ remoteTag,
+ localSequenceNumber,
+ remoteSequenceNumber,
+ localURI,
+ remoteURI,
+ remoteTarget,
+ routeSet,
+ secure
+ };
+ return dialogState;
+ }
+ /** Destructor. */
+ dispose() {
+ this.core.dialogs.delete(this.id);
+ }
+ /**
+ * A dialog is identified at each UA with a dialog ID, which consists of
+ * a Call-ID value, a local tag and a remote tag. The dialog ID at each
+ * UA involved in the dialog is not the same. Specifically, the local
+ * tag at one UA is identical to the remote tag at the peer UA. The
+ * tags are opaque tokens that facilitate the generation of unique
+ * dialog IDs.
+ * https://tools.ietf.org/html/rfc3261#section-12
+ */
+ get id() {
+ return this.dialogState.id;
+ }
+ /**
+ * A dialog can also be in the "early" state, which occurs when it is
+ * created with a provisional response, and then it transition to the
+ * "confirmed" state when a 2xx final response received or is sent.
+ *
+ * Note: RFC 3261 is concise on when a dialog is "confirmed", but it
+ * can be a point of confusion if an INVITE dialog is "confirmed" after
+ * a 2xx is sent or after receiving the ACK for the 2xx response.
+ * With careful reading it can be inferred a dialog is always is
+ * "confirmed" when the 2xx is sent (regardless of type of dialog).
+ * However a INVITE dialog does have additional considerations
+ * when it is confirmed but an ACK has not yet been received (in
+ * particular with regard to a callee sending BYE requests).
+ */
+ get early() {
+ return this.dialogState.early;
+ }
+ /** Call identifier component of the dialog id. */
+ get callId() {
+ return this.dialogState.callId;
+ }
+ /** Local tag component of the dialog id. */
+ get localTag() {
+ return this.dialogState.localTag;
+ }
+ /** Remote tag component of the dialog id. */
+ get remoteTag() {
+ return this.dialogState.remoteTag;
+ }
+ /** Local sequence number (used to order requests from the UA to its peer). */
+ get localSequenceNumber() {
+ return this.dialogState.localSequenceNumber;
+ }
+ /** Remote sequence number (used to order requests from its peer to the UA). */
+ get remoteSequenceNumber() {
+ return this.dialogState.remoteSequenceNumber;
+ }
+ /** Local URI. */
+ get localURI() {
+ return this.dialogState.localURI;
+ }
+ /** Remote URI. */
+ get remoteURI() {
+ return this.dialogState.remoteURI;
+ }
+ /** Remote target. */
+ get remoteTarget() {
+ return this.dialogState.remoteTarget;
+ }
+ /**
+ * Route set, which is an ordered list of URIs. The route set is the
+ * list of servers that need to be traversed to send a request to the peer.
+ */
+ get routeSet() {
+ return this.dialogState.routeSet;
+ }
+ /**
+ * If the request was sent over TLS, and the Request-URI contained
+ * a SIPS URI, the "secure" flag is set to true. *NOT IMPLEMENTED*
+ */
+ get secure() {
+ return this.dialogState.secure;
+ }
+ /** The user agent core servicing this dialog. */
+ get userAgentCore() {
+ return this.core;
+ }
+ /** Confirm the dialog. Only matters if dialog is currently early. */
+ confirm() {
+ this.dialogState.early = false;
+ }
+ /**
+ * Requests sent within a dialog, as any other requests, are atomic. If
+ * a particular request is accepted by the UAS, all the state changes
+ * associated with it are performed. If the request is rejected, none
+ * of the state changes are performed.
+ *
+ * Note that some requests, such as INVITEs, affect several pieces of
+ * state.
+ *
+ * https://tools.ietf.org/html/rfc3261#section-12.2.2
+ * @param message - Incoming request message within this dialog.
+ */
+ receiveRequest(message) {
+ // ACK guard.
+ // By convention, the handling of ACKs is the responsibility
+ // the particular dialog implementation. For example, see SessionDialog.
+ // Furthermore, ACKs have same sequence number as the associated INVITE.
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ return;
+ }
+ // If the remote sequence number was not empty, but the sequence number
+ // of the request is lower than the remote sequence number, the request
+ // is out of order and MUST be rejected with a 500 (Server Internal
+ // Error) response. If the remote sequence number was not empty, and
+ // the sequence number of the request is greater than the remote
+ // sequence number, the request is in order. It is possible for the
+ // CSeq sequence number to be higher than the remote sequence number by
+ // more than one. This is not an error condition, and a UAS SHOULD be
+ // prepared to receive and process requests with CSeq values more than
+ // one higher than the previous received request. The UAS MUST then set
+ // the remote sequence number to the value of the sequence number in the
+ // CSeq header field value in the request.
+ //
+ // If a proxy challenges a request generated by the UAC, the UAC has
+ // to resubmit the request with credentials. The resubmitted request
+ // will have a new CSeq number. The UAS will never see the first
+ // request, and thus, it will notice a gap in the CSeq number space.
+ // Such a gap does not represent any error condition.
+ //
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ if (this.remoteSequenceNumber) {
+ if (message.cseq <= this.remoteSequenceNumber) {
+ throw new Error("Out of sequence in dialog request. Did you forget to call sequenceGuard()?");
+ }
+ this.dialogState.remoteSequenceNumber = message.cseq;
+ }
+ // If the remote sequence number is empty, it MUST be set to the value
+ // of the sequence number in the CSeq header field value in the request.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ if (!this.remoteSequenceNumber) {
+ this.dialogState.remoteSequenceNumber = message.cseq;
+ }
+ // When a UAS receives a target refresh request, it MUST replace the
+ // dialog's remote target URI with the URI from the Contact header field
+ // in that request, if present.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ // Note: "target refresh request" processing delegated to sub-class.
+ }
+ /**
+ * If the dialog identifier in the 2xx response matches the dialog
+ * identifier of an existing dialog, the dialog MUST be transitioned to
+ * the "confirmed" state, and the route set for the dialog MUST be
+ * recomputed based on the 2xx response using the procedures of Section
+ * 12.2.1.2. Otherwise, a new dialog in the "confirmed" state MUST be
+ * constructed using the procedures of Section 12.1.2.
+ *
+ * Note that the only piece of state that is recomputed is the route
+ * set. Other pieces of state such as the highest sequence numbers
+ * (remote and local) sent within the dialog are not recomputed. The
+ * route set only is recomputed for backwards compatibility. RFC
+ * 2543 did not mandate mirroring of the Record-Route header field in
+ * a 1xx, only 2xx. However, we cannot update the entire state of
+ * the dialog, since mid-dialog requests may have been sent within
+ * the early dialog, modifying the sequence numbers, for example.
+ *
+ * https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ */
+ recomputeRouteSet(message) {
+ this.dialogState.routeSet = message.getHeaders("record-route").reverse();
+ }
+ /**
+ * A request within a dialog is constructed by using many of the
+ * components of the state stored as part of the dialog.
+ * https://tools.ietf.org/html/rfc3261#section-12.2.1.1
+ * @param method - Outgoing request method.
+ */
+ createOutgoingRequestMessage(method, options) {
+ // The URI in the To field of the request MUST be set to the remote URI
+ // from the dialog state. The tag in the To header field of the request
+ // MUST be set to the remote tag of the dialog ID. The From URI of the
+ // request MUST be set to the local URI from the dialog state. The tag
+ // in the From header field of the request MUST be set to the local tag
+ // of the dialog ID. If the value of the remote or local tags is null,
+ // the tag parameter MUST be omitted from the To or From header fields,
+ // respectively.
+ //
+ // Usage of the URI from the To and From fields in the original
+ // request within subsequent requests is done for backwards
+ // compatibility with RFC 2543, which used the URI for dialog
+ // identification. In this specification, only the tags are used for
+ // dialog identification. It is expected that mandatory reflection
+ // of the original To and From URI in mid-dialog requests will be
+ // deprecated in a subsequent revision of this specification.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.1.1
+ const toUri = this.remoteURI;
+ const toTag = this.remoteTag;
+ const fromUri = this.localURI;
+ const fromTag = this.localTag;
+ // The Call-ID of the request MUST be set to the Call-ID of the dialog.
+ // Requests within a dialog MUST contain strictly monotonically
+ // increasing and contiguous CSeq sequence numbers (increasing-by-one)
+ // in each direction (excepting ACK and CANCEL of course, whose numbers
+ // equal the requests being acknowledged or cancelled). Therefore, if
+ // the local sequence number is not empty, the value of the local
+ // sequence number MUST be incremented by one, and this value MUST be
+ // placed into the CSeq header field. If the local sequence number is
+ // empty, an initial value MUST be chosen using the guidelines of
+ // Section 8.1.1.5. The method field in the CSeq header field value
+ // MUST match the method of the request.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.1.1
+ const callId = this.callId;
+ let cseq;
+ if (options && options.cseq) {
+ cseq = options.cseq;
+ }
+ else if (!this.dialogState.localSequenceNumber) {
+ cseq = this.dialogState.localSequenceNumber = 1; // https://tools.ietf.org/html/rfc3261#section-8.1.1.5
+ }
+ else {
+ cseq = this.dialogState.localSequenceNumber += 1;
+ }
+ // The UAC uses the remote target and route set to build the Request-URI
+ // and Route header field of the request.
+ //
+ // If the route set is empty, the UAC MUST place the remote target URI
+ // into the Request-URI. The UAC MUST NOT add a Route header field to
+ // the request.
+ //
+ // If the route set is not empty, and the first URI in the route set
+ // contains the lr parameter (see Section 19.1.1), the UAC MUST place
+ // the remote target URI into the Request-URI and MUST include a Route
+ // header field containing the route set values in order, including all
+ // parameters.
+ //
+ // If the route set is not empty, and its first URI does not contain the
+ // lr parameter, the UAC MUST place the first URI from the route set
+ // into the Request-URI, stripping any parameters that are not allowed
+ // in a Request-URI. The UAC MUST add a Route header field containing
+ // the remainder of the route set values in order, including all
+ // parameters. The UAC MUST then place the remote target URI into the
+ // Route header field as the last value.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.1.1
+ // The lr parameter, when present, indicates that the element
+ // responsible for this resource implements the routing mechanisms
+ // specified in this document. This parameter will be used in the
+ // URIs proxies place into Record-Route header field values, and
+ // may appear in the URIs in a pre-existing route set.
+ //
+ // This parameter is used to achieve backwards compatibility with
+ // systems implementing the strict-routing mechanisms of RFC 2543
+ // and the rfc2543bis drafts up to bis-05. An element preparing
+ // to send a request based on a URI not containing this parameter
+ // can assume the receiving element implements strict-routing and
+ // reformat the message to preserve the information in the
+ // Request-URI.
+ // https://tools.ietf.org/html/rfc3261#section-19.1.1
+ // NOTE: Not backwards compatible with RFC 2543 (no support for strict-routing).
+ const ruri = this.remoteTarget;
+ const routeSet = this.routeSet;
+ const extraHeaders = options && options.extraHeaders;
+ const body = options && options.body;
+ // The relative order of header fields with different field names is not
+ // significant. However, it is RECOMMENDED that header fields which are
+ // needed for proxy processing (Via, Route, Record-Route, Proxy-Require,
+ // Max-Forwards, and Proxy-Authorization, for example) appear towards
+ // the top of the message to facilitate rapid parsing.
+ // https://tools.ietf.org/html/rfc3261#section-7.3.1
+ const message = this.userAgentCore.makeOutgoingRequestMessage(method, ruri, fromUri, toUri, {
+ callId,
+ cseq,
+ fromTag,
+ toTag,
+ routeSet
+ }, extraHeaders, body);
+ return message;
+ }
+ /**
+ * Increment the local sequence number by one.
+ * It feels like this should be protected, but the current authentication handling currently
+ * needs this to keep the dialog in sync when "auto re-sends" request messages.
+ * @internal
+ */
+ incrementLocalSequenceNumber() {
+ if (!this.dialogState.localSequenceNumber) {
+ throw new Error("Local sequence number undefined.");
+ }
+ this.dialogState.localSequenceNumber += 1;
+ }
+ /**
+ * If the remote sequence number was not empty, but the sequence number
+ * of the request is lower than the remote sequence number, the request
+ * is out of order and MUST be rejected with a 500 (Server Internal
+ * Error) response.
+ * https://tools.ietf.org/html/rfc3261#section-12.2.2
+ * @param request - Incoming request to guard.
+ * @returns True if the program execution is to continue in the branch in question.
+ * Otherwise a 500 Server Internal Error was stateless sent and request processing must stop.
+ */
+ sequenceGuard(message) {
+ // ACK guard.
+ // By convention, handling of unexpected ACKs is responsibility
+ // the particular dialog implementation. For example, see SessionDialog.
+ // Furthermore, we cannot reply to an "out of sequence" ACK.
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ return true;
+ }
+ // Note: We are rejecting on "less than or equal to" the remote
+ // sequence number (excepting ACK whose numbers equal the requests
+ // being acknowledged or cancelled), which is the correct thing to
+ // do in our case. The only time a request with the same sequence number
+ // will show up here if is a) it is a very late retransmission of a
+ // request we already handled or b) it is a different request with the
+ // same sequence number which would be violation of the standard.
+ // Request retransmissions are absorbed by the transaction layer,
+ // so any request with a duplicate sequence number getting here
+ // would have to be a retransmission after the transaction terminated
+ // or a broken request (with unique via branch value).
+ // Requests within a dialog MUST contain strictly monotonically
+ // increasing and contiguous CSeq sequence numbers (increasing-by-one)
+ // in each direction (excepting ACK and CANCEL of course, whose numbers
+ // equal the requests being acknowledged or cancelled). Therefore, if
+ // the local sequence number is not empty, the value of the local
+ // sequence number MUST be incremented by one, and this value MUST be
+ // placed into the CSeq header field.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.1.1
+ if (this.remoteSequenceNumber && message.cseq <= this.remoteSequenceNumber) {
+ this.core.replyStateless(message, { statusCode: 500 });
+ return false;
+ }
+ return true;
+ }
+}
+
+
+/***/ }),
+/* 8 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _grammar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Grammar", function() { return _grammar__WEBPACK_IMPORTED_MODULE_0__["Grammar"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NameAddrHeader", function() { return _grammar__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Parameters", function() { return _grammar__WEBPACK_IMPORTED_MODULE_0__["Parameters"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "URI", function() { return _grammar__WEBPACK_IMPORTED_MODULE_0__["URI"]; });
+
+/* harmony import */ var _methods__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(15);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _methods__WEBPACK_IMPORTED_MODULE_1__) if(["Grammar","NameAddrHeader","Parameters","URI","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _methods__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _body__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(29);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "fromBodyLegacy", function() { return _body__WEBPACK_IMPORTED_MODULE_2__["fromBodyLegacy"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "isBody", function() { return _body__WEBPACK_IMPORTED_MODULE_2__["isBody"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "getBody", function() { return _body__WEBPACK_IMPORTED_MODULE_2__["getBody"]; });
+
+/* harmony import */ var _digest_authentication__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(35);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "DigestAuthentication", function() { return _digest_authentication__WEBPACK_IMPORTED_MODULE_3__["DigestAuthentication"]; });
+
+/* harmony import */ var _incoming_message__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(31);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "IncomingMessage", function() { return _incoming_message__WEBPACK_IMPORTED_MODULE_4__["IncomingMessage"]; });
+
+/* harmony import */ var _incoming_request_message__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(30);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "IncomingRequestMessage", function() { return _incoming_request_message__WEBPACK_IMPORTED_MODULE_5__["IncomingRequestMessage"]; });
+
+/* harmony import */ var _incoming_request__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(37);
+/* harmony import */ var _incoming_request__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_incoming_request__WEBPACK_IMPORTED_MODULE_6__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _incoming_request__WEBPACK_IMPORTED_MODULE_6__) if(["Grammar","NameAddrHeader","Parameters","URI","fromBodyLegacy","isBody","getBody","DigestAuthentication","IncomingMessage","IncomingRequestMessage","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _incoming_request__WEBPACK_IMPORTED_MODULE_6__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _incoming_response_message__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(33);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "IncomingResponseMessage", function() { return _incoming_response_message__WEBPACK_IMPORTED_MODULE_7__["IncomingResponseMessage"]; });
+
+/* harmony import */ var _incoming_response__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(38);
+/* harmony import */ var _incoming_response__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(_incoming_response__WEBPACK_IMPORTED_MODULE_8__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _incoming_response__WEBPACK_IMPORTED_MODULE_8__) if(["Grammar","NameAddrHeader","Parameters","URI","fromBodyLegacy","isBody","getBody","DigestAuthentication","IncomingMessage","IncomingRequestMessage","IncomingResponseMessage","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _incoming_response__WEBPACK_IMPORTED_MODULE_8__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _outgoing_request_message__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(34);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "OutgoingRequestMessage", function() { return _outgoing_request_message__WEBPACK_IMPORTED_MODULE_9__["OutgoingRequestMessage"]; });
+
+/* harmony import */ var _outgoing_request__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(39);
+/* harmony import */ var _outgoing_request__WEBPACK_IMPORTED_MODULE_10___default = /*#__PURE__*/__webpack_require__.n(_outgoing_request__WEBPACK_IMPORTED_MODULE_10__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _outgoing_request__WEBPACK_IMPORTED_MODULE_10__) if(["Grammar","NameAddrHeader","Parameters","URI","fromBodyLegacy","isBody","getBody","DigestAuthentication","IncomingMessage","IncomingRequestMessage","IncomingResponseMessage","OutgoingRequestMessage","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _outgoing_request__WEBPACK_IMPORTED_MODULE_10__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _outgoing_response__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(40);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "constructOutgoingResponse", function() { return _outgoing_response__WEBPACK_IMPORTED_MODULE_11__["constructOutgoingResponse"]; });
+
+/* harmony import */ var _parser__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(41);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Parser", function() { return _parser__WEBPACK_IMPORTED_MODULE_12__["Parser"]; });
+
+// Grammar
+
+// Directories
+
+// Files
+
+
+
+
+
+
+
+
+
+
+
+
+
+/***/ }),
+/* 9 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _grammar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(10);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Grammar", function() { return _grammar__WEBPACK_IMPORTED_MODULE_0__["Grammar"]; });
+
+/* harmony import */ var _name_addr_header__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(12);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NameAddrHeader", function() { return _name_addr_header__WEBPACK_IMPORTED_MODULE_1__["NameAddrHeader"]; });
+
+/* harmony import */ var _parameters__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(13);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Parameters", function() { return _parameters__WEBPACK_IMPORTED_MODULE_2__["Parameters"]; });
+
+/* harmony import */ var _uri__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(14);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "URI", function() { return _uri__WEBPACK_IMPORTED_MODULE_3__["URI"]; });
+
+
+
+
+
+
+
+/***/ }),
+/* 10 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Grammar", function() { return Grammar; });
+/* harmony import */ var _pegjs_dist_grammar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(11);
+/* eslint-disable @typescript-eslint/no-namespace */
+/* eslint-disable no-inner-declarations */
+
+/**
+ * Grammar.
+ * @internal
+ */
+var Grammar;
+(function (Grammar) {
+ /**
+ * Parse.
+ * @param input -
+ * @param startRule -
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ function parse(input, startRule) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const options = { startRule };
+ try {
+ _pegjs_dist_grammar__WEBPACK_IMPORTED_MODULE_0__["parse"](input, options);
+ }
+ catch (e) {
+ options.data = -1;
+ }
+ return options.data;
+ }
+ Grammar.parse = parse;
+ /**
+ * Parse the given string and returns a SIP.NameAddrHeader instance or undefined if
+ * it is an invalid NameAddrHeader.
+ * @param name_addr_header -
+ */
+ function nameAddrHeaderParse(nameAddrHeader) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parsedNameAddrHeader = Grammar.parse(nameAddrHeader, "Name_Addr_Header");
+ return parsedNameAddrHeader !== -1 ? parsedNameAddrHeader : undefined;
+ }
+ Grammar.nameAddrHeaderParse = nameAddrHeaderParse;
+ /**
+ * Parse the given string and returns a SIP.URI instance or undefined if
+ * it is an invalid URI.
+ * @param uri -
+ */
+ function URIParse(uri) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parsedUri = Grammar.parse(uri, "SIP_URI");
+ return parsedUri !== -1 ? parsedUri : undefined;
+ }
+ Grammar.URIParse = URIParse;
+})(Grammar || (Grammar = {}));
+
+
+/***/ }),
+/* 11 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SyntaxError", function() { return SyntaxError; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "parse", function() { return parse; });
+/* harmony import */ var _name_addr_header__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(12);
+/* harmony import */ var _uri__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(14);
+// tslint:disable:interface-name
+// tslint:disable: trailing-comma
+// tslint:disable: object-literal-sort-keys
+// tslint:disable: max-line-length
+// tslint:disable: only-arrow-functions
+// tslint:disable: one-variable-per-declaration
+// tslint:disable: no-consecutive-blank-lines
+// tslint:disable: align
+// tslint:disable: radix
+// tslint:disable: quotemark
+// tslint:disable: semicolon
+// tslint:disable: object-literal-shorthand
+// tslint:disable: variable-name
+// tslint:disable: no-var-keyword
+// tslint:disable: whitespace
+// tslint:disable: curly
+// tslint:disable: prefer-const
+// tslint:disable: object-literal-key-quotes
+// tslint:disable: no-string-literal
+// tslint:disable: one-line
+// tslint:disable: no-unused-expression
+// tslint:disable: space-before-function-paren
+// tslint:disable: arrow-return-shorthand
+// Generated by PEG.js v. 0.10.0 (ts-pegjs plugin v. 0.2.6 )
+//
+// https://pegjs.org/ https://github.com/metadevpro/ts-pegjs
+
+
+class SyntaxError extends Error {
+ constructor(message, expected, found, location) {
+ super();
+ this.message = message;
+ this.expected = expected;
+ this.found = found;
+ this.location = location;
+ this.name = "SyntaxError";
+ if (typeof Error.captureStackTrace === "function") {
+ Error.captureStackTrace(this, SyntaxError);
+ }
+ }
+ static buildMessage(expected, found) {
+ function hex(ch) {
+ return ch.charCodeAt(0).toString(16).toUpperCase();
+ }
+ function literalEscape(s) {
+ return s
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, "\\\"")
+ .replace(/\0/g, "\\0")
+ .replace(/\t/g, "\\t")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[\x00-\x0F]/g, (ch) => "\\x0" + hex(ch))
+ .replace(/[\x10-\x1F\x7F-\x9F]/g, (ch) => "\\x" + hex(ch));
+ }
+ function classEscape(s) {
+ return s
+ .replace(/\\/g, "\\\\")
+ .replace(/\]/g, "\\]")
+ .replace(/\^/g, "\\^")
+ .replace(/-/g, "\\-")
+ .replace(/\0/g, "\\0")
+ .replace(/\t/g, "\\t")
+ .replace(/\n/g, "\\n")
+ .replace(/\r/g, "\\r")
+ .replace(/[\x00-\x0F]/g, (ch) => "\\x0" + hex(ch))
+ .replace(/[\x10-\x1F\x7F-\x9F]/g, (ch) => "\\x" + hex(ch));
+ }
+ function describeExpectation(expectation) {
+ switch (expectation.type) {
+ case "literal":
+ return "\"" + literalEscape(expectation.text) + "\"";
+ case "class":
+ const escapedParts = expectation.parts.map((part) => {
+ return Array.isArray(part)
+ ? classEscape(part[0]) + "-" + classEscape(part[1])
+ : classEscape(part);
+ });
+ return "[" + (expectation.inverted ? "^" : "") + escapedParts + "]";
+ case "any":
+ return "any character";
+ case "end":
+ return "end of input";
+ case "other":
+ return expectation.description;
+ }
+ }
+ function describeExpected(expected1) {
+ const descriptions = expected1.map(describeExpectation);
+ let i;
+ let j;
+ descriptions.sort();
+ if (descriptions.length > 0) {
+ for (i = 1, j = 1; i < descriptions.length; i++) {
+ if (descriptions[i - 1] !== descriptions[i]) {
+ descriptions[j] = descriptions[i];
+ j++;
+ }
+ }
+ descriptions.length = j;
+ }
+ switch (descriptions.length) {
+ case 1:
+ return descriptions[0];
+ case 2:
+ return descriptions[0] + " or " + descriptions[1];
+ default:
+ return descriptions.slice(0, -1).join(", ")
+ + ", or "
+ + descriptions[descriptions.length - 1];
+ }
+ }
+ function describeFound(found1) {
+ return found1 ? "\"" + literalEscape(found1) + "\"" : "end of input";
+ }
+ return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found.";
+ }
+}
+function peg$parse(input, options) {
+ options = options !== undefined ? options : {};
+ const peg$FAILED = {};
+ const peg$startRuleIndices = { Contact: 119, Name_Addr_Header: 156, Record_Route: 176, Request_Response: 81, SIP_URI: 45, Subscription_State: 186, Supported: 191, Require: 182, Via: 194, absoluteURI: 84, Call_ID: 118, Content_Disposition: 130, Content_Length: 135, Content_Type: 136, CSeq: 146, displayName: 122, Event: 149, From: 151, host: 52, Max_Forwards: 154, Min_SE: 213, Proxy_Authenticate: 157, quoted_string: 40, Refer_To: 178, Replaces: 179, Session_Expires: 210, stun_URI: 217, To: 192, turn_URI: 223, uuid: 226, WWW_Authenticate: 209, challenge: 158, sipfrag: 230, Referred_By: 231 };
+ let peg$startRuleIndex = 119;
+ const peg$consts = [
+ "\r\n",
+ peg$literalExpectation("\r\n", false),
+ /^[0-9]/,
+ peg$classExpectation([["0", "9"]], false, false),
+ /^[a-zA-Z]/,
+ peg$classExpectation([["a", "z"], ["A", "Z"]], false, false),
+ /^[0-9a-fA-F]/,
+ peg$classExpectation([["0", "9"], ["a", "f"], ["A", "F"]], false, false),
+ /^[\0-\xFF]/,
+ peg$classExpectation([["\0", "\xFF"]], false, false),
+ /^["]/,
+ peg$classExpectation(["\""], false, false),
+ " ",
+ peg$literalExpectation(" ", false),
+ "\t",
+ peg$literalExpectation("\t", false),
+ /^[a-zA-Z0-9]/,
+ peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"]], false, false),
+ ";",
+ peg$literalExpectation(";", false),
+ "/",
+ peg$literalExpectation("/", false),
+ "?",
+ peg$literalExpectation("?", false),
+ ":",
+ peg$literalExpectation(":", false),
+ "@",
+ peg$literalExpectation("@", false),
+ "&",
+ peg$literalExpectation("&", false),
+ "=",
+ peg$literalExpectation("=", false),
+ "+",
+ peg$literalExpectation("+", false),
+ "$",
+ peg$literalExpectation("$", false),
+ ",",
+ peg$literalExpectation(",", false),
+ "-",
+ peg$literalExpectation("-", false),
+ "_",
+ peg$literalExpectation("_", false),
+ ".",
+ peg$literalExpectation(".", false),
+ "!",
+ peg$literalExpectation("!", false),
+ "~",
+ peg$literalExpectation("~", false),
+ "*",
+ peg$literalExpectation("*", false),
+ "'",
+ peg$literalExpectation("'", false),
+ "(",
+ peg$literalExpectation("(", false),
+ ")",
+ peg$literalExpectation(")", false),
+ "%",
+ peg$literalExpectation("%", false),
+ function () { return " "; },
+ function () { return ':'; },
+ /^[!-~]/,
+ peg$classExpectation([["!", "~"]], false, false),
+ /^[\x80-\uFFFF]/,
+ peg$classExpectation([["\x80", "\uFFFF"]], false, false),
+ /^[\x80-\xBF]/,
+ peg$classExpectation([["\x80", "\xBF"]], false, false),
+ /^[a-f]/,
+ peg$classExpectation([["a", "f"]], false, false),
+ "`",
+ peg$literalExpectation("`", false),
+ "<",
+ peg$literalExpectation("<", false),
+ ">",
+ peg$literalExpectation(">", false),
+ "\\",
+ peg$literalExpectation("\\", false),
+ "[",
+ peg$literalExpectation("[", false),
+ "]",
+ peg$literalExpectation("]", false),
+ "{",
+ peg$literalExpectation("{", false),
+ "}",
+ peg$literalExpectation("}", false),
+ function () { return "*"; },
+ function () { return "/"; },
+ function () { return "="; },
+ function () { return "("; },
+ function () { return ")"; },
+ function () { return ">"; },
+ function () { return "<"; },
+ function () { return ","; },
+ function () { return ";"; },
+ function () { return ":"; },
+ function () { return "\""; },
+ /^[!-']/,
+ peg$classExpectation([["!", "'"]], false, false),
+ /^[*-[]/,
+ peg$classExpectation([["*", "["]], false, false),
+ /^[\]-~]/,
+ peg$classExpectation([["]", "~"]], false, false),
+ function (contents) {
+ return contents;
+ },
+ /^[#-[]/,
+ peg$classExpectation([["#", "["]], false, false),
+ /^[\0-\t]/,
+ peg$classExpectation([["\0", "\t"]], false, false),
+ /^[\x0B-\f]/,
+ peg$classExpectation([["\x0B", "\f"]], false, false),
+ /^[\x0E-\x7F]/,
+ peg$classExpectation([["\x0E", "\x7F"]], false, false),
+ function () {
+ options = options || { data: {} };
+ options.data.uri = new _uri__WEBPACK_IMPORTED_MODULE_1__["URI"](options.data.scheme, options.data.user, options.data.host, options.data.port);
+ delete options.data.scheme;
+ delete options.data.user;
+ delete options.data.host;
+ delete options.data.host_type;
+ delete options.data.port;
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.uri = new _uri__WEBPACK_IMPORTED_MODULE_1__["URI"](options.data.scheme, options.data.user, options.data.host, options.data.port, options.data.uri_params, options.data.uri_headers);
+ delete options.data.scheme;
+ delete options.data.user;
+ delete options.data.host;
+ delete options.data.host_type;
+ delete options.data.port;
+ delete options.data.uri_params;
+ if (options.startRule === 'SIP_URI') {
+ options.data = options.data.uri;
+ }
+ },
+ "sips",
+ peg$literalExpectation("sips", true),
+ "sip",
+ peg$literalExpectation("sip", true),
+ function (uri_scheme) {
+ options = options || { data: {} };
+ options.data.scheme = uri_scheme;
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.user = decodeURIComponent(text().slice(0, -1));
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.password = text();
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.host = text();
+ return options.data.host;
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.host_type = 'domain';
+ return text();
+ },
+ /^[a-zA-Z0-9_\-]/,
+ peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "_", "-"], false, false),
+ /^[a-zA-Z0-9\-]/,
+ peg$classExpectation([["a", "z"], ["A", "Z"], ["0", "9"], "-"], false, false),
+ function () {
+ options = options || { data: {} };
+ options.data.host_type = 'IPv6';
+ return text();
+ },
+ "::",
+ peg$literalExpectation("::", false),
+ function () {
+ options = options || { data: {} };
+ options.data.host_type = 'IPv6';
+ return text();
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.host_type = 'IPv4';
+ return text();
+ },
+ "25",
+ peg$literalExpectation("25", false),
+ /^[0-5]/,
+ peg$classExpectation([["0", "5"]], false, false),
+ "2",
+ peg$literalExpectation("2", false),
+ /^[0-4]/,
+ peg$classExpectation([["0", "4"]], false, false),
+ "1",
+ peg$literalExpectation("1", false),
+ /^[1-9]/,
+ peg$classExpectation([["1", "9"]], false, false),
+ function (port) {
+ options = options || { data: {} };
+ port = parseInt(port.join(''));
+ options.data.port = port;
+ return port;
+ },
+ "transport=",
+ peg$literalExpectation("transport=", true),
+ "udp",
+ peg$literalExpectation("udp", true),
+ "tcp",
+ peg$literalExpectation("tcp", true),
+ "sctp",
+ peg$literalExpectation("sctp", true),
+ "tls",
+ peg$literalExpectation("tls", true),
+ function (transport) {
+ options = options || { data: {} };
+ if (!options.data.uri_params)
+ options.data.uri_params = {};
+ options.data.uri_params['transport'] = transport.toLowerCase();
+ },
+ "user=",
+ peg$literalExpectation("user=", true),
+ "phone",
+ peg$literalExpectation("phone", true),
+ "ip",
+ peg$literalExpectation("ip", true),
+ function (user) {
+ options = options || { data: {} };
+ if (!options.data.uri_params)
+ options.data.uri_params = {};
+ options.data.uri_params['user'] = user.toLowerCase();
+ },
+ "method=",
+ peg$literalExpectation("method=", true),
+ function (method) {
+ options = options || { data: {} };
+ if (!options.data.uri_params)
+ options.data.uri_params = {};
+ options.data.uri_params['method'] = method;
+ },
+ "ttl=",
+ peg$literalExpectation("ttl=", true),
+ function (ttl) {
+ options = options || { data: {} };
+ if (!options.data.params)
+ options.data.params = {};
+ options.data.params['ttl'] = ttl;
+ },
+ "maddr=",
+ peg$literalExpectation("maddr=", true),
+ function (maddr) {
+ options = options || { data: {} };
+ if (!options.data.uri_params)
+ options.data.uri_params = {};
+ options.data.uri_params['maddr'] = maddr;
+ },
+ "lr",
+ peg$literalExpectation("lr", true),
+ function () {
+ options = options || { data: {} };
+ if (!options.data.uri_params)
+ options.data.uri_params = {};
+ options.data.uri_params['lr'] = undefined;
+ },
+ function (param, value) {
+ options = options || { data: {} };
+ if (!options.data.uri_params)
+ options.data.uri_params = {};
+ if (value === null) {
+ value = undefined;
+ }
+ else {
+ value = value[1];
+ }
+ options.data.uri_params[param.toLowerCase()] = value;
+ },
+ function (hname, hvalue) {
+ hname = hname.join('').toLowerCase();
+ hvalue = hvalue.join('');
+ options = options || { data: {} };
+ if (!options.data.uri_headers)
+ options.data.uri_headers = {};
+ if (!options.data.uri_headers[hname]) {
+ options.data.uri_headers[hname] = [hvalue];
+ }
+ else {
+ options.data.uri_headers[hname].push(hvalue);
+ }
+ },
+ function () {
+ options = options || { data: {} };
+ // lots of tests fail if this isn't guarded...
+ if (options.startRule === 'Refer_To') {
+ options.data.uri = new _uri__WEBPACK_IMPORTED_MODULE_1__["URI"](options.data.scheme, options.data.user, options.data.host, options.data.port, options.data.uri_params, options.data.uri_headers);
+ delete options.data.scheme;
+ delete options.data.user;
+ delete options.data.host;
+ delete options.data.host_type;
+ delete options.data.port;
+ delete options.data.uri_params;
+ }
+ },
+ "//",
+ peg$literalExpectation("//", false),
+ function () {
+ options = options || { data: {} };
+ options.data.scheme = text();
+ },
+ peg$literalExpectation("SIP", true),
+ function () {
+ options = options || { data: {} };
+ options.data.sip_version = text();
+ },
+ "INVITE",
+ peg$literalExpectation("INVITE", false),
+ "ACK",
+ peg$literalExpectation("ACK", false),
+ "VXACH",
+ peg$literalExpectation("VXACH", false),
+ "OPTIONS",
+ peg$literalExpectation("OPTIONS", false),
+ "BYE",
+ peg$literalExpectation("BYE", false),
+ "CANCEL",
+ peg$literalExpectation("CANCEL", false),
+ "REGISTER",
+ peg$literalExpectation("REGISTER", false),
+ "SUBSCRIBE",
+ peg$literalExpectation("SUBSCRIBE", false),
+ "NOTIFY",
+ peg$literalExpectation("NOTIFY", false),
+ "REFER",
+ peg$literalExpectation("REFER", false),
+ "PUBLISH",
+ peg$literalExpectation("PUBLISH", false),
+ function () {
+ options = options || { data: {} };
+ options.data.method = text();
+ return options.data.method;
+ },
+ function (status_code) {
+ options = options || { data: {} };
+ options.data.status_code = parseInt(status_code.join(''));
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.reason_phrase = text();
+ },
+ function () {
+ options = options || { data: {} };
+ options.data = text();
+ },
+ function () {
+ var idx, length;
+ options = options || { data: {} };
+ length = options.data.multi_header.length;
+ for (idx = 0; idx < length; idx++) {
+ if (options.data.multi_header[idx].parsed === null) {
+ options.data = null;
+ break;
+ }
+ }
+ if (options.data !== null) {
+ options.data = options.data.multi_header;
+ }
+ else {
+ options.data = -1;
+ }
+ },
+ function () {
+ var header;
+ options = options || { data: {} };
+ if (!options.data.multi_header)
+ options.data.multi_header = [];
+ try {
+ header = new _name_addr_header__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](options.data.uri, options.data.displayName, options.data.params);
+ delete options.data.uri;
+ delete options.data.displayName;
+ delete options.data.params;
+ }
+ catch (e) {
+ header = null;
+ }
+ options.data.multi_header.push({ 'position': peg$currPos,
+ 'offset': location().start.offset,
+ 'parsed': header
+ });
+ },
+ function (displayName) {
+ displayName = text().trim();
+ if (displayName[0] === '\"') {
+ displayName = displayName.substring(1, displayName.length - 1);
+ }
+ options = options || { data: {} };
+ options.data.displayName = displayName;
+ },
+ "q",
+ peg$literalExpectation("q", true),
+ function (q) {
+ options = options || { data: {} };
+ if (!options.data.params)
+ options.data.params = {};
+ options.data.params['q'] = q;
+ },
+ "expires",
+ peg$literalExpectation("expires", true),
+ function (expires) {
+ options = options || { data: {} };
+ if (!options.data.params)
+ options.data.params = {};
+ options.data.params['expires'] = expires;
+ },
+ function (delta_seconds) {
+ return parseInt(delta_seconds.join(''));
+ },
+ "0",
+ peg$literalExpectation("0", false),
+ function () {
+ return parseFloat(text());
+ },
+ function (param, value) {
+ options = options || { data: {} };
+ if (!options.data.params)
+ options.data.params = {};
+ if (value === null) {
+ value = undefined;
+ }
+ else {
+ value = value[1];
+ }
+ options.data.params[param.toLowerCase()] = value;
+ },
+ "render",
+ peg$literalExpectation("render", true),
+ "session",
+ peg$literalExpectation("session", true),
+ "icon",
+ peg$literalExpectation("icon", true),
+ "alert",
+ peg$literalExpectation("alert", true),
+ function () {
+ options = options || { data: {} };
+ if (options.startRule === 'Content_Disposition') {
+ options.data.type = text().toLowerCase();
+ }
+ },
+ "handling",
+ peg$literalExpectation("handling", true),
+ "optional",
+ peg$literalExpectation("optional", true),
+ "required",
+ peg$literalExpectation("required", true),
+ function (length) {
+ options = options || { data: {} };
+ options.data = parseInt(length.join(''));
+ },
+ function () {
+ options = options || { data: {} };
+ options.data = text();
+ },
+ "text",
+ peg$literalExpectation("text", true),
+ "image",
+ peg$literalExpectation("image", true),
+ "audio",
+ peg$literalExpectation("audio", true),
+ "video",
+ peg$literalExpectation("video", true),
+ "application",
+ peg$literalExpectation("application", true),
+ "message",
+ peg$literalExpectation("message", true),
+ "multipart",
+ peg$literalExpectation("multipart", true),
+ "x-",
+ peg$literalExpectation("x-", true),
+ function (cseq_value) {
+ options = options || { data: {} };
+ options.data.value = parseInt(cseq_value.join(''));
+ },
+ function (expires) { options = options || { data: {} }; options.data = expires; },
+ function (event_type) {
+ options = options || { data: {} };
+ options.data.event = event_type.toLowerCase();
+ },
+ function () {
+ options = options || { data: {} };
+ var tag = options.data.tag;
+ options.data = new _name_addr_header__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](options.data.uri, options.data.displayName, options.data.params);
+ if (tag) {
+ options.data.setParam('tag', tag);
+ }
+ },
+ "tag",
+ peg$literalExpectation("tag", true),
+ function (tag) { options = options || { data: {} }; options.data.tag = tag; },
+ function (forwards) {
+ options = options || { data: {} };
+ options.data = parseInt(forwards.join(''));
+ },
+ function (min_expires) { options = options || { data: {} }; options.data = min_expires; },
+ function () {
+ options = options || { data: {} };
+ options.data = new _name_addr_header__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](options.data.uri, options.data.displayName, options.data.params);
+ },
+ "digest",
+ peg$literalExpectation("Digest", true),
+ "realm",
+ peg$literalExpectation("realm", true),
+ function (realm) { options = options || { data: {} }; options.data.realm = realm; },
+ "domain",
+ peg$literalExpectation("domain", true),
+ "nonce",
+ peg$literalExpectation("nonce", true),
+ function (nonce) { options = options || { data: {} }; options.data.nonce = nonce; },
+ "opaque",
+ peg$literalExpectation("opaque", true),
+ function (opaque) { options = options || { data: {} }; options.data.opaque = opaque; },
+ "stale",
+ peg$literalExpectation("stale", true),
+ "true",
+ peg$literalExpectation("true", true),
+ function () { options = options || { data: {} }; options.data.stale = true; },
+ "false",
+ peg$literalExpectation("false", true),
+ function () { options = options || { data: {} }; options.data.stale = false; },
+ "algorithm",
+ peg$literalExpectation("algorithm", true),
+ "md5",
+ peg$literalExpectation("MD5", true),
+ "md5-sess",
+ peg$literalExpectation("MD5-sess", true),
+ function (algorithm) {
+ options = options || { data: {} };
+ options.data.algorithm = algorithm.toUpperCase();
+ },
+ "qop",
+ peg$literalExpectation("qop", true),
+ "auth-int",
+ peg$literalExpectation("auth-int", true),
+ "auth",
+ peg$literalExpectation("auth", true),
+ function (qop_value) {
+ options = options || { data: {} };
+ options.data.qop || (options.data.qop = []);
+ options.data.qop.push(qop_value.toLowerCase());
+ },
+ function (rack_value) {
+ options = options || { data: {} };
+ options.data.value = parseInt(rack_value.join(''));
+ },
+ function () {
+ var idx, length;
+ options = options || { data: {} };
+ length = options.data.multi_header.length;
+ for (idx = 0; idx < length; idx++) {
+ if (options.data.multi_header[idx].parsed === null) {
+ options.data = null;
+ break;
+ }
+ }
+ if (options.data !== null) {
+ options.data = options.data.multi_header;
+ }
+ else {
+ options.data = -1;
+ }
+ },
+ function () {
+ var header;
+ options = options || { data: {} };
+ if (!options.data.multi_header)
+ options.data.multi_header = [];
+ try {
+ header = new _name_addr_header__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](options.data.uri, options.data.displayName, options.data.params);
+ delete options.data.uri;
+ delete options.data.displayName;
+ delete options.data.params;
+ }
+ catch (e) {
+ header = null;
+ }
+ options.data.multi_header.push({ 'position': peg$currPos,
+ 'offset': location().start.offset,
+ 'parsed': header
+ });
+ },
+ function () {
+ options = options || { data: {} };
+ options.data = new _name_addr_header__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](options.data.uri, options.data.displayName, options.data.params);
+ },
+ function () {
+ options = options || { data: {} };
+ if (!(options.data.replaces_from_tag && options.data.replaces_to_tag)) {
+ options.data = -1;
+ }
+ },
+ function () {
+ options = options || { data: {} };
+ options.data = {
+ call_id: options.data
+ };
+ },
+ "from-tag",
+ peg$literalExpectation("from-tag", true),
+ function (from_tag) {
+ options = options || { data: {} };
+ options.data.replaces_from_tag = from_tag;
+ },
+ "to-tag",
+ peg$literalExpectation("to-tag", true),
+ function (to_tag) {
+ options = options || { data: {} };
+ options.data.replaces_to_tag = to_tag;
+ },
+ "early-only",
+ peg$literalExpectation("early-only", true),
+ function () {
+ options = options || { data: {} };
+ options.data.early_only = true;
+ },
+ function (head, r) { return r; },
+ function (head, tail) { return list(head, tail); },
+ function (value) {
+ options = options || { data: {} };
+ if (options.startRule === 'Require') {
+ options.data = value || [];
+ }
+ },
+ function (rseq_value) {
+ options = options || { data: {} };
+ options.data.value = parseInt(rseq_value.join(''));
+ },
+ "active",
+ peg$literalExpectation("active", true),
+ "pending",
+ peg$literalExpectation("pending", true),
+ "terminated",
+ peg$literalExpectation("terminated", true),
+ function () {
+ options = options || { data: {} };
+ options.data.state = text();
+ },
+ "reason",
+ peg$literalExpectation("reason", true),
+ function (reason) {
+ options = options || { data: {} };
+ if (typeof reason !== 'undefined')
+ options.data.reason = reason;
+ },
+ function (expires) {
+ options = options || { data: {} };
+ if (typeof expires !== 'undefined')
+ options.data.expires = expires;
+ },
+ "retry_after",
+ peg$literalExpectation("retry_after", true),
+ function (retry_after) {
+ options = options || { data: {} };
+ if (typeof retry_after !== 'undefined')
+ options.data.retry_after = retry_after;
+ },
+ "deactivated",
+ peg$literalExpectation("deactivated", true),
+ "probation",
+ peg$literalExpectation("probation", true),
+ "rejected",
+ peg$literalExpectation("rejected", true),
+ "timeout",
+ peg$literalExpectation("timeout", true),
+ "giveup",
+ peg$literalExpectation("giveup", true),
+ "noresource",
+ peg$literalExpectation("noresource", true),
+ "invariant",
+ peg$literalExpectation("invariant", true),
+ function (value) {
+ options = options || { data: {} };
+ if (options.startRule === 'Supported') {
+ options.data = value || [];
+ }
+ },
+ function () {
+ options = options || { data: {} };
+ var tag = options.data.tag;
+ options.data = new _name_addr_header__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](options.data.uri, options.data.displayName, options.data.params);
+ if (tag) {
+ options.data.setParam('tag', tag);
+ }
+ },
+ "ttl",
+ peg$literalExpectation("ttl", true),
+ function (via_ttl_value) {
+ options = options || { data: {} };
+ options.data.ttl = via_ttl_value;
+ },
+ "maddr",
+ peg$literalExpectation("maddr", true),
+ function (via_maddr) {
+ options = options || { data: {} };
+ options.data.maddr = via_maddr;
+ },
+ "received",
+ peg$literalExpectation("received", true),
+ function (via_received) {
+ options = options || { data: {} };
+ options.data.received = via_received;
+ },
+ "branch",
+ peg$literalExpectation("branch", true),
+ function (via_branch) {
+ options = options || { data: {} };
+ options.data.branch = via_branch;
+ },
+ "rport",
+ peg$literalExpectation("rport", true),
+ function (response_port) {
+ options = options || { data: {} };
+ if (typeof response_port !== 'undefined')
+ options.data.rport = response_port.join('');
+ },
+ function (via_protocol) {
+ options = options || { data: {} };
+ options.data.protocol = via_protocol;
+ },
+ peg$literalExpectation("UDP", true),
+ peg$literalExpectation("TCP", true),
+ peg$literalExpectation("TLS", true),
+ peg$literalExpectation("SCTP", true),
+ function (via_transport) {
+ options = options || { data: {} };
+ options.data.transport = via_transport;
+ },
+ function () {
+ options = options || { data: {} };
+ options.data.host = text();
+ },
+ function (via_sent_by_port) {
+ options = options || { data: {} };
+ options.data.port = parseInt(via_sent_by_port.join(''));
+ },
+ function (ttl) {
+ return parseInt(ttl.join(''));
+ },
+ function (deltaSeconds) {
+ options = options || { data: {} };
+ if (options.startRule === 'Session_Expires') {
+ options.data.deltaSeconds = deltaSeconds;
+ }
+ },
+ "refresher",
+ peg$literalExpectation("refresher", false),
+ "uas",
+ peg$literalExpectation("uas", false),
+ "uac",
+ peg$literalExpectation("uac", false),
+ function (endpoint) {
+ options = options || { data: {} };
+ if (options.startRule === 'Session_Expires') {
+ options.data.refresher = endpoint;
+ }
+ },
+ function (deltaSeconds) {
+ options = options || { data: {} };
+ if (options.startRule === 'Min_SE') {
+ options.data = deltaSeconds;
+ }
+ },
+ "stuns",
+ peg$literalExpectation("stuns", true),
+ "stun",
+ peg$literalExpectation("stun", true),
+ function (scheme) {
+ options = options || { data: {} };
+ options.data.scheme = scheme;
+ },
+ function (host) {
+ options = options || { data: {} };
+ options.data.host = host;
+ },
+ "?transport=",
+ peg$literalExpectation("?transport=", false),
+ "turns",
+ peg$literalExpectation("turns", true),
+ "turn",
+ peg$literalExpectation("turn", true),
+ function (transport) {
+ options = options || { data: {} };
+ options.data.transport = transport;
+ },
+ function () {
+ options = options || { data: {} };
+ options.data = text();
+ },
+ "Referred-By",
+ peg$literalExpectation("Referred-By", false),
+ "b",
+ peg$literalExpectation("b", false),
+ "cid",
+ peg$literalExpectation("cid", false)
+ ];
+ const peg$bytecode = [
+ peg$decode("2 \"\"6 7!"),
+ peg$decode("4\"\"\"5!7#"),
+ peg$decode("4$\"\"5!7%"),
+ peg$decode("4&\"\"5!7'"),
+ peg$decode(";'.# &;("),
+ peg$decode("4(\"\"5!7)"),
+ peg$decode("4*\"\"5!7+"),
+ peg$decode("2,\"\"6,7-"),
+ peg$decode("2.\"\"6.7/"),
+ peg$decode("40\"\"5!71"),
+ peg$decode("22\"\"6273.\x89 &24\"\"6475.} &26\"\"6677.q &28\"\"6879.e &2:\"\"6:7;.Y &2<\"\"6<7=.M &2>\"\"6>7?.A &2@\"\"6@7A.5 &2B\"\"6B7C.) &2D\"\"6D7E"),
+ peg$decode(";).# &;,"),
+ peg$decode("2F\"\"6F7G.} &2H\"\"6H7I.q &2J\"\"6J7K.e &2L\"\"6L7M.Y &2N\"\"6N7O.M &2P\"\"6P7Q.A &2R\"\"6R7S.5 &2T\"\"6T7U.) &2V\"\"6V7W"),
+ peg$decode("%%2X\"\"6X7Y/5#;#/,$;#/#$+#)(#'#(\"'#&'#/\"!&,)"),
+ peg$decode("%%$;$0#*;$&/,#; /#$+\")(\"'#&'#.\" &\"/=#$;$/#*;$&&/'$8\":Z\" )(\"'#&'#"),
+ peg$decode(";..\" &\""),
+ peg$decode("%$;'.# &;(0)*;'.# &;(&/?#28\"\"6879/0$;//'$8#:[# )(#'#(\"'#&'#"),
+ peg$decode("%%$;2/#*;2&&/g#$%$;.0#*;.&/,#;2/#$+\")(\"'#&'#0=*%$;.0#*;.&/,#;2/#$+\")(\"'#&'#&/#$+\")(\"'#&'#/\"!&,)"),
+ peg$decode("4\\\"\"5!7].# &;3"),
+ peg$decode("4^\"\"5!7_"),
+ peg$decode("4`\"\"5!7a"),
+ peg$decode(";!.) &4b\"\"5!7c"),
+ peg$decode("%$;).\x95 &2F\"\"6F7G.\x89 &2J\"\"6J7K.} &2L\"\"6L7M.q &2X\"\"6X7Y.e &2P\"\"6P7Q.Y &2H\"\"6H7I.M &2@\"\"6@7A.A &2d\"\"6d7e.5 &2R\"\"6R7S.) &2N\"\"6N7O/\x9E#0\x9B*;).\x95 &2F\"\"6F7G.\x89 &2J\"\"6J7K.} &2L\"\"6L7M.q &2X\"\"6X7Y.e &2P\"\"6P7Q.Y &2H\"\"6H7I.M &2@\"\"6@7A.A &2d\"\"6d7e.5 &2R\"\"6R7S.) &2N\"\"6N7O&&/\"!&,)"),
+ peg$decode("%$;).\x89 &2F\"\"6F7G.} &2L\"\"6L7M.q &2X\"\"6X7Y.e &2P\"\"6P7Q.Y &2H\"\"6H7I.M &2@\"\"6@7A.A &2d\"\"6d7e.5 &2R\"\"6R7S.) &2N\"\"6N7O/\x92#0\x8F*;).\x89 &2F\"\"6F7G.} &2L\"\"6L7M.q &2X\"\"6X7Y.e &2P\"\"6P7Q.Y &2H\"\"6H7I.M &2@\"\"6@7A.A &2d\"\"6d7e.5 &2R\"\"6R7S.) &2N\"\"6N7O&&/\"!&,)"),
+ peg$decode("2T\"\"6T7U.\xE3 &2V\"\"6V7W.\xD7 &2f\"\"6f7g.\xCB &2h\"\"6h7i.\xBF &2:\"\"6:7;.\xB3 &2D\"\"6D7E.\xA7 &22\"\"6273.\x9B &28\"\"6879.\x8F &2j\"\"6j7k.\x83 &;&.} &24\"\"6475.q &2l\"\"6l7m.e &2n\"\"6n7o.Y &26\"\"6677.M &2>\"\"6>7?.A &2p\"\"6p7q.5 &2r\"\"6r7s.) &;'.# &;("),
+ peg$decode("%$;).\u012B &2F\"\"6F7G.\u011F &2J\"\"6J7K.\u0113 &2L\"\"6L7M.\u0107 &2X\"\"6X7Y.\xFB &2P\"\"6P7Q.\xEF &2H\"\"6H7I.\xE3 &2@\"\"6@7A.\xD7 &2d\"\"6d7e.\xCB &2R\"\"6R7S.\xBF &2N\"\"6N7O.\xB3 &2T\"\"6T7U.\xA7 &2V\"\"6V7W.\x9B &2f\"\"6f7g.\x8F &2h\"\"6h7i.\x83 &28\"\"6879.w &2j\"\"6j7k.k &;&.e &24\"\"6475.Y &2l\"\"6l7m.M &2n\"\"6n7o.A &26\"\"6677.5 &2p\"\"6p7q.) &2r\"\"6r7s/\u0134#0\u0131*;).\u012B &2F\"\"6F7G.\u011F &2J\"\"6J7K.\u0113 &2L\"\"6L7M.\u0107 &2X\"\"6X7Y.\xFB &2P\"\"6P7Q.\xEF &2H\"\"6H7I.\xE3 &2@\"\"6@7A.\xD7 &2d\"\"6d7e.\xCB &2R\"\"6R7S.\xBF &2N\"\"6N7O.\xB3 &2T\"\"6T7U.\xA7 &2V\"\"6V7W.\x9B &2f\"\"6f7g.\x8F &2h\"\"6h7i.\x83 &28\"\"6879.w &2j\"\"6j7k.k &;&.e &24\"\"6475.Y &2l\"\"6l7m.M &2n\"\"6n7o.A &26\"\"6677.5 &2p\"\"6p7q.) &2r\"\"6r7s&&/\"!&,)"),
+ peg$decode("%;//?#2P\"\"6P7Q/0$;//'$8#:t# )(#'#(\"'#&'#"),
+ peg$decode("%;//?#24\"\"6475/0$;//'$8#:u# )(#'#(\"'#&'#"),
+ peg$decode("%;//?#2>\"\"6>7?/0$;//'$8#:v# )(#'#(\"'#&'#"),
+ peg$decode("%;//?#2T\"\"6T7U/0$;//'$8#:w# )(#'#(\"'#&'#"),
+ peg$decode("%;//?#2V\"\"6V7W/0$;//'$8#:x# )(#'#(\"'#&'#"),
+ peg$decode("%2h\"\"6h7i/0#;//'$8\":y\" )(\"'#&'#"),
+ peg$decode("%;//6#2f\"\"6f7g/'$8\":z\" )(\"'#&'#"),
+ peg$decode("%;//?#2D\"\"6D7E/0$;//'$8#:{# )(#'#(\"'#&'#"),
+ peg$decode("%;//?#22\"\"6273/0$;//'$8#:|# )(#'#(\"'#&'#"),
+ peg$decode("%;//?#28\"\"6879/0$;//'$8#:}# )(#'#(\"'#&'#"),
+ peg$decode("%;//0#;&/'$8\":~\" )(\"'#&'#"),
+ peg$decode("%;&/0#;//'$8\":~\" )(\"'#&'#"),
+ peg$decode("%;=/T#$;G.) &;K.# &;F0/*;G.) &;K.# &;F&/,$;>/#$+#)(#'#(\"'#&'#"),
+ peg$decode("4\x7F\"\"5!7\x80.A &4\x81\"\"5!7\x82.5 &4\x83\"\"5!7\x84.) &;3.# &;."),
+ peg$decode("%%;//Q#;&/H$$;J.# &;K0)*;J.# &;K&/,$;&/#$+$)($'#(#'#(\"'#&'#/\"!&,)"),
+ peg$decode("%;//]#;&/T$%$;J.# &;K0)*;J.# &;K&/\"!&,)/1$;&/($8$:\x85$!!)($'#(#'#(\"'#&'#"),
+ peg$decode(";..G &2L\"\"6L7M.; &4\x86\"\"5!7\x87./ &4\x83\"\"5!7\x84.# &;3"),
+ peg$decode("%2j\"\"6j7k/J#4\x88\"\"5!7\x89.5 &4\x8A\"\"5!7\x8B.) &4\x8C\"\"5!7\x8D/#$+\")(\"'#&'#"),
+ peg$decode("%;N/M#28\"\"6879/>$;O.\" &\"/0$;S/'$8$:\x8E$ )($'#(#'#(\"'#&'#"),
+ peg$decode("%;N/d#28\"\"6879/U$;O.\" &\"/G$;S/>$;_/5$;l.\" &\"/'$8&:\x8F& )(&'#(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("%3\x90\"\"5$7\x91.) &3\x92\"\"5#7\x93/' 8!:\x94!! )"),
+ peg$decode("%;P/]#%28\"\"6879/,#;R/#$+\")(\"'#&'#.\" &\"/6$2:\"\"6:7;/'$8#:\x95# )(#'#(\"'#&'#"),
+ peg$decode("$;+.) &;-.# &;Q/2#0/*;+.) &;-.# &;Q&&"),
+ peg$decode("2<\"\"6<7=.q &2>\"\"6>7?.e &2@\"\"6@7A.Y &2B\"\"6B7C.M &2D\"\"6D7E.A &22\"\"6273.5 &26\"\"6677.) &24\"\"6475"),
+ peg$decode("%$;+._ &;-.Y &2<\"\"6<7=.M &2>\"\"6>7?.A &2@\"\"6@7A.5 &2B\"\"6B7C.) &2D\"\"6D7E0e*;+._ &;-.Y &2<\"\"6<7=.M &2>\"\"6>7?.A &2@\"\"6@7A.5 &2B\"\"6B7C.) &2D\"\"6D7E&/& 8!:\x96! )"),
+ peg$decode("%;T/J#%28\"\"6879/,#;^/#$+\")(\"'#&'#.\" &\"/#$+\")(\"'#&'#"),
+ peg$decode("%;U.) &;\\.# &;X/& 8!:\x97! )"),
+ peg$decode("%$%;V/2#2J\"\"6J7K/#$+\")(\"'#&'#0<*%;V/2#2J\"\"6J7K/#$+\")(\"'#&'#&/D#;W/;$2J\"\"6J7K.\" &\"/'$8#:\x98# )(#'#(\"'#&'#"),
+ peg$decode("$4\x99\"\"5!7\x9A/,#0)*4\x99\"\"5!7\x9A&&"),
+ peg$decode("%4$\"\"5!7%/?#$4\x9B\"\"5!7\x9C0)*4\x9B\"\"5!7\x9C&/#$+\")(\"'#&'#"),
+ peg$decode("%2l\"\"6l7m/?#;Y/6$2n\"\"6n7o/'$8#:\x9D# )(#'#(\"'#&'#"),
+ peg$decode("%%;Z/\xB3#28\"\"6879/\xA4$;Z/\x9B$28\"\"6879/\x8C$;Z/\x83$28\"\"6879/t$;Z/k$28\"\"6879/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+-)(-'#(,'#(+'#(*'#()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u0790 &%2\x9E\"\"6\x9E7\x9F/\xA4#;Z/\x9B$28\"\"6879/\x8C$;Z/\x83$28\"\"6879/t$;Z/k$28\"\"6879/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+,)(,'#(+'#(*'#()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u06F9 &%2\x9E\"\"6\x9E7\x9F/\x8C#;Z/\x83$28\"\"6879/t$;Z/k$28\"\"6879/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+*)(*'#()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u067A &%2\x9E\"\"6\x9E7\x9F/t#;Z/k$28\"\"6879/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+()(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u0613 &%2\x9E\"\"6\x9E7\x9F/\\#;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+&)(&'#(%'#($'#(#'#(\"'#&'#.\u05C4 &%2\x9E\"\"6\x9E7\x9F/D#;Z/;$28\"\"6879/,$;[/#$+$)($'#(#'#(\"'#&'#.\u058D &%2\x9E\"\"6\x9E7\x9F/,#;[/#$+\")(\"'#&'#.\u056E &%2\x9E\"\"6\x9E7\x9F/,#;Z/#$+\")(\"'#&'#.\u054F &%;Z/\x9B#2\x9E\"\"6\x9E7\x9F/\x8C$;Z/\x83$28\"\"6879/t$;Z/k$28\"\"6879/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$++)(+'#(*'#()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u04C7 &%;Z/\xAA#%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\x83$2\x9E\"\"6\x9E7\x9F/t$;Z/k$28\"\"6879/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+*)(*'#()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u0430 &%;Z/\xB9#%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\x92$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/k$2\x9E\"\"6\x9E7\x9F/\\$;Z/S$28\"\"6879/D$;Z/;$28\"\"6879/,$;[/#$+))()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u038A &%;Z/\xC8#%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xA1$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/z$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/S$2\x9E\"\"6\x9E7\x9F/D$;Z/;$28\"\"6879/,$;[/#$+()(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u02D5 &%;Z/\xD7#%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xB0$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\x89$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/b$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/;$2\x9E\"\"6\x9E7\x9F/,$;[/#$+')(''#(&'#(%'#($'#(#'#(\"'#&'#.\u0211 &%;Z/\xFE#%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xD7$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xB0$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\x89$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/b$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/;$2\x9E\"\"6\x9E7\x9F/,$;Z/#$+()(('#(''#(&'#(%'#($'#(#'#(\"'#&'#.\u0126 &%;Z/\u011C#%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xF5$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xCE$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\xA7$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/\x80$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/Y$%28\"\"6879/,#;Z/#$+\")(\"'#&'#.\" &\"/2$2\x9E\"\"6\x9E7\x9F/#$+()(('#(''#(&'#(%'#($'#(#'#(\"'#&'#/& 8!:\xA0! )"),
+ peg$decode("%;#/M#;#.\" &\"/?$;#.\" &\"/1$;#.\" &\"/#$+$)($'#(#'#(\"'#&'#"),
+ peg$decode("%;Z/;#28\"\"6879/,$;Z/#$+#)(#'#(\"'#&'#.# &;\\"),
+ peg$decode("%;]/o#2J\"\"6J7K/`$;]/W$2J\"\"6J7K/H$;]/?$2J\"\"6J7K/0$;]/'$8':\xA1' )(''#(&'#(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("%2\xA2\"\"6\xA27\xA3/2#4\xA4\"\"5!7\xA5/#$+\")(\"'#&'#.\x98 &%2\xA6\"\"6\xA67\xA7/;#4\xA8\"\"5!7\xA9/,$;!/#$+#)(#'#(\"'#&'#.j &%2\xAA\"\"6\xAA7\xAB/5#;!/,$;!/#$+#)(#'#(\"'#&'#.B &%4\xAC\"\"5!7\xAD/,#;!/#$+\")(\"'#&'#.# &;!"),
+ peg$decode("%%;!.\" &\"/[#;!.\" &\"/M$;!.\" &\"/?$;!.\" &\"/1$;!.\" &\"/#$+%)(%'#($'#(#'#(\"'#&'#/' 8!:\xAE!! )"),
+ peg$decode("$%22\"\"6273/,#;`/#$+\")(\"'#&'#0<*%22\"\"6273/,#;`/#$+\")(\"'#&'#&"),
+ peg$decode(";a.A &;b.; &;c.5 &;d./ &;e.) &;f.# &;g"),
+ peg$decode("%3\xAF\"\"5*7\xB0/a#3\xB1\"\"5#7\xB2.G &3\xB3\"\"5#7\xB4.; &3\xB5\"\"5$7\xB6./ &3\xB7\"\"5#7\xB8.# &;6/($8\":\xB9\"! )(\"'#&'#"),
+ peg$decode("%3\xBA\"\"5%7\xBB/I#3\xBC\"\"5%7\xBD./ &3\xBE\"\"5\"7\xBF.# &;6/($8\":\xC0\"! )(\"'#&'#"),
+ peg$decode("%3\xC1\"\"5'7\xC2/1#;\x90/($8\":\xC3\"! )(\"'#&'#"),
+ peg$decode("%3\xC4\"\"5$7\xC5/1#;\xF0/($8\":\xC6\"! )(\"'#&'#"),
+ peg$decode("%3\xC7\"\"5&7\xC8/1#;T/($8\":\xC9\"! )(\"'#&'#"),
+ peg$decode("%3\xCA\"\"5\"7\xCB/N#%2>\"\"6>7?/,#;6/#$+\")(\"'#&'#.\" &\"/'$8\":\xCC\" )(\"'#&'#"),
+ peg$decode("%;h/P#%2>\"\"6>7?/,#;i/#$+\")(\"'#&'#.\" &\"/)$8\":\xCD\"\"! )(\"'#&'#"),
+ peg$decode("%$;j/#*;j&&/\"!&,)"),
+ peg$decode("%$;j/#*;j&&/\"!&,)"),
+ peg$decode(";k.) &;+.# &;-"),
+ peg$decode("2l\"\"6l7m.e &2n\"\"6n7o.Y &24\"\"6475.M &28\"\"6879.A &2<\"\"6<7=.5 &2@\"\"6@7A.) &2B\"\"6B7C"),
+ peg$decode("%26\"\"6677/n#;m/e$$%2<\"\"6<7=/,#;m/#$+\")(\"'#&'#0<*%2<\"\"6<7=/,#;m/#$+\")(\"'#&'#&/#$+#)(#'#(\"'#&'#"),
+ peg$decode("%;n/A#2>\"\"6>7?/2$;o/)$8#:\xCE#\"\" )(#'#(\"'#&'#"),
+ peg$decode("$;p.) &;+.# &;-/2#0/*;p.) &;+.# &;-&&"),
+ peg$decode("$;p.) &;+.# &;-0/*;p.) &;+.# &;-&"),
+ peg$decode("2l\"\"6l7m.e &2n\"\"6n7o.Y &24\"\"6475.M &26\"\"6677.A &28\"\"6879.5 &2@\"\"6@7A.) &2B\"\"6B7C"),
+ peg$decode(";\x91.# &;r"),
+ peg$decode("%;\x90/G#;'/>$;s/5$;'/,$;\x84/#$+%)(%'#($'#(#'#(\"'#&'#"),
+ peg$decode(";M.# &;t"),
+ peg$decode("%;\x7F/E#28\"\"6879/6$;u.# &;x/'$8#:\xCF# )(#'#(\"'#&'#"),
+ peg$decode("%;v.# &;w/J#%26\"\"6677/,#;\x83/#$+\")(\"'#&'#.\" &\"/#$+\")(\"'#&'#"),
+ peg$decode("%2\xD0\"\"6\xD07\xD1/:#;\x80/1$;w.\" &\"/#$+#)(#'#(\"'#&'#"),
+ peg$decode("%24\"\"6475/,#;{/#$+\")(\"'#&'#"),
+ peg$decode("%;z/3#$;y0#*;y&/#$+\")(\"'#&'#"),
+ peg$decode(";*.) &;+.# &;-"),
+ peg$decode(";+.\x8F &;-.\x89 &22\"\"6273.} &26\"\"6677.q &28\"\"6879.e &2:\"\"6:7;.Y &2<\"\"6<7=.M &2>\"\"6>7?.A &2@\"\"6@7A.5 &2B\"\"6B7C.) &2D\"\"6D7E"),
+ peg$decode("%;|/e#$%24\"\"6475/,#;|/#$+\")(\"'#&'#0<*%24\"\"6475/,#;|/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%$;~0#*;~&/e#$%22\"\"6273/,#;}/#$+\")(\"'#&'#0<*%22\"\"6273/,#;}/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("$;~0#*;~&"),
+ peg$decode(";+.w &;-.q &28\"\"6879.e &2:\"\"6:7;.Y &2<\"\"6<7=.M &2>\"\"6>7?.A &2@\"\"6@7A.5 &2B\"\"6B7C.) &2D\"\"6D7E"),
+ peg$decode("%%;\"/\x87#$;\".G &;!.A &2@\"\"6@7A.5 &2F\"\"6F7G.) &2J\"\"6J7K0M*;\".G &;!.A &2@\"\"6@7A.5 &2F\"\"6F7G.) &2J\"\"6J7K&/#$+\")(\"'#&'#/& 8!:\xD2! )"),
+ peg$decode(";\x81.# &;\x82"),
+ peg$decode("%%;O/2#2:\"\"6:7;/#$+\")(\"'#&'#.\" &\"/,#;S/#$+\")(\"'#&'#.\" &\""),
+ peg$decode("$;+.\x83 &;-.} &2B\"\"6B7C.q &2D\"\"6D7E.e &22\"\"6273.Y &28\"\"6879.M &2:\"\"6:7;.A &2<\"\"6<7=.5 &2>\"\"6>7?.) &2@\"\"6@7A/\x8C#0\x89*;+.\x83 &;-.} &2B\"\"6B7C.q &2D\"\"6D7E.e &22\"\"6273.Y &28\"\"6879.M &2:\"\"6:7;.A &2<\"\"6<7=.5 &2>\"\"6>7?.) &2@\"\"6@7A&&"),
+ peg$decode("$;y0#*;y&"),
+ peg$decode("%3\x92\"\"5#7\xD3/q#24\"\"6475/b$$;!/#*;!&&/L$2J\"\"6J7K/=$$;!/#*;!&&/'$8%:\xD4% )(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("2\xD5\"\"6\xD57\xD6"),
+ peg$decode("2\xD7\"\"6\xD77\xD8"),
+ peg$decode("2\xD9\"\"6\xD97\xDA"),
+ peg$decode("2\xDB\"\"6\xDB7\xDC"),
+ peg$decode("2\xDD\"\"6\xDD7\xDE"),
+ peg$decode("2\xDF\"\"6\xDF7\xE0"),
+ peg$decode("2\xE1\"\"6\xE17\xE2"),
+ peg$decode("2\xE3\"\"6\xE37\xE4"),
+ peg$decode("2\xE5\"\"6\xE57\xE6"),
+ peg$decode("2\xE7\"\"6\xE77\xE8"),
+ peg$decode("2\xE9\"\"6\xE97\xEA"),
+ peg$decode("%;\x85.Y &;\x86.S &;\x88.M &;\x89.G &;\x8A.A &;\x8B.; &;\x8C.5 &;\x8F./ &;\x8D.) &;\x8E.# &;6/& 8!:\xEB! )"),
+ peg$decode("%;\x84/G#;'/>$;\x92/5$;'/,$;\x94/#$+%)(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("%;\x93/' 8!:\xEC!! )"),
+ peg$decode("%;!/5#;!/,$;!/#$+#)(#'#(\"'#&'#"),
+ peg$decode("%$;*.A &;+.; &;-.5 &;3./ &;4.) &;'.# &;(0G*;*.A &;+.; &;-.5 &;3./ &;4.) &;'.# &;(&/& 8!:\xED! )"),
+ peg$decode("%;\xB6/Y#$%;A/,#;\xB6/#$+\")(\"'#&'#06*%;A/,#;\xB6/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%;9/N#%2:\"\"6:7;/,#;9/#$+\")(\"'#&'#.\" &\"/'$8\":\xEE\" )(\"'#&'#"),
+ peg$decode("%;:.c &%;\x98/Y#$%;A/,#;\x98/#$+\")(\"'#&'#06*%;A/,#;\x98/#$+\")(\"'#&'#&/#$+\")(\"'#&'#/& 8!:\xEF! )"),
+ peg$decode("%;L.# &;\x99/]#$%;B/,#;\x9B/#$+\")(\"'#&'#06*%;B/,#;\x9B/#$+\")(\"'#&'#&/'$8\":\xF0\" )(\"'#&'#"),
+ peg$decode("%;\x9A.\" &\"/>#;@/5$;M/,$;?/#$+$)($'#(#'#(\"'#&'#"),
+ peg$decode("%%;6/Y#$%;./,#;6/#$+\")(\"'#&'#06*%;./,#;6/#$+\")(\"'#&'#&/#$+\")(\"'#&'#.# &;H/' 8!:\xF1!! )"),
+ peg$decode(";\x9C.) &;\x9D.# &;\xA0"),
+ peg$decode("%3\xF2\"\"5!7\xF3/:#;1$;\x9F/($8#:\xF4#! )(#'#(\"'#&'#"),
+ peg$decode("%3\xF5\"\"5'7\xF6/:#;1$;\x9E/($8#:\xF7#! )(#'#(\"'#&'#"),
+ peg$decode("%$;!/#*;!&&/' 8!:\xF8!! )"),
+ peg$decode("%2\xF9\"\"6\xF97\xFA/o#%2J\"\"6J7K/M#;!.\" &\"/?$;!.\" &\"/1$;!.\" &\"/#$+$)($'#(#'#(\"'#&'#.\" &\"/'$8\":\xFB\" )(\"'#&'#"),
+ peg$decode("%;6/J#%;,#;\xA1/#$+\")(\"'#&'#.\" &\"/)$8\":\xFC\"\"! )(\"'#&'#"),
+ peg$decode(";6.) &;T.# &;H"),
+ peg$decode("%;\xA3/Y#$%;B/,#;\xA4/#$+\")(\"'#&'#06*%;B/,#;\xA4/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%3\xFD\"\"5&7\xFE.G &3\xFF\"\"5'7\u0100.; &3\u0101\"\"5$7\u0102./ &3\u0103\"\"5%7\u0104.# &;6/& 8!:\u0105! )"),
+ peg$decode(";\xA5.# &;\xA0"),
+ peg$decode("%3\u0106\"\"5(7\u0107/M#;$;\xCF/5$;./,$;\x90/#$+%)(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("%$;!/#*;!&&/' 8!:\u014B!! )"),
+ peg$decode("%;\xD1/]#$%;A/,#;\xD1/#$+\")(\"'#&'#06*%;A/,#;\xD1/#$+\")(\"'#&'#&/'$8\":\u014C\" )(\"'#&'#"),
+ peg$decode("%;\x99/]#$%;B/,#;\xA0/#$+\")(\"'#&'#06*%;B/,#;\xA0/#$+\")(\"'#&'#&/'$8\":\u014D\" )(\"'#&'#"),
+ peg$decode("%;L.O &;\x99.I &%;@.\" &\"/:#;t/1$;?.\" &\"/#$+#)(#'#(\"'#&'#/]#$%;B/,#;\xA0/#$+\")(\"'#&'#06*%;B/,#;\xA0/#$+\")(\"'#&'#&/'$8\":\u014E\" )(\"'#&'#"),
+ peg$decode("%;\xD4/]#$%;B/,#;\xD5/#$+\")(\"'#&'#06*%;B/,#;\xD5/#$+\")(\"'#&'#&/'$8\":\u014F\" )(\"'#&'#"),
+ peg$decode("%;\x96/& 8!:\u0150! )"),
+ peg$decode("%3\u0151\"\"5(7\u0152/:#;1$;6/($8#:\u0153#! )(#'#(\"'#&'#.g &%3\u0154\"\"5&7\u0155/:#;1$;6/($8#:\u0156#! )(#'#(\"'#&'#.: &%3\u0157\"\"5*7\u0158/& 8!:\u0159! ).# &;\xA0"),
+ peg$decode("%%;6/k#$%;A/2#;6/)$8\":\u015A\"\"$ )(\"'#&'#0<*%;A/2#;6/)$8\":\u015A\"\"$ )(\"'#&'#&/)$8\":\u015B\"\"! )(\"'#&'#.\" &\"/' 8!:\u015C!! )"),
+ peg$decode("%;\xD8/Y#$%;A/,#;\xD8/#$+\")(\"'#&'#06*%;A/,#;\xD8/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%;\x99/Y#$%;B/,#;\xA0/#$+\")(\"'#&'#06*%;B/,#;\xA0/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%$;!/#*;!&&/' 8!:\u015D!! )"),
+ peg$decode("%;\xDB/Y#$%;B/,#;\xDC/#$+\")(\"'#&'#06*%;B/,#;\xDC/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%3\u015E\"\"5&7\u015F.; &3\u0160\"\"5'7\u0161./ &3\u0162\"\"5*7\u0163.# &;6/& 8!:\u0164! )"),
+ peg$decode("%3\u0165\"\"5&7\u0166/:#;1$;\xDD/($8#:\u0167#! )(#'#(\"'#&'#.} &%3\xF5\"\"5'7\xF6/:#;1$;\x9E/($8#:\u0168#! )(#'#(\"'#&'#.P &%3\u0169\"\"5+7\u016A/:#;1$;\x9E/($8#:\u016B#! )(#'#(\"'#&'#.# &;\xA0"),
+ peg$decode("3\u016C\"\"5+7\u016D.k &3\u016E\"\"5)7\u016F._ &3\u0170\"\"5(7\u0171.S &3\u0172\"\"5'7\u0173.G &3\u0174\"\"5&7\u0175.; &3\u0176\"\"5*7\u0177./ &3\u0178\"\"5)7\u0179.# &;6"),
+ peg$decode(";1.\" &\""),
+ peg$decode("%%;6/k#$%;A/2#;6/)$8\":\u015A\"\"$ )(\"'#&'#0<*%;A/2#;6/)$8\":\u015A\"\"$ )(\"'#&'#&/)$8\":\u015B\"\"! )(\"'#&'#.\" &\"/' 8!:\u017A!! )"),
+ peg$decode("%;L.# &;\x99/]#$%;B/,#;\xE1/#$+\")(\"'#&'#06*%;B/,#;\xE1/#$+\")(\"'#&'#&/'$8\":\u017B\" )(\"'#&'#"),
+ peg$decode(";\xB9.# &;\xA0"),
+ peg$decode("%;\xE3/Y#$%;A/,#;\xE3/#$+\")(\"'#&'#06*%;A/,#;\xE3/#$+\")(\"'#&'#&/#$+\")(\"'#&'#"),
+ peg$decode("%;\xEA/k#;./b$;\xED/Y$$%;B/,#;\xE4/#$+\")(\"'#&'#06*%;B/,#;\xE4/#$+\")(\"'#&'#&/#$+$)($'#(#'#(\"'#&'#"),
+ peg$decode(";\xE5.; &;\xE6.5 &;\xE7./ &;\xE8.) &;\xE9.# &;\xA0"),
+ peg$decode("%3\u017C\"\"5#7\u017D/:#;1$;\xF0/($8#:\u017E#! )(#'#(\"'#&'#"),
+ peg$decode("%3\u017F\"\"5%7\u0180/:#;1$;T/($8#:\u0181#! )(#'#(\"'#&'#"),
+ peg$decode("%3\u0182\"\"5(7\u0183/F#;=$;\\.) &;Y.# &;X/($8#:\u0184#! )(#'#(\"'#&'#"),
+ peg$decode("%3\u0185\"\"5&7\u0186/:#;1$;6/($8#:\u0187#! )(#'#(\"'#&'#"),
+ peg$decode("%3\u0188\"\"5%7\u0189/A#;8$$;!0#*;!&/($8#:\u018A#! )(#'#(\"'#&'#"),
+ peg$decode("%;\xEB/G#;;/>$;6/5$;;/,$;\xEC/#$+%)(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("%3\x92\"\"5#7\xD3.# &;6/' 8!:\u018B!! )"),
+ peg$decode("%3\xB1\"\"5#7\u018C.G &3\xB3\"\"5#7\u018D.; &3\xB7\"\"5#7\u018E./ &3\xB5\"\"5$7\u018F.# &;6/' 8!:\u0190!! )"),
+ peg$decode("%;\xEE/D#%;C/,#;\xEF/#$+\")(\"'#&'#.\" &\"/#$+\")(\"'#&'#"),
+ peg$decode("%;U.) &;\\.# &;X/& 8!:\u0191! )"),
+ peg$decode("%%;!.\" &\"/[#;!.\" &\"/M$;!.\" &\"/?$;!.\" &\"/1$;!.\" &\"/#$+%)(%'#($'#(#'#(\"'#&'#/' 8!:\u0192!! )"),
+ peg$decode("%%;!/?#;!.\" &\"/1$;!.\" &\"/#$+#)(#'#(\"'#&'#/' 8!:\u0193!! )"),
+ peg$decode(";\xBE"),
+ peg$decode("%;\x9E/^#$%;B/,#;\xF3/#$+\")(\"'#&'#06*%;B/,#;\xF3/#$+\")(\"'#&'#&/($8\":\u0194\"!!)(\"'#&'#"),
+ peg$decode(";\xF4.# &;\xA0"),
+ peg$decode("%2\u0195\"\"6\u01957\u0196/L#;\"\"6>7?"),
+ peg$decode("%;\u0100/b#28\"\"6879/S$;\xFB/J$%2\u01A3\"\"6\u01A37\u01A4/,#;\xEC/#$+\")(\"'#&'#.\" &\"/#$+$)($'#(#'#(\"'#&'#"),
+ peg$decode("%3\u01A5\"\"5%7\u01A6.) &3\u01A7\"\"5$7\u01A8/' 8!:\u01A1!! )"),
+ peg$decode("%3\xB1\"\"5#7\xB2.6 &3\xB3\"\"5#7\xB4.* &$;+0#*;+&/' 8!:\u01A9!! )"),
+ peg$decode("%;\u0104/\x87#2F\"\"6F7G/x$;\u0103/o$2F\"\"6F7G/`$;\u0103/W$2F\"\"6F7G/H$;\u0103/?$2F\"\"6F7G/0$;\u0105/'$8):\u01AA) )()'#(('#(''#(&'#(%'#($'#(#'#(\"'#&'#"),
+ peg$decode("%;#/>#;#/5$;#/,$;#/#$+$)($'#(#'#(\"'#&'#"),
+ peg$decode("%;\u0103/,#;\u0103/#$+\")(\"'#&'#"),
+ peg$decode("%;\u0103/5#;\u0103/,$;\u0103/#$+#)(#'#(\"'#&'#"),
+ peg$decode("%;q/T#$;m0#*;m&/D$%; /,#;\xF8/#$+\")(\"'#&'#.\" &\"/#$+#)(#'#(\"'#&'#"),
+ peg$decode("%2\u01AB\"\"6\u01AB7\u01AC.) &2\u01AD\"\"6\u01AD7\u01AE/w#;0/n$;\u0108/e$$%;B/2#;\u0109.# &;\xA0/#$+\")(\"'#&'#0<*%;B/2#;\u0109.# &;\xA0/#$+\")(\"'#&'#&/#$+$)($'#(#'#(\"'#&'#"),
+ peg$decode(";\x99.# &;L"),
+ peg$decode("%2\u01AF\"\"6\u01AF7\u01B0/5#;,$;\u010A/#$+#)(#'#(\"'#&'#"),
+ peg$decode("%;D/S#;,/J$2:\"\"6:7;/;$;,.# &;T/,$;E/#$+%)(%'#($'#(#'#(\"'#&'#")
+ ];
+ let peg$currPos = 0;
+ let peg$savedPos = 0;
+ const peg$posDetailsCache = [{ line: 1, column: 1 }];
+ let peg$maxFailPos = 0;
+ let peg$maxFailExpected = [];
+ let peg$silentFails = 0;
+ let peg$result;
+ if (options.startRule !== undefined) {
+ if (!(options.startRule in peg$startRuleIndices)) {
+ throw new Error("Can't start parsing from rule \"" + options.startRule + "\".");
+ }
+ peg$startRuleIndex = peg$startRuleIndices[options.startRule];
+ }
+ function text() {
+ return input.substring(peg$savedPos, peg$currPos);
+ }
+ function location() {
+ return peg$computeLocation(peg$savedPos, peg$currPos);
+ }
+ function expected(description, location1) {
+ location1 = location1 !== undefined
+ ? location1
+ : peg$computeLocation(peg$savedPos, peg$currPos);
+ throw peg$buildStructuredError([peg$otherExpectation(description)], input.substring(peg$savedPos, peg$currPos), location1);
+ }
+ function error(message, location1) {
+ location1 = location1 !== undefined
+ ? location1
+ : peg$computeLocation(peg$savedPos, peg$currPos);
+ throw peg$buildSimpleError(message, location1);
+ }
+ function peg$literalExpectation(text1, ignoreCase) {
+ return { type: "literal", text: text1, ignoreCase: ignoreCase };
+ }
+ function peg$classExpectation(parts, inverted, ignoreCase) {
+ return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase };
+ }
+ function peg$anyExpectation() {
+ return { type: "any" };
+ }
+ function peg$endExpectation() {
+ return { type: "end" };
+ }
+ function peg$otherExpectation(description) {
+ return { type: "other", description: description };
+ }
+ function peg$computePosDetails(pos) {
+ let details = peg$posDetailsCache[pos];
+ let p;
+ if (details) {
+ return details;
+ }
+ else {
+ p = pos - 1;
+ while (!peg$posDetailsCache[p]) {
+ p--;
+ }
+ details = peg$posDetailsCache[p];
+ details = {
+ line: details.line,
+ column: details.column
+ };
+ while (p < pos) {
+ if (input.charCodeAt(p) === 10) {
+ details.line++;
+ details.column = 1;
+ }
+ else {
+ details.column++;
+ }
+ p++;
+ }
+ peg$posDetailsCache[pos] = details;
+ return details;
+ }
+ }
+ function peg$computeLocation(startPos, endPos) {
+ const startPosDetails = peg$computePosDetails(startPos);
+ const endPosDetails = peg$computePosDetails(endPos);
+ return {
+ start: {
+ offset: startPos,
+ line: startPosDetails.line,
+ column: startPosDetails.column
+ },
+ end: {
+ offset: endPos,
+ line: endPosDetails.line,
+ column: endPosDetails.column
+ }
+ };
+ }
+ function peg$fail(expected1) {
+ if (peg$currPos < peg$maxFailPos) {
+ return;
+ }
+ if (peg$currPos > peg$maxFailPos) {
+ peg$maxFailPos = peg$currPos;
+ peg$maxFailExpected = [];
+ }
+ peg$maxFailExpected.push(expected1);
+ }
+ function peg$buildSimpleError(message, location1) {
+ return new SyntaxError(message, [], "", location1);
+ }
+ function peg$buildStructuredError(expected1, found, location1) {
+ return new SyntaxError(SyntaxError.buildMessage(expected1, found), expected1, found, location1);
+ }
+ function peg$decode(s) {
+ return s.split("").map((ch) => ch.charCodeAt(0) - 32);
+ }
+ function peg$parseRule(index) {
+ const bc = peg$bytecode[index];
+ let ip = 0;
+ const ips = [];
+ let end = bc.length;
+ const ends = [];
+ const stack = [];
+ let params;
+ while (true) {
+ while (ip < end) {
+ switch (bc[ip]) {
+ case 0:
+ stack.push(peg$consts[bc[ip + 1]]);
+ ip += 2;
+ break;
+ case 1:
+ stack.push(undefined);
+ ip++;
+ break;
+ case 2:
+ stack.push(null);
+ ip++;
+ break;
+ case 3:
+ stack.push(peg$FAILED);
+ ip++;
+ break;
+ case 4:
+ stack.push([]);
+ ip++;
+ break;
+ case 5:
+ stack.push(peg$currPos);
+ ip++;
+ break;
+ case 6:
+ stack.pop();
+ ip++;
+ break;
+ case 7:
+ peg$currPos = stack.pop();
+ ip++;
+ break;
+ case 8:
+ stack.length -= bc[ip + 1];
+ ip += 2;
+ break;
+ case 9:
+ stack.splice(-2, 1);
+ ip++;
+ break;
+ case 10:
+ stack[stack.length - 2].push(stack.pop());
+ ip++;
+ break;
+ case 11:
+ stack.push(stack.splice(stack.length - bc[ip + 1], bc[ip + 1]));
+ ip += 2;
+ break;
+ case 12:
+ stack.push(input.substring(stack.pop(), peg$currPos));
+ ip++;
+ break;
+ case 13:
+ ends.push(end);
+ ips.push(ip + 3 + bc[ip + 1] + bc[ip + 2]);
+ if (stack[stack.length - 1]) {
+ end = ip + 3 + bc[ip + 1];
+ ip += 3;
+ }
+ else {
+ end = ip + 3 + bc[ip + 1] + bc[ip + 2];
+ ip += 3 + bc[ip + 1];
+ }
+ break;
+ case 14:
+ ends.push(end);
+ ips.push(ip + 3 + bc[ip + 1] + bc[ip + 2]);
+ if (stack[stack.length - 1] === peg$FAILED) {
+ end = ip + 3 + bc[ip + 1];
+ ip += 3;
+ }
+ else {
+ end = ip + 3 + bc[ip + 1] + bc[ip + 2];
+ ip += 3 + bc[ip + 1];
+ }
+ break;
+ case 15:
+ ends.push(end);
+ ips.push(ip + 3 + bc[ip + 1] + bc[ip + 2]);
+ if (stack[stack.length - 1] !== peg$FAILED) {
+ end = ip + 3 + bc[ip + 1];
+ ip += 3;
+ }
+ else {
+ end = ip + 3 + bc[ip + 1] + bc[ip + 2];
+ ip += 3 + bc[ip + 1];
+ }
+ break;
+ case 16:
+ if (stack[stack.length - 1] !== peg$FAILED) {
+ ends.push(end);
+ ips.push(ip);
+ end = ip + 2 + bc[ip + 1];
+ ip += 2;
+ }
+ else {
+ ip += 2 + bc[ip + 1];
+ }
+ break;
+ case 17:
+ ends.push(end);
+ ips.push(ip + 3 + bc[ip + 1] + bc[ip + 2]);
+ if (input.length > peg$currPos) {
+ end = ip + 3 + bc[ip + 1];
+ ip += 3;
+ }
+ else {
+ end = ip + 3 + bc[ip + 1] + bc[ip + 2];
+ ip += 3 + bc[ip + 1];
+ }
+ break;
+ case 18:
+ ends.push(end);
+ ips.push(ip + 4 + bc[ip + 2] + bc[ip + 3]);
+ if (input.substr(peg$currPos, peg$consts[bc[ip + 1]].length) === peg$consts[bc[ip + 1]]) {
+ end = ip + 4 + bc[ip + 2];
+ ip += 4;
+ }
+ else {
+ end = ip + 4 + bc[ip + 2] + bc[ip + 3];
+ ip += 4 + bc[ip + 2];
+ }
+ break;
+ case 19:
+ ends.push(end);
+ ips.push(ip + 4 + bc[ip + 2] + bc[ip + 3]);
+ if (input.substr(peg$currPos, peg$consts[bc[ip + 1]].length).toLowerCase() === peg$consts[bc[ip + 1]]) {
+ end = ip + 4 + bc[ip + 2];
+ ip += 4;
+ }
+ else {
+ end = ip + 4 + bc[ip + 2] + bc[ip + 3];
+ ip += 4 + bc[ip + 2];
+ }
+ break;
+ case 20:
+ ends.push(end);
+ ips.push(ip + 4 + bc[ip + 2] + bc[ip + 3]);
+ if (peg$consts[bc[ip + 1]].test(input.charAt(peg$currPos))) {
+ end = ip + 4 + bc[ip + 2];
+ ip += 4;
+ }
+ else {
+ end = ip + 4 + bc[ip + 2] + bc[ip + 3];
+ ip += 4 + bc[ip + 2];
+ }
+ break;
+ case 21:
+ stack.push(input.substr(peg$currPos, bc[ip + 1]));
+ peg$currPos += bc[ip + 1];
+ ip += 2;
+ break;
+ case 22:
+ stack.push(peg$consts[bc[ip + 1]]);
+ peg$currPos += peg$consts[bc[ip + 1]].length;
+ ip += 2;
+ break;
+ case 23:
+ stack.push(peg$FAILED);
+ if (peg$silentFails === 0) {
+ peg$fail(peg$consts[bc[ip + 1]]);
+ }
+ ip += 2;
+ break;
+ case 24:
+ peg$savedPos = stack[stack.length - 1 - bc[ip + 1]];
+ ip += 2;
+ break;
+ case 25:
+ peg$savedPos = peg$currPos;
+ ip++;
+ break;
+ case 26:
+ params = bc.slice(ip + 4, ip + 4 + bc[ip + 3])
+ .map(function (p) { return stack[stack.length - 1 - p]; });
+ stack.splice(stack.length - bc[ip + 2], bc[ip + 2], peg$consts[bc[ip + 1]].apply(null, params));
+ ip += 4 + bc[ip + 3];
+ break;
+ case 27:
+ stack.push(peg$parseRule(bc[ip + 1]));
+ ip += 2;
+ break;
+ case 28:
+ peg$silentFails++;
+ ip++;
+ break;
+ case 29:
+ peg$silentFails--;
+ ip++;
+ break;
+ default:
+ throw new Error("Invalid opcode: " + bc[ip] + ".");
+ }
+ }
+ if (ends.length > 0) {
+ end = ends.pop();
+ ip = ips.pop();
+ }
+ else {
+ break;
+ }
+ }
+ return stack[0];
+ }
+ options.data = {}; // Object to which header attributes will be assigned during parsing
+ function list(head, tail) {
+ return [head].concat(tail);
+ }
+ peg$result = peg$parseRule(peg$startRuleIndex);
+ if (peg$result !== peg$FAILED && peg$currPos === input.length) {
+ return peg$result;
+ }
+ else {
+ if (peg$result !== peg$FAILED && peg$currPos < input.length) {
+ peg$fail(peg$endExpectation());
+ }
+ throw peg$buildStructuredError(peg$maxFailExpected, peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, peg$maxFailPos < input.length
+ ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1)
+ : peg$computeLocation(peg$maxFailPos, peg$maxFailPos));
+ }
+}
+const parse = peg$parse;
+
+
+/***/ }),
+/* 12 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "NameAddrHeader", function() { return NameAddrHeader; });
+/* harmony import */ var _parameters__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(13);
+
+/**
+ * Name Address SIP header.
+ * @public
+ */
+class NameAddrHeader extends _parameters__WEBPACK_IMPORTED_MODULE_0__["Parameters"] {
+ /**
+ * Constructor
+ * @param uri -
+ * @param displayName -
+ * @param parameters -
+ */
+ constructor(uri, displayName, parameters) {
+ super(parameters);
+ this.uri = uri;
+ this._displayName = displayName;
+ }
+ get friendlyName() {
+ return this.displayName || this.uri.aor;
+ }
+ get displayName() { return this._displayName; }
+ set displayName(value) {
+ this._displayName = value;
+ }
+ clone() {
+ return new NameAddrHeader(this.uri.clone(), this._displayName, JSON.parse(JSON.stringify(this.parameters)));
+ }
+ toString() {
+ let body = (this.displayName || this.displayName === "0") ? '"' + this.displayName + '" ' : "";
+ body += "<" + this.uri.toString() + ">";
+ for (const parameter in this.parameters) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (this.parameters.hasOwnProperty(parameter)) {
+ body += ";" + parameter;
+ if (this.parameters[parameter] !== null) {
+ body += "=" + this.parameters[parameter];
+ }
+ }
+ }
+ return body;
+ }
+}
+
+
+/***/ }),
+/* 13 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Parameters", function() { return Parameters; });
+/**
+ * @internal
+ */
+class Parameters {
+ constructor(parameters) {
+ this.parameters = {};
+ for (const param in parameters) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (parameters.hasOwnProperty(param)) {
+ this.setParam(param, parameters[param]);
+ }
+ }
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ setParam(key, value) {
+ if (key) {
+ this.parameters[key.toLowerCase()] = (typeof value === "undefined" || value === null) ? null : value.toString();
+ }
+ }
+ getParam(key) {
+ if (key) {
+ return this.parameters[key.toLowerCase()];
+ }
+ }
+ hasParam(key) {
+ if (key) {
+ // eslint-disable-next-line no-prototype-builtins
+ return !!this.parameters.hasOwnProperty(key.toLowerCase());
+ }
+ return false;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ deleteParam(parameter) {
+ parameter = parameter.toLowerCase();
+ // eslint-disable-next-line no-prototype-builtins
+ if (this.parameters.hasOwnProperty(parameter)) {
+ const value = this.parameters[parameter];
+ delete this.parameters[parameter];
+ return value;
+ }
+ }
+ clearParams() {
+ this.parameters = {};
+ }
+}
+
+
+/***/ }),
+/* 14 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "URI", function() { return URI; });
+/* harmony import */ var _parameters__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(13);
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/**
+ * URI.
+ * @public
+ */
+class URI extends _parameters__WEBPACK_IMPORTED_MODULE_0__["Parameters"] {
+ /**
+ * Constructor
+ * @param scheme -
+ * @param user -
+ * @param host -
+ * @param port -
+ * @param parameters -
+ * @param headers -
+ */
+ constructor(scheme, user, host, port, parameters, headers) {
+ super(parameters);
+ this.headers = {};
+ // Checks
+ if (!host) {
+ throw new TypeError('missing or invalid "host" parameter');
+ }
+ // Initialize parameters
+ scheme = scheme || "sip";
+ for (const header in headers) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (headers.hasOwnProperty(header)) {
+ this.setHeader(header, headers[header]);
+ }
+ }
+ // Raw URI
+ this.raw = {
+ scheme,
+ user,
+ host,
+ port
+ };
+ // Normalized URI
+ this.normal = {
+ scheme: scheme.toLowerCase(),
+ user,
+ host: host.toLowerCase(),
+ port
+ };
+ }
+ get scheme() { return this.normal.scheme; }
+ set scheme(value) {
+ this.raw.scheme = value;
+ this.normal.scheme = value.toLowerCase();
+ }
+ get user() { return this.normal.user; }
+ set user(value) {
+ this.normal.user = this.raw.user = value;
+ }
+ get host() { return this.normal.host; }
+ set host(value) {
+ this.raw.host = value;
+ this.normal.host = value.toLowerCase();
+ }
+ get aor() { return this.normal.user + "@" + this.normal.host; }
+ get port() { return this.normal.port; }
+ set port(value) {
+ this.normal.port = this.raw.port = value === 0 ? value : value;
+ }
+ setHeader(name, value) {
+ this.headers[this.headerize(name)] = (value instanceof Array) ? value : [value];
+ }
+ getHeader(name) {
+ if (name) {
+ return this.headers[this.headerize(name)];
+ }
+ }
+ hasHeader(name) {
+ // eslint-disable-next-line no-prototype-builtins
+ return !!name && !!this.headers.hasOwnProperty(this.headerize(name));
+ }
+ deleteHeader(header) {
+ header = this.headerize(header);
+ // eslint-disable-next-line no-prototype-builtins
+ if (this.headers.hasOwnProperty(header)) {
+ const value = this.headers[header];
+ delete this.headers[header];
+ return value;
+ }
+ }
+ clearHeaders() {
+ this.headers = {};
+ }
+ clone() {
+ return new URI(this._raw.scheme, this._raw.user || "", this._raw.host, this._raw.port, JSON.parse(JSON.stringify(this.parameters)), JSON.parse(JSON.stringify(this.headers)));
+ }
+ toRaw() {
+ return this._toString(this._raw);
+ }
+ toString() {
+ return this._toString(this._normal);
+ }
+ get _normal() { return this.normal; }
+ get _raw() { return this.raw; }
+ _toString(uri) {
+ let uriString = uri.scheme + ":";
+ // add slashes if it's not a sip(s) URI
+ if (!uri.scheme.toLowerCase().match("^sips?$")) {
+ uriString += "//";
+ }
+ if (uri.user) {
+ uriString += this.escapeUser(uri.user) + "@";
+ }
+ uriString += uri.host;
+ if (uri.port || uri.port === 0) {
+ uriString += ":" + uri.port;
+ }
+ for (const parameter in this.parameters) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (this.parameters.hasOwnProperty(parameter)) {
+ uriString += ";" + parameter;
+ if (this.parameters[parameter] !== null) {
+ uriString += "=" + this.parameters[parameter];
+ }
+ }
+ }
+ const headers = [];
+ for (const header in this.headers) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (this.headers.hasOwnProperty(header)) {
+ for (const idx in this.headers[header]) {
+ // eslint-disable-next-line no-prototype-builtins
+ if (this.headers[header].hasOwnProperty(idx)) {
+ headers.push(header + "=" + this.headers[header][idx]);
+ }
+ }
+ }
+ }
+ if (headers.length > 0) {
+ uriString += "?" + headers.join("&");
+ }
+ return uriString;
+ }
+ /*
+ * Hex-escape a SIP URI user.
+ * @private
+ * @param {String} user
+ */
+ escapeUser(user) {
+ let decodedUser;
+ // FIXME: This is called by toString above which should never throw, but
+ // decodeURIComponent can throw and I've seen one case in production where
+ // it did throw resulting in a cascading failure. This class should be
+ // fixed so that decodeURIComponent is not called at this point (in toString).
+ // The user should be decoded when the URI is constructor or some other
+ // place where we can catch the error before the URI is created or somesuch.
+ // eslint-disable-next-line no-useless-catch
+ try {
+ decodedUser = decodeURIComponent(user);
+ }
+ catch (error) {
+ throw error;
+ }
+ // Don't hex-escape ':' (%3A), '+' (%2B), '?' (%3F"), '/' (%2F).
+ return encodeURIComponent(decodedUser)
+ .replace(/%3A/ig, ":")
+ .replace(/%2B/ig, "+")
+ .replace(/%3F/ig, "?")
+ .replace(/%2F/ig, "/");
+ }
+ headerize(str) {
+ const exceptions = {
+ "Call-Id": "Call-ID",
+ "Cseq": "CSeq",
+ "Min-Se": "Min-SE",
+ "Rack": "RAck",
+ "Rseq": "RSeq",
+ "Www-Authenticate": "WWW-Authenticate",
+ };
+ const name = str.toLowerCase().replace(/_/g, "-").split("-");
+ const parts = name.length;
+ let hname = "";
+ for (let part = 0; part < parts; part++) {
+ if (part !== 0) {
+ hname += "-";
+ }
+ hname += name[part].charAt(0).toUpperCase() + name[part].substring(1);
+ }
+ if (exceptions[hname]) {
+ hname = exceptions[hname];
+ }
+ return hname;
+ }
+}
+
+
+/***/ }),
+/* 15 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _constants__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(16);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "C", function() { return _constants__WEBPACK_IMPORTED_MODULE_0__["C"]; });
+
+/* harmony import */ var _ack__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(17);
+/* harmony import */ var _ack__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_ack__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _ack__WEBPACK_IMPORTED_MODULE_1__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _ack__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _bye__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(18);
+/* harmony import */ var _bye__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_bye__WEBPACK_IMPORTED_MODULE_2__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _bye__WEBPACK_IMPORTED_MODULE_2__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _bye__WEBPACK_IMPORTED_MODULE_2__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _cancel__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(19);
+/* harmony import */ var _cancel__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_cancel__WEBPACK_IMPORTED_MODULE_3__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _cancel__WEBPACK_IMPORTED_MODULE_3__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _cancel__WEBPACK_IMPORTED_MODULE_3__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _info__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(20);
+/* harmony import */ var _info__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_info__WEBPACK_IMPORTED_MODULE_4__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _info__WEBPACK_IMPORTED_MODULE_4__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _info__WEBPACK_IMPORTED_MODULE_4__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _invite__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(21);
+/* harmony import */ var _invite__WEBPACK_IMPORTED_MODULE_5___default = /*#__PURE__*/__webpack_require__.n(_invite__WEBPACK_IMPORTED_MODULE_5__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _invite__WEBPACK_IMPORTED_MODULE_5__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _invite__WEBPACK_IMPORTED_MODULE_5__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _message__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(22);
+/* harmony import */ var _message__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_message__WEBPACK_IMPORTED_MODULE_6__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _message__WEBPACK_IMPORTED_MODULE_6__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _message__WEBPACK_IMPORTED_MODULE_6__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _notify__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(23);
+/* harmony import */ var _notify__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(_notify__WEBPACK_IMPORTED_MODULE_7__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _notify__WEBPACK_IMPORTED_MODULE_7__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _notify__WEBPACK_IMPORTED_MODULE_7__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _prack__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(24);
+/* harmony import */ var _prack__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(_prack__WEBPACK_IMPORTED_MODULE_8__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _prack__WEBPACK_IMPORTED_MODULE_8__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _prack__WEBPACK_IMPORTED_MODULE_8__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _publish__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(25);
+/* harmony import */ var _publish__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(_publish__WEBPACK_IMPORTED_MODULE_9__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _publish__WEBPACK_IMPORTED_MODULE_9__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _publish__WEBPACK_IMPORTED_MODULE_9__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _register__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(26);
+/* harmony import */ var _register__WEBPACK_IMPORTED_MODULE_10___default = /*#__PURE__*/__webpack_require__.n(_register__WEBPACK_IMPORTED_MODULE_10__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _register__WEBPACK_IMPORTED_MODULE_10__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _register__WEBPACK_IMPORTED_MODULE_10__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _refer__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(27);
+/* harmony import */ var _refer__WEBPACK_IMPORTED_MODULE_11___default = /*#__PURE__*/__webpack_require__.n(_refer__WEBPACK_IMPORTED_MODULE_11__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _refer__WEBPACK_IMPORTED_MODULE_11__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _refer__WEBPACK_IMPORTED_MODULE_11__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _subscribe__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(28);
+/* harmony import */ var _subscribe__WEBPACK_IMPORTED_MODULE_12___default = /*#__PURE__*/__webpack_require__.n(_subscribe__WEBPACK_IMPORTED_MODULE_12__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscribe__WEBPACK_IMPORTED_MODULE_12__) if(["C","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscribe__WEBPACK_IMPORTED_MODULE_12__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+/***/ }),
+/* 16 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "C", function() { return C; });
+/* eslint-disable @typescript-eslint/no-namespace */
+/**
+ * SIP Methods
+ * @internal
+ */
+var C;
+(function (C) {
+ C.ACK = "ACK";
+ C.BYE = "BYE";
+ C.CANCEL = "CANCEL";
+ C.INFO = "INFO";
+ C.INVITE = "INVITE";
+ C.MESSAGE = "MESSAGE";
+ C.NOTIFY = "NOTIFY";
+ C.OPTIONS = "OPTIONS";
+ C.REGISTER = "REGISTER";
+ C.UPDATE = "UPDATE";
+ C.SUBSCRIBE = "SUBSCRIBE";
+ C.PUBLISH = "PUBLISH";
+ C.REFER = "REFER";
+ C.PRACK = "PRACK";
+})(C || (C = {}));
+
+
+/***/ }),
+/* 17 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 18 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 19 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 20 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 21 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 22 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 23 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 24 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 25 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 26 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 27 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 28 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 29 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "fromBodyLegacy", function() { return fromBodyLegacy; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "isBody", function() { return isBody; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getBody", function() { return getBody; });
+/* harmony import */ var _incoming_request_message__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(30);
+/* harmony import */ var _incoming_response_message__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(33);
+/* harmony import */ var _outgoing_request_message__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(34);
+
+
+
+// If the Content-Disposition header field is missing, bodies of
+// Content-Type application/sdp imply the disposition "session", while
+// other content types imply "render".
+// https://tools.ietf.org/html/rfc3261#section-13.2.1
+function contentTypeToContentDisposition(contentType) {
+ if (contentType === "application/sdp") {
+ return "session";
+ }
+ else {
+ return "render";
+ }
+}
+/**
+ * Create a Body given a legacy body type.
+ * @param bodyLegacy - Body Object
+ * @internal
+ */
+function fromBodyLegacy(bodyLegacy) {
+ const content = typeof bodyLegacy === "string" ? bodyLegacy : bodyLegacy.body;
+ const contentType = typeof bodyLegacy === "string" ? "application/sdp" : bodyLegacy.contentType;
+ const contentDisposition = contentTypeToContentDisposition(contentType);
+ const body = { contentDisposition, contentType, content };
+ return body;
+}
+/**
+ * User-Defined Type Guard for Body.
+ * @param body - Body to check.
+ * @internal
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function isBody(body) {
+ return body &&
+ typeof body.content === "string" &&
+ typeof body.contentType === "string" &&
+ body.contentDisposition === undefined
+ ? true
+ : typeof body.contentDisposition === "string";
+}
+/**
+ * Given a message, get a normalized body.
+ * The content disposition is inferred if not set.
+ * @param message - The message.
+ * @internal
+ */
+function getBody(message) {
+ let contentDisposition;
+ let contentType;
+ let content;
+ // We're in UAS role, receiving incoming request
+ if (message instanceof _incoming_request_message__WEBPACK_IMPORTED_MODULE_0__["IncomingRequestMessage"]) {
+ if (message.body) {
+ // FIXME: Parsing needs typing
+ const parse = message.parseHeader("Content-Disposition");
+ contentDisposition = parse ? parse.type : undefined;
+ contentType = message.parseHeader("Content-Type");
+ content = message.body;
+ }
+ }
+ // We're in UAC role, receiving incoming response
+ if (message instanceof _incoming_response_message__WEBPACK_IMPORTED_MODULE_1__["IncomingResponseMessage"]) {
+ if (message.body) {
+ // FIXME: Parsing needs typing
+ const parse = message.parseHeader("Content-Disposition");
+ contentDisposition = parse ? parse.type : undefined;
+ contentType = message.parseHeader("Content-Type");
+ content = message.body;
+ }
+ }
+ // We're in UAC role, sending outgoing request
+ if (message instanceof _outgoing_request_message__WEBPACK_IMPORTED_MODULE_2__["OutgoingRequestMessage"]) {
+ if (message.body) {
+ contentDisposition = message.getHeader("Content-Disposition");
+ contentType = message.getHeader("Content-Type");
+ if (typeof message.body === "string") {
+ // FIXME: OutgoingRequest should not allow a "string" body without a "Content-Type" header.
+ if (!contentType) {
+ throw new Error("Header content type header does not equal body content type.");
+ }
+ content = message.body;
+ }
+ else {
+ // FIXME: OutgoingRequest should not allow the "Content-Type" header not to match th body content type
+ if (contentType && contentType !== message.body.contentType) {
+ throw new Error("Header content type header does not equal body content type.");
+ }
+ contentType = message.body.contentType;
+ content = message.body.body;
+ }
+ }
+ }
+ // We're in UAS role, sending outgoing response
+ if (isBody(message)) {
+ contentDisposition = message.contentDisposition;
+ contentType = message.contentType;
+ content = message.content;
+ }
+ // No content, no body.
+ if (!content) {
+ return undefined;
+ }
+ if (contentType && !contentDisposition) {
+ contentDisposition = contentTypeToContentDisposition(contentType);
+ }
+ if (!contentDisposition) {
+ throw new Error("Content disposition undefined.");
+ }
+ if (!contentType) {
+ throw new Error("Content type undefined.");
+ }
+ return {
+ contentDisposition,
+ contentType,
+ content
+ };
+}
+
+
+/***/ }),
+/* 30 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "IncomingRequestMessage", function() { return IncomingRequestMessage; });
+/* harmony import */ var _incoming_message__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(31);
+
+/**
+ * Incoming request message.
+ * @public
+ */
+class IncomingRequestMessage extends _incoming_message__WEBPACK_IMPORTED_MODULE_0__["IncomingMessage"] {
+ constructor() {
+ super();
+ }
+}
+
+
+/***/ }),
+/* 31 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "IncomingMessage", function() { return IncomingMessage; });
+/* harmony import */ var _grammar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
+/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+
+
+/**
+ * Incoming message.
+ * @public
+ */
+class IncomingMessage {
+ constructor() {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ this.headers = {};
+ }
+ /**
+ * Insert a header of the given name and value into the last position of the
+ * header array.
+ * @param name - header name
+ * @param value - header value
+ */
+ addHeader(name, value) {
+ const header = { raw: value };
+ name = Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name);
+ if (this.headers[name]) {
+ this.headers[name].push(header);
+ }
+ else {
+ this.headers[name] = [header];
+ }
+ }
+ /**
+ * Get the value of the given header name at the given position.
+ * @param name - header name
+ * @returns Returns the specified header, undefined if header doesn't exist.
+ */
+ getHeader(name) {
+ const header = this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)];
+ if (header) {
+ if (header[0]) {
+ return header[0].raw;
+ }
+ }
+ else {
+ return;
+ }
+ }
+ /**
+ * Get the header/s of the given name.
+ * @param name - header name
+ * @returns Array - with all the headers of the specified name.
+ */
+ getHeaders(name) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const header = this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)];
+ const result = [];
+ if (!header) {
+ return [];
+ }
+ for (const headerPart of header) {
+ result.push(headerPart.raw);
+ }
+ return result;
+ }
+ /**
+ * Verify the existence of the given header.
+ * @param name - header name
+ * @returns true if header with given name exists, false otherwise
+ */
+ hasHeader(name) {
+ return !!this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)];
+ }
+ /**
+ * Parse the given header on the given index.
+ * @param name - header name
+ * @param idx - header index
+ * @returns Parsed header object, undefined if the
+ * header is not present or in case of a parsing error.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ parseHeader(name, idx = 0) {
+ name = Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name);
+ if (!this.headers[name]) {
+ // this.logger.log("header '" + name + "' not present");
+ return;
+ }
+ else if (idx >= this.headers[name].length) {
+ // this.logger.log("not so many '" + name + "' headers present");
+ return;
+ }
+ const header = this.headers[name][idx];
+ const value = header.raw;
+ if (header.parsed) {
+ return header.parsed;
+ }
+ // substitute '-' by '_' for grammar rule matching.
+ const parsed = _grammar__WEBPACK_IMPORTED_MODULE_0__["Grammar"].parse(value, name.replace(/-/g, "_"));
+ if (parsed === -1) {
+ this.headers[name].splice(idx, 1); // delete from headers
+ // this.logger.warn('error parsing "' + name + '" header field with value "' + value + '"');
+ return;
+ }
+ else {
+ header.parsed = parsed;
+ return parsed;
+ }
+ }
+ /**
+ * Message Header attribute selector. Alias of parseHeader.
+ * @param name - header name
+ * @param idx - header index
+ * @returns Parsed header object, undefined if the
+ * header is not present or in case of a parsing error.
+ *
+ * @example
+ * message.s('via',3).port
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ s(name, idx = 0) {
+ return this.parseHeader(name, idx);
+ }
+ /**
+ * Replace the value of the given header by the value.
+ * @param name - header name
+ * @param value - header value
+ */
+ setHeader(name, value) {
+ this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)] = [{ raw: value }];
+ }
+ toString() {
+ return this.data;
+ }
+}
+
+
+/***/ }),
+/* 32 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "createRandomToken", function() { return createRandomToken; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "getReasonPhrase", function() { return getReasonPhrase; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "newTag", function() { return newTag; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "headerize", function() { return headerize; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "utf8Length", function() { return utf8Length; });
+/**
+ * SIP Response Reasons
+ * DOC: http://www.iana.org/assignments/sip-parameters
+ * @internal
+ */
+const REASON_PHRASE = {
+ 100: "Trying",
+ 180: "Ringing",
+ 181: "Call Is Being Forwarded",
+ 182: "Queued",
+ 183: "Session Progress",
+ 199: "Early Dialog Terminated",
+ 200: "OK",
+ 202: "Accepted",
+ 204: "No Notification",
+ 300: "Multiple Choices",
+ 301: "Moved Permanently",
+ 302: "Moved Temporarily",
+ 305: "Use Proxy",
+ 380: "Alternative Service",
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 402: "Payment Required",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 405: "Method Not Allowed",
+ 406: "Not Acceptable",
+ 407: "Proxy Authentication Required",
+ 408: "Request Timeout",
+ 410: "Gone",
+ 412: "Conditional Request Failed",
+ 413: "Request Entity Too Large",
+ 414: "Request-URI Too Long",
+ 415: "Unsupported Media Type",
+ 416: "Unsupported URI Scheme",
+ 417: "Unknown Resource-Priority",
+ 420: "Bad Extension",
+ 421: "Extension Required",
+ 422: "Session Interval Too Small",
+ 423: "Interval Too Brief",
+ 428: "Use Identity Header",
+ 429: "Provide Referrer Identity",
+ 430: "Flow Failed",
+ 433: "Anonymity Disallowed",
+ 436: "Bad Identity-Info",
+ 437: "Unsupported Certificate",
+ 438: "Invalid Identity Header",
+ 439: "First Hop Lacks Outbound Support",
+ 440: "Max-Breadth Exceeded",
+ 469: "Bad Info Package",
+ 470: "Consent Needed",
+ 478: "Unresolvable Destination",
+ 480: "Temporarily Unavailable",
+ 481: "Call/Transaction Does Not Exist",
+ 482: "Loop Detected",
+ 483: "Too Many Hops",
+ 484: "Address Incomplete",
+ 485: "Ambiguous",
+ 486: "Busy Here",
+ 487: "Request Terminated",
+ 488: "Not Acceptable Here",
+ 489: "Bad Event",
+ 491: "Request Pending",
+ 493: "Undecipherable",
+ 494: "Security Agreement Required",
+ 500: "Internal Server Error",
+ 501: "Not Implemented",
+ 502: "Bad Gateway",
+ 503: "Service Unavailable",
+ 504: "Server Time-out",
+ 505: "Version Not Supported",
+ 513: "Message Too Large",
+ 580: "Precondition Failure",
+ 600: "Busy Everywhere",
+ 603: "Decline",
+ 604: "Does Not Exist Anywhere",
+ 606: "Not Acceptable"
+};
+/**
+ * @param size -
+ * @param base -
+ * @internal
+ */
+function createRandomToken(size, base = 32) {
+ let token = "";
+ for (let i = 0; i < size; i++) {
+ const r = Math.floor(Math.random() * base);
+ token += r.toString(base);
+ }
+ return token;
+}
+/**
+ * @internal
+ */
+function getReasonPhrase(code) {
+ return REASON_PHRASE[code] || "";
+}
+/**
+ * @internal
+ */
+function newTag() {
+ return createRandomToken(10);
+}
+/**
+ * @param str -
+ * @internal
+ */
+function headerize(str) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const exceptions = {
+ "Call-Id": "Call-ID",
+ Cseq: "CSeq",
+ "Min-Se": "Min-SE",
+ Rack: "RAck",
+ Rseq: "RSeq",
+ "Www-Authenticate": "WWW-Authenticate"
+ };
+ const name = str.toLowerCase().replace(/_/g, "-").split("-");
+ const parts = name.length;
+ let hname = "";
+ for (let part = 0; part < parts; part++) {
+ if (part !== 0) {
+ hname += "-";
+ }
+ hname += name[part].charAt(0).toUpperCase() + name[part].substring(1);
+ }
+ if (exceptions[hname]) {
+ hname = exceptions[hname];
+ }
+ return hname;
+}
+/**
+ * @param str -
+ * @internal
+ */
+function utf8Length(str) {
+ return encodeURIComponent(str).replace(/%[A-F\d]{2}/g, "U").length;
+}
+
+
+/***/ }),
+/* 33 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "IncomingResponseMessage", function() { return IncomingResponseMessage; });
+/* harmony import */ var _incoming_message__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(31);
+
+/**
+ * Incoming response message.
+ * @public
+ */
+class IncomingResponseMessage extends _incoming_message__WEBPACK_IMPORTED_MODULE_0__["IncomingMessage"] {
+ constructor() {
+ super();
+ }
+}
+
+
+/***/ }),
+/* 34 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "OutgoingRequestMessage", function() { return OutgoingRequestMessage; });
+/* harmony import */ var _grammar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
+/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+
+
+/**
+ * Outgoing SIP request message.
+ * @public
+ */
+class OutgoingRequestMessage {
+ constructor(method, ruri, fromURI, toURI, options, extraHeaders, body) {
+ this.headers = {};
+ this.extraHeaders = [];
+ // Initialize default options
+ this.options = OutgoingRequestMessage.getDefaultOptions();
+ // Options - merge a deep copy
+ if (options) {
+ this.options = Object.assign(Object.assign({}, this.options), options);
+ if (this.options.optionTags && this.options.optionTags.length) {
+ this.options.optionTags = this.options.optionTags.slice();
+ }
+ if (this.options.routeSet && this.options.routeSet.length) {
+ this.options.routeSet = this.options.routeSet.slice();
+ }
+ }
+ // Extra headers - deep copy
+ if (extraHeaders && extraHeaders.length) {
+ this.extraHeaders = extraHeaders.slice();
+ }
+ // Body - deep copy
+ if (body) {
+ // TODO: internal representation should be Body
+ // this.body = { ...body };
+ this.body = {
+ body: body.content,
+ contentType: body.contentType
+ };
+ }
+ // Method
+ this.method = method;
+ // RURI
+ this.ruri = ruri.clone();
+ // From
+ this.fromURI = fromURI.clone();
+ this.fromTag = this.options.fromTag ? this.options.fromTag : Object(_utils__WEBPACK_IMPORTED_MODULE_1__["newTag"])();
+ this.from = OutgoingRequestMessage.makeNameAddrHeader(this.fromURI, this.options.fromDisplayName, this.fromTag);
+ // To
+ this.toURI = toURI.clone();
+ this.toTag = this.options.toTag;
+ this.to = OutgoingRequestMessage.makeNameAddrHeader(this.toURI, this.options.toDisplayName, this.toTag);
+ // Call-ID
+ this.callId = this.options.callId ? this.options.callId : this.options.callIdPrefix + Object(_utils__WEBPACK_IMPORTED_MODULE_1__["createRandomToken"])(15);
+ // CSeq
+ this.cseq = this.options.cseq;
+ // The relative order of header fields with different field names is not
+ // significant. However, it is RECOMMENDED that header fields which are
+ // needed for proxy processing (Via, Route, Record-Route, Proxy-Require,
+ // Max-Forwards, and Proxy-Authorization, for example) appear towards
+ // the top of the message to facilitate rapid parsing.
+ // https://tools.ietf.org/html/rfc3261#section-7.3.1
+ this.setHeader("route", this.options.routeSet);
+ this.setHeader("via", "");
+ this.setHeader("to", this.to.toString());
+ this.setHeader("from", this.from.toString());
+ this.setHeader("cseq", this.cseq + " " + this.method);
+ this.setHeader("call-id", this.callId);
+ this.setHeader("max-forwards", "70");
+ }
+ /** Get a copy of the default options. */
+ static getDefaultOptions() {
+ return {
+ callId: "",
+ callIdPrefix: "",
+ cseq: 1,
+ toDisplayName: "",
+ toTag: "",
+ fromDisplayName: "",
+ fromTag: "",
+ forceRport: false,
+ hackViaTcp: false,
+ hackViaWs: false,
+ optionTags: ["outbound"],
+ routeSet: [],
+ userAgentString: "sip.js",
+ viaHost: ""
+ };
+ }
+ static makeNameAddrHeader(uri, displayName, tag) {
+ const parameters = {};
+ if (tag) {
+ parameters.tag = tag;
+ }
+ return new _grammar__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"](uri, displayName, parameters);
+ }
+ /**
+ * Get the value of the given header name at the given position.
+ * @param name - header name
+ * @returns Returns the specified header, undefined if header doesn't exist.
+ */
+ getHeader(name) {
+ const header = this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)];
+ if (header) {
+ if (header[0]) {
+ return header[0];
+ }
+ }
+ else {
+ const regexp = new RegExp("^\\s*" + name + "\\s*:", "i");
+ for (const exHeader of this.extraHeaders) {
+ if (regexp.test(exHeader)) {
+ return exHeader.substring(exHeader.indexOf(":") + 1).trim();
+ }
+ }
+ }
+ return;
+ }
+ /**
+ * Get the header/s of the given name.
+ * @param name - header name
+ * @returns Array with all the headers of the specified name.
+ */
+ getHeaders(name) {
+ const result = [];
+ const headerArray = this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)];
+ if (headerArray) {
+ for (const headerPart of headerArray) {
+ result.push(headerPart);
+ }
+ }
+ else {
+ const regexp = new RegExp("^\\s*" + name + "\\s*:", "i");
+ for (const exHeader of this.extraHeaders) {
+ if (regexp.test(exHeader)) {
+ result.push(exHeader.substring(exHeader.indexOf(":") + 1).trim());
+ }
+ }
+ }
+ return result;
+ }
+ /**
+ * Verify the existence of the given header.
+ * @param name - header name
+ * @returns true if header with given name exists, false otherwise
+ */
+ hasHeader(name) {
+ if (this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)]) {
+ return true;
+ }
+ else {
+ const regexp = new RegExp("^\\s*" + name + "\\s*:", "i");
+ for (const extraHeader of this.extraHeaders) {
+ if (regexp.test(extraHeader)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+ /**
+ * Replace the the given header by the given value.
+ * @param name - header name
+ * @param value - header value
+ */
+ setHeader(name, value) {
+ this.headers[Object(_utils__WEBPACK_IMPORTED_MODULE_1__["headerize"])(name)] = value instanceof Array ? value : [value];
+ }
+ /**
+ * The Via header field indicates the transport used for the transaction
+ * and identifies the location where the response is to be sent. A Via
+ * header field value is added only after the transport that will be
+ * used to reach the next hop has been selected (which may involve the
+ * usage of the procedures in [4]).
+ *
+ * When the UAC creates a request, it MUST insert a Via into that
+ * request. The protocol name and protocol version in the header field
+ * MUST be SIP and 2.0, respectively. The Via header field value MUST
+ * contain a branch parameter. This parameter is used to identify the
+ * transaction created by that request. This parameter is used by both
+ * the client and the server.
+ * https://tools.ietf.org/html/rfc3261#section-8.1.1.7
+ * @param branchParameter - The branch parameter.
+ * @param transport - The sent protocol transport.
+ */
+ setViaHeader(branch, transport) {
+ // FIXME: Hack
+ if (this.options.hackViaTcp) {
+ transport = "TCP";
+ } else if (this.options.hackViaWs) {
+ transport = "WS";
+ }
+ let via = "SIP/2.0/" + transport;
+ via += " " + this.options.viaHost + ";branch=" + branch;
+ if (this.options.forceRport) {
+ via += ";rport";
+ }
+ this.setHeader("via", via);
+ this.branch = branch;
+ }
+ toString() {
+ let msg = "";
+ msg += this.method + " " + this.ruri.toRaw() + " SIP/2.0\r\n";
+ for (const header in this.headers) {
+ if (this.headers[header]) {
+ for (const headerPart of this.headers[header]) {
+ msg += header + ": " + headerPart + "\r\n";
+ }
+ }
+ }
+ for (const header of this.extraHeaders) {
+ msg += header.trim() + "\r\n";
+ }
+ msg += "Supported: " + this.options.optionTags.join(", ") + "\r\n";
+ msg += "User-Agent: " + this.options.userAgentString + "\r\n";
+ if (this.body) {
+ if (typeof this.body === "string") {
+ msg += "Content-Length: " + Object(_utils__WEBPACK_IMPORTED_MODULE_1__["utf8Length"])(this.body) + "\r\n\r\n";
+ msg += this.body;
+ }
+ else {
+ if (this.body.body && this.body.contentType) {
+ msg += "Content-Type: " + this.body.contentType + "\r\n";
+ msg += "Content-Length: " + Object(_utils__WEBPACK_IMPORTED_MODULE_1__["utf8Length"])(this.body.body) + "\r\n\r\n";
+ msg += this.body.body;
+ }
+ else {
+ msg += "Content-Length: " + 0 + "\r\n\r\n";
+ }
+ }
+ }
+ else {
+ msg += "Content-Length: " + 0 + "\r\n\r\n";
+ }
+ return msg;
+ }
+}
+
+
+/***/ }),
+/* 35 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "DigestAuthentication", function() { return DigestAuthentication; });
+/* harmony import */ var _md5__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(36);
+/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+
+
+function MD5(s) {
+ return _md5__WEBPACK_IMPORTED_MODULE_0__["Md5"].hashStr(s);
+}
+/**
+ * Digest Authentication.
+ * @internal
+ */
+class DigestAuthentication {
+ /**
+ * Constructor.
+ * @param loggerFactory - LoggerFactory.
+ * @param username - Username.
+ * @param password - Password.
+ */
+ constructor(loggerFactory, ha1, username, password) {
+ this.logger = loggerFactory.getLogger("sipjs.digestauthentication");
+ this.username = username;
+ this.password = password;
+ this.ha1 = ha1;
+ this.nc = 0;
+ this.ncHex = "00000000";
+ }
+ /**
+ * Performs Digest authentication given a SIP request and the challenge
+ * received in a response to that request.
+ * @param request -
+ * @param challenge -
+ * @returns true if credentials were successfully generated, false otherwise.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ authenticate(request, challenge, body) {
+ // Inspect and validate the challenge.
+ this.algorithm = challenge.algorithm;
+ this.realm = challenge.realm;
+ this.nonce = challenge.nonce;
+ this.opaque = challenge.opaque;
+ this.stale = challenge.stale;
+ if (this.algorithm) {
+ if (this.algorithm !== "MD5") {
+ this.logger.warn("challenge with Digest algorithm different than 'MD5', authentication aborted");
+ return false;
+ }
+ }
+ else {
+ this.algorithm = "MD5";
+ }
+ if (!this.realm) {
+ this.logger.warn("challenge without Digest realm, authentication aborted");
+ return false;
+ }
+ if (!this.nonce) {
+ this.logger.warn("challenge without Digest nonce, authentication aborted");
+ return false;
+ }
+ // 'qop' can contain a list of values (Array). Let's choose just one.
+ if (challenge.qop) {
+ if (challenge.qop.indexOf("auth") > -1) {
+ this.qop = "auth";
+ }
+ else if (challenge.qop.indexOf("auth-int") > -1) {
+ this.qop = "auth-int";
+ }
+ else {
+ // Otherwise 'qop' is present but does not contain 'auth' or 'auth-int', so abort here.
+ this.logger.warn("challenge without Digest qop different than 'auth' or 'auth-int', authentication aborted");
+ return false;
+ }
+ }
+ else {
+ this.qop = undefined;
+ }
+ // Fill other attributes.
+ this.method = request.method;
+ this.uri = request.ruri;
+ this.cnonce = Object(_utils__WEBPACK_IMPORTED_MODULE_1__["createRandomToken"])(12);
+ this.nc += 1;
+ this.updateNcHex();
+ // nc-value = 8LHEX. Max value = 'FFFFFFFF'.
+ if (this.nc === 4294967296) {
+ this.nc = 1;
+ this.ncHex = "00000001";
+ }
+ // Calculate the Digest "response" value.
+ this.calculateResponse(body);
+ return true;
+ }
+ /**
+ * Return the Proxy-Authorization or WWW-Authorization header value.
+ */
+ toString() {
+ const authParams = [];
+ if (!this.response) {
+ throw new Error("response field does not exist, cannot generate Authorization header");
+ }
+ authParams.push("algorithm=" + this.algorithm);
+ authParams.push('username="' + this.username + '"');
+ authParams.push('realm="' + this.realm + '"');
+ authParams.push('nonce="' + this.nonce + '"');
+ authParams.push('uri="' + this.uri + '"');
+ authParams.push('response="' + this.response + '"');
+ if (this.opaque) {
+ authParams.push('opaque="' + this.opaque + '"');
+ }
+ if (this.qop) {
+ authParams.push("qop=" + this.qop);
+ authParams.push('cnonce="' + this.cnonce + '"');
+ authParams.push("nc=" + this.ncHex);
+ }
+ return "Digest " + authParams.join(", ");
+ }
+ /**
+ * Generate the 'nc' value as required by Digest in this.ncHex by reading this.nc.
+ */
+ updateNcHex() {
+ const hex = Number(this.nc).toString(16);
+ this.ncHex = "00000000".substr(0, 8 - hex.length) + hex;
+ }
+ /**
+ * Generate Digest 'response' value.
+ */
+ calculateResponse(body) {
+ let ha1, ha2;
+ // HA1 = MD5(A1) = MD5(username:realm:password)
+ ha1 = this.ha1;
+ if (ha1 === "" || ha1 === undefined) {
+ ha1 = MD5(this.username + ":" + this.realm + ":" + this.password);
+ }
+ if (this.qop === "auth") {
+ // HA2 = MD5(A2) = MD5(method:digestURI)
+ ha2 = MD5(this.method + ":" + this.uri);
+ // response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2)`
+ this.response = MD5(ha1 + ":" + this.nonce + ":" + this.ncHex + ":" + this.cnonce + ":auth:" + ha2);
+ }
+ else if (this.qop === "auth-int") {
+ // HA2 = MD5(A2) = MD5(method:digestURI:MD5(entityBody))
+ ha2 = MD5(this.method + ":" + this.uri + ":" + MD5(body ? body : ""));
+ // response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2)
+ this.response = MD5(ha1 + ":" + this.nonce + ":" + this.ncHex + ":" + this.cnonce + ":auth-int:" + ha2);
+ }
+ else if (this.qop === undefined) {
+ // HA2 = MD5(A2) = MD5(method:digestURI)
+ ha2 = MD5(this.method + ":" + this.uri);
+ // response = MD5(HA1:nonce:HA2)
+ this.response = MD5(ha1 + ":" + this.nonce + ":" + ha2);
+ }
+ }
+}
+
+
+/***/ }),
+/* 36 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Md5", function() { return Md5; });
+/* eslint-disable */
+//
+// Scoped from: https://github.com/cotag/ts-md5
+//
+/*
+
+TypeScript Md5
+==============
+
+Based on work by
+* Joseph Myers: http://www.myersdaily.org/joseph/javascript/md5-text.html
+* André Cruz: https://github.com/satazor/SparkMD5
+* Raymond Hill: https://github.com/gorhill/yamd5.js
+
+Effectively a TypeScrypt re-write of Raymond Hill JS Library
+
+The MIT License (MIT)
+
+Copyright (C) 2014 Raymond Hill
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+ Version 2, December 2004
+
+ Copyright (C) 2015 André Cruz
+
+ Everyone is permitted to copy and distribute verbatim or modified
+ copies of this license document, and changing it is allowed as long
+ as the name is changed.
+
+ DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. You just DO WHAT THE FUCK YOU WANT TO.
+
+
+*/
+class Md5 {
+ constructor() {
+ this._dataLength = 0;
+ this._bufferLength = 0;
+ this._state = new Int32Array(4);
+ this._buffer = new ArrayBuffer(68);
+ this._buffer8 = new Uint8Array(this._buffer, 0, 68);
+ this._buffer32 = new Uint32Array(this._buffer, 0, 17);
+ this.start();
+ }
+ static hashStr(str, raw = false) {
+ return this.onePassHasher
+ .start()
+ .appendStr(str)
+ .end(raw);
+ }
+ static hashAsciiStr(str, raw = false) {
+ return this.onePassHasher
+ .start()
+ .appendAsciiStr(str)
+ .end(raw);
+ }
+ static _hex(x) {
+ const hc = Md5.hexChars;
+ const ho = Md5.hexOut;
+ let n;
+ let offset;
+ let j;
+ let i;
+ for (i = 0; i < 4; i += 1) {
+ offset = i * 8;
+ n = x[i];
+ for (j = 0; j < 8; j += 2) {
+ ho[offset + 1 + j] = hc.charAt(n & 0x0F);
+ n >>>= 4;
+ ho[offset + 0 + j] = hc.charAt(n & 0x0F);
+ n >>>= 4;
+ }
+ }
+ return ho.join('');
+ }
+ static _md5cycle(x, k) {
+ let a = x[0];
+ let b = x[1];
+ let c = x[2];
+ let d = x[3];
+ // ff()
+ a += (b & c | ~b & d) + k[0] - 680876936 | 0;
+ a = (a << 7 | a >>> 25) + b | 0;
+ d += (a & b | ~a & c) + k[1] - 389564586 | 0;
+ d = (d << 12 | d >>> 20) + a | 0;
+ c += (d & a | ~d & b) + k[2] + 606105819 | 0;
+ c = (c << 17 | c >>> 15) + d | 0;
+ b += (c & d | ~c & a) + k[3] - 1044525330 | 0;
+ b = (b << 22 | b >>> 10) + c | 0;
+ a += (b & c | ~b & d) + k[4] - 176418897 | 0;
+ a = (a << 7 | a >>> 25) + b | 0;
+ d += (a & b | ~a & c) + k[5] + 1200080426 | 0;
+ d = (d << 12 | d >>> 20) + a | 0;
+ c += (d & a | ~d & b) + k[6] - 1473231341 | 0;
+ c = (c << 17 | c >>> 15) + d | 0;
+ b += (c & d | ~c & a) + k[7] - 45705983 | 0;
+ b = (b << 22 | b >>> 10) + c | 0;
+ a += (b & c | ~b & d) + k[8] + 1770035416 | 0;
+ a = (a << 7 | a >>> 25) + b | 0;
+ d += (a & b | ~a & c) + k[9] - 1958414417 | 0;
+ d = (d << 12 | d >>> 20) + a | 0;
+ c += (d & a | ~d & b) + k[10] - 42063 | 0;
+ c = (c << 17 | c >>> 15) + d | 0;
+ b += (c & d | ~c & a) + k[11] - 1990404162 | 0;
+ b = (b << 22 | b >>> 10) + c | 0;
+ a += (b & c | ~b & d) + k[12] + 1804603682 | 0;
+ a = (a << 7 | a >>> 25) + b | 0;
+ d += (a & b | ~a & c) + k[13] - 40341101 | 0;
+ d = (d << 12 | d >>> 20) + a | 0;
+ c += (d & a | ~d & b) + k[14] - 1502002290 | 0;
+ c = (c << 17 | c >>> 15) + d | 0;
+ b += (c & d | ~c & a) + k[15] + 1236535329 | 0;
+ b = (b << 22 | b >>> 10) + c | 0;
+ // gg()
+ a += (b & d | c & ~d) + k[1] - 165796510 | 0;
+ a = (a << 5 | a >>> 27) + b | 0;
+ d += (a & c | b & ~c) + k[6] - 1069501632 | 0;
+ d = (d << 9 | d >>> 23) + a | 0;
+ c += (d & b | a & ~b) + k[11] + 643717713 | 0;
+ c = (c << 14 | c >>> 18) + d | 0;
+ b += (c & a | d & ~a) + k[0] - 373897302 | 0;
+ b = (b << 20 | b >>> 12) + c | 0;
+ a += (b & d | c & ~d) + k[5] - 701558691 | 0;
+ a = (a << 5 | a >>> 27) + b | 0;
+ d += (a & c | b & ~c) + k[10] + 38016083 | 0;
+ d = (d << 9 | d >>> 23) + a | 0;
+ c += (d & b | a & ~b) + k[15] - 660478335 | 0;
+ c = (c << 14 | c >>> 18) + d | 0;
+ b += (c & a | d & ~a) + k[4] - 405537848 | 0;
+ b = (b << 20 | b >>> 12) + c | 0;
+ a += (b & d | c & ~d) + k[9] + 568446438 | 0;
+ a = (a << 5 | a >>> 27) + b | 0;
+ d += (a & c | b & ~c) + k[14] - 1019803690 | 0;
+ d = (d << 9 | d >>> 23) + a | 0;
+ c += (d & b | a & ~b) + k[3] - 187363961 | 0;
+ c = (c << 14 | c >>> 18) + d | 0;
+ b += (c & a | d & ~a) + k[8] + 1163531501 | 0;
+ b = (b << 20 | b >>> 12) + c | 0;
+ a += (b & d | c & ~d) + k[13] - 1444681467 | 0;
+ a = (a << 5 | a >>> 27) + b | 0;
+ d += (a & c | b & ~c) + k[2] - 51403784 | 0;
+ d = (d << 9 | d >>> 23) + a | 0;
+ c += (d & b | a & ~b) + k[7] + 1735328473 | 0;
+ c = (c << 14 | c >>> 18) + d | 0;
+ b += (c & a | d & ~a) + k[12] - 1926607734 | 0;
+ b = (b << 20 | b >>> 12) + c | 0;
+ // hh()
+ a += (b ^ c ^ d) + k[5] - 378558 | 0;
+ a = (a << 4 | a >>> 28) + b | 0;
+ d += (a ^ b ^ c) + k[8] - 2022574463 | 0;
+ d = (d << 11 | d >>> 21) + a | 0;
+ c += (d ^ a ^ b) + k[11] + 1839030562 | 0;
+ c = (c << 16 | c >>> 16) + d | 0;
+ b += (c ^ d ^ a) + k[14] - 35309556 | 0;
+ b = (b << 23 | b >>> 9) + c | 0;
+ a += (b ^ c ^ d) + k[1] - 1530992060 | 0;
+ a = (a << 4 | a >>> 28) + b | 0;
+ d += (a ^ b ^ c) + k[4] + 1272893353 | 0;
+ d = (d << 11 | d >>> 21) + a | 0;
+ c += (d ^ a ^ b) + k[7] - 155497632 | 0;
+ c = (c << 16 | c >>> 16) + d | 0;
+ b += (c ^ d ^ a) + k[10] - 1094730640 | 0;
+ b = (b << 23 | b >>> 9) + c | 0;
+ a += (b ^ c ^ d) + k[13] + 681279174 | 0;
+ a = (a << 4 | a >>> 28) + b | 0;
+ d += (a ^ b ^ c) + k[0] - 358537222 | 0;
+ d = (d << 11 | d >>> 21) + a | 0;
+ c += (d ^ a ^ b) + k[3] - 722521979 | 0;
+ c = (c << 16 | c >>> 16) + d | 0;
+ b += (c ^ d ^ a) + k[6] + 76029189 | 0;
+ b = (b << 23 | b >>> 9) + c | 0;
+ a += (b ^ c ^ d) + k[9] - 640364487 | 0;
+ a = (a << 4 | a >>> 28) + b | 0;
+ d += (a ^ b ^ c) + k[12] - 421815835 | 0;
+ d = (d << 11 | d >>> 21) + a | 0;
+ c += (d ^ a ^ b) + k[15] + 530742520 | 0;
+ c = (c << 16 | c >>> 16) + d | 0;
+ b += (c ^ d ^ a) + k[2] - 995338651 | 0;
+ b = (b << 23 | b >>> 9) + c | 0;
+ // ii()
+ a += (c ^ (b | ~d)) + k[0] - 198630844 | 0;
+ a = (a << 6 | a >>> 26) + b | 0;
+ d += (b ^ (a | ~c)) + k[7] + 1126891415 | 0;
+ d = (d << 10 | d >>> 22) + a | 0;
+ c += (a ^ (d | ~b)) + k[14] - 1416354905 | 0;
+ c = (c << 15 | c >>> 17) + d | 0;
+ b += (d ^ (c | ~a)) + k[5] - 57434055 | 0;
+ b = (b << 21 | b >>> 11) + c | 0;
+ a += (c ^ (b | ~d)) + k[12] + 1700485571 | 0;
+ a = (a << 6 | a >>> 26) + b | 0;
+ d += (b ^ (a | ~c)) + k[3] - 1894986606 | 0;
+ d = (d << 10 | d >>> 22) + a | 0;
+ c += (a ^ (d | ~b)) + k[10] - 1051523 | 0;
+ c = (c << 15 | c >>> 17) + d | 0;
+ b += (d ^ (c | ~a)) + k[1] - 2054922799 | 0;
+ b = (b << 21 | b >>> 11) + c | 0;
+ a += (c ^ (b | ~d)) + k[8] + 1873313359 | 0;
+ a = (a << 6 | a >>> 26) + b | 0;
+ d += (b ^ (a | ~c)) + k[15] - 30611744 | 0;
+ d = (d << 10 | d >>> 22) + a | 0;
+ c += (a ^ (d | ~b)) + k[6] - 1560198380 | 0;
+ c = (c << 15 | c >>> 17) + d | 0;
+ b += (d ^ (c | ~a)) + k[13] + 1309151649 | 0;
+ b = (b << 21 | b >>> 11) + c | 0;
+ a += (c ^ (b | ~d)) + k[4] - 145523070 | 0;
+ a = (a << 6 | a >>> 26) + b | 0;
+ d += (b ^ (a | ~c)) + k[11] - 1120210379 | 0;
+ d = (d << 10 | d >>> 22) + a | 0;
+ c += (a ^ (d | ~b)) + k[2] + 718787259 | 0;
+ c = (c << 15 | c >>> 17) + d | 0;
+ b += (d ^ (c | ~a)) + k[9] - 343485551 | 0;
+ b = (b << 21 | b >>> 11) + c | 0;
+ x[0] = a + x[0] | 0;
+ x[1] = b + x[1] | 0;
+ x[2] = c + x[2] | 0;
+ x[3] = d + x[3] | 0;
+ }
+ start() {
+ this._dataLength = 0;
+ this._bufferLength = 0;
+ this._state.set(Md5.stateIdentity);
+ return this;
+ }
+ // Char to code point to to array conversion:
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/charCodeAt
+ // #Example.3A_Fixing_charCodeAt_to_handle_non-Basic-Multilingual-Plane_characters_if_their_presence_earlier_in_the_string_is_unknown
+ appendStr(str) {
+ const buf8 = this._buffer8;
+ const buf32 = this._buffer32;
+ let bufLen = this._bufferLength;
+ let code;
+ let i;
+ for (i = 0; i < str.length; i += 1) {
+ code = str.charCodeAt(i);
+ if (code < 128) {
+ buf8[bufLen++] = code;
+ }
+ else if (code < 0x800) {
+ buf8[bufLen++] = (code >>> 6) + 0xC0;
+ buf8[bufLen++] = code & 0x3F | 0x80;
+ }
+ else if (code < 0xD800 || code > 0xDBFF) {
+ buf8[bufLen++] = (code >>> 12) + 0xE0;
+ buf8[bufLen++] = (code >>> 6 & 0x3F) | 0x80;
+ buf8[bufLen++] = (code & 0x3F) | 0x80;
+ }
+ else {
+ code = ((code - 0xD800) * 0x400) + (str.charCodeAt(++i) - 0xDC00) + 0x10000;
+ if (code > 0x10FFFF) {
+ throw new Error('Unicode standard supports code points up to U+10FFFF');
+ }
+ buf8[bufLen++] = (code >>> 18) + 0xF0;
+ buf8[bufLen++] = (code >>> 12 & 0x3F) | 0x80;
+ buf8[bufLen++] = (code >>> 6 & 0x3F) | 0x80;
+ buf8[bufLen++] = (code & 0x3F) | 0x80;
+ }
+ if (bufLen >= 64) {
+ this._dataLength += 64;
+ Md5._md5cycle(this._state, buf32);
+ bufLen -= 64;
+ buf32[0] = buf32[16];
+ }
+ }
+ this._bufferLength = bufLen;
+ return this;
+ }
+ appendAsciiStr(str) {
+ const buf8 = this._buffer8;
+ const buf32 = this._buffer32;
+ let bufLen = this._bufferLength;
+ let i;
+ let j = 0;
+ for (;;) {
+ i = Math.min(str.length - j, 64 - bufLen);
+ while (i--) {
+ buf8[bufLen++] = str.charCodeAt(j++);
+ }
+ if (bufLen < 64) {
+ break;
+ }
+ this._dataLength += 64;
+ Md5._md5cycle(this._state, buf32);
+ bufLen = 0;
+ }
+ this._bufferLength = bufLen;
+ return this;
+ }
+ appendByteArray(input) {
+ const buf8 = this._buffer8;
+ const buf32 = this._buffer32;
+ let bufLen = this._bufferLength;
+ let i;
+ let j = 0;
+ for (;;) {
+ i = Math.min(input.length - j, 64 - bufLen);
+ while (i--) {
+ buf8[bufLen++] = input[j++];
+ }
+ if (bufLen < 64) {
+ break;
+ }
+ this._dataLength += 64;
+ Md5._md5cycle(this._state, buf32);
+ bufLen = 0;
+ }
+ this._bufferLength = bufLen;
+ return this;
+ }
+ getState() {
+ const self = this;
+ const s = self._state;
+ return {
+ buffer: String.fromCharCode.apply(null, self._buffer8),
+ buflen: self._bufferLength,
+ length: self._dataLength,
+ state: [s[0], s[1], s[2], s[3]]
+ };
+ }
+ setState(state) {
+ const buf = state.buffer;
+ const x = state.state;
+ const s = this._state;
+ let i;
+ this._dataLength = state.length;
+ this._bufferLength = state.buflen;
+ s[0] = x[0];
+ s[1] = x[1];
+ s[2] = x[2];
+ s[3] = x[3];
+ for (i = 0; i < buf.length; i += 1) {
+ this._buffer8[i] = buf.charCodeAt(i);
+ }
+ }
+ end(raw = false) {
+ const bufLen = this._bufferLength;
+ const buf8 = this._buffer8;
+ const buf32 = this._buffer32;
+ const i = (bufLen >> 2) + 1;
+ let dataBitsLen;
+ this._dataLength += bufLen;
+ buf8[bufLen] = 0x80;
+ buf8[bufLen + 1] = buf8[bufLen + 2] = buf8[bufLen + 3] = 0;
+ buf32.set(Md5.buffer32Identity.subarray(i), i);
+ if (bufLen > 55) {
+ Md5._md5cycle(this._state, buf32);
+ buf32.set(Md5.buffer32Identity);
+ }
+ // Do the final computation based on the tail and length
+ // Beware that the final length may not fit in 32 bits so we take care of that
+ dataBitsLen = this._dataLength * 8;
+ if (dataBitsLen <= 0xFFFFFFFF) {
+ buf32[14] = dataBitsLen;
+ }
+ else {
+ const matches = dataBitsLen.toString(16).match(/(.*?)(.{0,8})$/);
+ if (matches === null) {
+ return;
+ }
+ const lo = parseInt(matches[2], 16);
+ const hi = parseInt(matches[1], 16) || 0;
+ buf32[14] = lo;
+ buf32[15] = hi;
+ }
+ Md5._md5cycle(this._state, buf32);
+ return raw ? this._state : Md5._hex(this._state);
+ }
+}
+// Private Static Variables
+Md5.stateIdentity = new Int32Array([1732584193, -271733879, -1732584194, 271733878]);
+Md5.buffer32Identity = new Int32Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
+Md5.hexChars = '0123456789abcdef';
+Md5.hexOut = [];
+// Permanent instance is to use for one-call hashing
+Md5.onePassHasher = new Md5();
+if (Md5.hashStr('hello') !== '5d41402abc4b2a76b9719d911017c592') {
+ console.error('Md5 self test failed.');
+}
+
+
+/***/ }),
+/* 37 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 38 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 39 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 40 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "constructOutgoingResponse", function() { return constructOutgoingResponse; });
+/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(32);
+
+/**
+ * When a UAS wishes to construct a response to a request, it follows
+ * the general procedures detailed in the following subsections.
+ * Additional behaviors specific to the response code in question, which
+ * are not detailed in this section, may also be required.
+ * https://tools.ietf.org/html/rfc3261#section-8.2.6
+ * @internal
+ */
+function constructOutgoingResponse(message, options) {
+ const CRLF = "\r\n";
+ if (options.statusCode < 100 || options.statusCode > 699) {
+ throw new TypeError("Invalid statusCode: " + options.statusCode);
+ }
+ const reasonPhrase = options.reasonPhrase ? options.reasonPhrase : Object(_utils__WEBPACK_IMPORTED_MODULE_0__["getReasonPhrase"])(options.statusCode);
+ // SIP responses are distinguished from requests by having a Status-Line
+ // as their start-line. A Status-Line consists of the protocol version
+ // followed by a numeric Status-Code and its associated textual phrase,
+ // with each element separated by a single SP character.
+ // https://tools.ietf.org/html/rfc3261#section-7.2
+ let response = "SIP/2.0 " + options.statusCode + " " + reasonPhrase + CRLF;
+ // One largely non-method-specific guideline for the generation of
+ // responses is that UASs SHOULD NOT issue a provisional response for a
+ // non-INVITE request. Rather, UASs SHOULD generate a final response to
+ // a non-INVITE request as soon as possible.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.6.1
+ if (options.statusCode >= 100 && options.statusCode < 200) {
+ // TODO
+ }
+ // When a 100 (Trying) response is generated, any Timestamp header field
+ // present in the request MUST be copied into this 100 (Trying)
+ // response. If there is a delay in generating the response, the UAS
+ // SHOULD add a delay value into the Timestamp value in the response.
+ // This value MUST contain the difference between the time of sending of
+ // the response and receipt of the request, measured in seconds.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.6.1
+ if (options.statusCode === 100) {
+ // TODO
+ }
+ // The From field of the response MUST equal the From header field of
+ // the request. The Call-ID header field of the response MUST equal the
+ // Call-ID header field of the request. The CSeq header field of the
+ // response MUST equal the CSeq field of the request. The Via header
+ // field values in the response MUST equal the Via header field values
+ // in the request and MUST maintain the same ordering.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.6.2
+ const fromHeader = "From: " + message.getHeader("From") + CRLF;
+ const callIdHeader = "Call-ID: " + message.callId + CRLF;
+ const cSeqHeader = "CSeq: " + message.cseq + " " + message.method + CRLF;
+ const viaHeaders = message.getHeaders("via").reduce((previous, current) => {
+ return previous + "Via: " + current + CRLF;
+ }, "");
+ // If a request contained a To tag in the request, the To header field
+ // in the response MUST equal that of the request. However, if the To
+ // header field in the request did not contain a tag, the URI in the To
+ // header field in the response MUST equal the URI in the To header
+ // field; additionally, the UAS MUST add a tag to the To header field in
+ // the response (with the exception of the 100 (Trying) response, in
+ // which a tag MAY be present). This serves to identify the UAS that is
+ // responding, possibly resulting in a component of a dialog ID. The
+ // same tag MUST be used for all responses to that request, both final
+ // and provisional (again excepting the 100 (Trying)).
+ // https://tools.ietf.org/html/rfc3261#section-8.2.6.2
+ let toHeader = "To: " + message.getHeader("to");
+ if (options.statusCode > 100 && !message.parseHeader("to").hasParam("tag")) {
+ let toTag = options.toTag;
+ if (!toTag) {
+ // Stateless UAS Behavior...
+ // o To header tags MUST be generated for responses in a stateless
+ // manner - in a manner that will generate the same tag for the
+ // same request consistently. For information on tag construction
+ // see Section 19.3.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.7
+ toTag = Object(_utils__WEBPACK_IMPORTED_MODULE_0__["newTag"])(); // FIXME: newTag() currently generates random tags
+ }
+ toHeader += ";tag=" + toTag;
+ }
+ toHeader += CRLF;
+ // FIXME: TODO: needs review... moved to InviteUserAgentServer (as it is specific to that)
+ // let recordRouteHeaders = "";
+ // if (request.method === C.INVITE && statusCode > 100 && statusCode <= 200) {
+ // recordRouteHeaders = request.getHeaders("record-route").reduce((previous, current) => {
+ // return previous + "Record-Route: " + current + CRLF;
+ // }, "");
+ // }
+ // FIXME: TODO: needs review...
+ let supportedHeader = "";
+ if (options.supported) {
+ supportedHeader = "Supported: " + options.supported.join(", ") + CRLF;
+ }
+ // FIXME: TODO: needs review...
+ let userAgentHeader = "";
+ if (options.userAgent) {
+ userAgentHeader = "User-Agent: " + options.userAgent + CRLF;
+ }
+ let extensionHeaders = "";
+ if (options.extraHeaders) {
+ extensionHeaders = options.extraHeaders.reduce((previous, current) => {
+ return previous + current.trim() + CRLF;
+ }, "");
+ }
+ // The relative order of header fields with different field names is not
+ // significant. However, it is RECOMMENDED that header fields which are
+ // needed for proxy processing (Via, Route, Record-Route, Proxy-Require,
+ // Max-Forwards, and Proxy-Authorization, for example) appear towards
+ // the top of the message to facilitate rapid parsing.
+ // https://tools.ietf.org/html/rfc3261#section-7.3.1
+ // response += recordRouteHeaders;
+ response += viaHeaders;
+ response += fromHeader;
+ response += toHeader;
+ response += cSeqHeader;
+ response += callIdHeader;
+ response += supportedHeader;
+ response += userAgentHeader;
+ response += extensionHeaders;
+ if (options.body) {
+ response += "Content-Type: " + options.body.contentType + CRLF;
+ response += "Content-Length: " + Object(_utils__WEBPACK_IMPORTED_MODULE_0__["utf8Length"])(options.body.content) + CRLF + CRLF;
+ response += options.body.content;
+ }
+ else {
+ response += "Content-Length: " + 0 + CRLF + CRLF;
+ }
+ return { message: response };
+}
+
+
+/***/ }),
+/* 41 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Parser", function() { return Parser; });
+/* harmony import */ var _grammar__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(9);
+/* harmony import */ var _incoming_request_message__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(30);
+/* harmony import */ var _incoming_response_message__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(33);
+/* eslint-disable no-inner-declarations */
+/* eslint-disable @typescript-eslint/no-namespace */
+
+
+
+/**
+ * Extract and parse every header of a SIP message.
+ * @internal
+ */
+var Parser;
+(function (Parser) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ function getHeader(data, headerStart) {
+ // 'start' position of the header.
+ let start = headerStart;
+ // 'end' position of the header.
+ let end = 0;
+ // 'partial end' position of the header.
+ let partialEnd = 0;
+ // End of message.
+ if (data.substring(start, start + 2).match(/(^\r\n)/)) {
+ return -2;
+ }
+ while (end === 0) {
+ // Partial End of Header.
+ partialEnd = data.indexOf("\r\n", start);
+ // 'indexOf' returns -1 if the value to be found never occurs.
+ if (partialEnd === -1) {
+ return partialEnd;
+ }
+ if (!data.substring(partialEnd + 2, partialEnd + 4).match(/(^\r\n)/) &&
+ data.charAt(partialEnd + 2).match(/(^\s+)/)) {
+ // Not the end of the message. Continue from the next position.
+ start = partialEnd + 2;
+ }
+ else {
+ end = partialEnd;
+ }
+ }
+ return end;
+ }
+ Parser.getHeader = getHeader;
+ function parseHeader(message,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ data, headerStart, headerEnd) {
+ const hcolonIndex = data.indexOf(":", headerStart);
+ const headerName = data.substring(headerStart, hcolonIndex).trim();
+ const headerValue = data.substring(hcolonIndex + 1, headerEnd).trim();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let parsed;
+ // If header-field is well-known, parse it.
+ switch (headerName.toLowerCase()) {
+ case "via":
+ case "v":
+ message.addHeader("via", headerValue);
+ if (message.getHeaders("via").length === 1) {
+ parsed = message.parseHeader("Via");
+ if (parsed) {
+ message.via = parsed;
+ message.viaBranch = parsed.branch;
+ }
+ }
+ else {
+ parsed = 0;
+ }
+ break;
+ case "from":
+ case "f":
+ message.setHeader("from", headerValue);
+ parsed = message.parseHeader("from");
+ if (parsed) {
+ message.from = parsed;
+ message.fromTag = parsed.getParam("tag");
+ }
+ break;
+ case "to":
+ case "t":
+ message.setHeader("to", headerValue);
+ parsed = message.parseHeader("to");
+ if (parsed) {
+ message.to = parsed;
+ message.toTag = parsed.getParam("tag");
+ }
+ break;
+ case "record-route":
+ parsed = _grammar__WEBPACK_IMPORTED_MODULE_0__["Grammar"].parse(headerValue, "Record_Route");
+ if (parsed === -1) {
+ parsed = undefined;
+ break;
+ }
+ if (!(parsed instanceof Array)) {
+ parsed = undefined;
+ break;
+ }
+ parsed.forEach((header) => {
+ message.addHeader("record-route", headerValue.substring(header.position, header.offset));
+ message.headers["Record-Route"][message.getHeaders("record-route").length - 1].parsed = header.parsed;
+ });
+ break;
+ case "call-id":
+ case "i":
+ message.setHeader("call-id", headerValue);
+ parsed = message.parseHeader("call-id");
+ if (parsed) {
+ message.callId = headerValue;
+ }
+ break;
+ case "contact":
+ case "m":
+ parsed = _grammar__WEBPACK_IMPORTED_MODULE_0__["Grammar"].parse(headerValue, "Contact");
+ if (parsed === -1) {
+ parsed = undefined;
+ break;
+ }
+ if (!(parsed instanceof Array)) {
+ parsed = undefined;
+ break;
+ }
+ parsed.forEach((header) => {
+ message.addHeader("contact", headerValue.substring(header.position, header.offset));
+ message.headers.Contact[message.getHeaders("contact").length - 1].parsed = header.parsed;
+ });
+ break;
+ case "content-length":
+ case "l":
+ message.setHeader("content-length", headerValue);
+ parsed = message.parseHeader("content-length");
+ break;
+ case "content-type":
+ case "c":
+ message.setHeader("content-type", headerValue);
+ parsed = message.parseHeader("content-type");
+ break;
+ case "cseq":
+ message.setHeader("cseq", headerValue);
+ parsed = message.parseHeader("cseq");
+ if (parsed) {
+ message.cseq = parsed.value;
+ }
+ if (message instanceof _incoming_response_message__WEBPACK_IMPORTED_MODULE_2__["IncomingResponseMessage"]) {
+ message.method = parsed.method;
+ }
+ break;
+ case "max-forwards":
+ message.setHeader("max-forwards", headerValue);
+ parsed = message.parseHeader("max-forwards");
+ break;
+ case "www-authenticate":
+ message.setHeader("www-authenticate", headerValue);
+ parsed = message.parseHeader("www-authenticate");
+ break;
+ case "proxy-authenticate":
+ message.setHeader("proxy-authenticate", headerValue);
+ parsed = message.parseHeader("proxy-authenticate");
+ break;
+ case "refer-to":
+ case "r":
+ message.setHeader("refer-to", headerValue);
+ parsed = message.parseHeader("refer-to");
+ if (parsed) {
+ message.referTo = parsed;
+ }
+ break;
+ default:
+ // Do not parse this header.
+ message.addHeader(headerName.toLowerCase(), headerValue);
+ parsed = 0;
+ }
+ if (parsed === undefined) {
+ return {
+ error: "error parsing header '" + headerName + "'"
+ };
+ }
+ else {
+ return true;
+ }
+ }
+ Parser.parseHeader = parseHeader;
+ function parseMessage(data, logger) {
+ let headerStart = 0;
+ let headerEnd = data.indexOf("\r\n");
+ if (headerEnd === -1) {
+ logger.warn("no CRLF found, not a SIP message, discarded");
+ return;
+ }
+ // Parse first line. Check if it is a Request or a Reply.
+ const firstLine = data.substring(0, headerEnd);
+ const parsed = _grammar__WEBPACK_IMPORTED_MODULE_0__["Grammar"].parse(firstLine, "Request_Response");
+ let message;
+ if (parsed === -1) {
+ logger.warn('error parsing first line of SIP message: "' + firstLine + '"');
+ return;
+ }
+ else if (!parsed.status_code) {
+ message = new _incoming_request_message__WEBPACK_IMPORTED_MODULE_1__["IncomingRequestMessage"]();
+ message.method = parsed.method;
+ message.ruri = parsed.uri;
+ }
+ else {
+ message = new _incoming_response_message__WEBPACK_IMPORTED_MODULE_2__["IncomingResponseMessage"]();
+ message.statusCode = parsed.status_code;
+ message.reasonPhrase = parsed.reason_phrase;
+ }
+ message.data = data;
+ headerStart = headerEnd + 2;
+ // Loop over every line in data. Detect the end of each header and parse
+ // it or simply add to the headers collection.
+ let bodyStart;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ headerEnd = getHeader(data, headerStart);
+ // The SIP message has normally finished.
+ if (headerEnd === -2) {
+ bodyStart = headerStart + 2;
+ break;
+ }
+ else if (headerEnd === -1) {
+ // data.indexOf returned -1 due to a malformed message.
+ logger.error("malformed message");
+ return;
+ }
+ const parsedHeader = parseHeader(message, data, headerStart, headerEnd);
+ if (parsedHeader !== true) {
+ logger.error(parsed.error);
+ return;
+ }
+ headerStart = headerEnd + 2;
+ }
+ // RFC3261 18.3.
+ // If there are additional bytes in the transport packet
+ // beyond the end of the body, they MUST be discarded.
+ if (message.hasHeader("content-length")) {
+ message.body = data.substr(bodyStart, Number(message.getHeader("content-length")));
+ }
+ else {
+ message.body = data.substring(bodyStart);
+ }
+ return message;
+ }
+ Parser.parseMessage = parseMessage;
+})(Parser || (Parser = {}));
+
+
+/***/ }),
+/* 42 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 43 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SessionDialog", function() { return SessionDialog; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44);
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(48);
+/* harmony import */ var _user_agents_bye_user_agent_client__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(62);
+/* harmony import */ var _user_agents_bye_user_agent_server__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(64);
+/* harmony import */ var _user_agents_info_user_agent_client__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(66);
+/* harmony import */ var _user_agents_info_user_agent_server__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(67);
+/* harmony import */ var _user_agents_message_user_agent_client__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(68);
+/* harmony import */ var _user_agents_message_user_agent_server__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(69);
+/* harmony import */ var _user_agents_notify_user_agent_client__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(70);
+/* harmony import */ var _user_agents_notify_user_agent_server__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(71);
+/* harmony import */ var _user_agents_prack_user_agent_client__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(72);
+/* harmony import */ var _user_agents_prack_user_agent_server__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(73);
+/* harmony import */ var _user_agents_re_invite_user_agent_client__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(74);
+/* harmony import */ var _user_agents_re_invite_user_agent_server__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(75);
+/* harmony import */ var _user_agents_refer_user_agent_client__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(76);
+/* harmony import */ var _user_agents_refer_user_agent_server__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(77);
+/* harmony import */ var _dialog__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(7);
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+/**
+ * Session Dialog.
+ * @public
+ */
+class SessionDialog extends _dialog__WEBPACK_IMPORTED_MODULE_18__["Dialog"] {
+ constructor(initialTransaction, core, state, delegate) {
+ super(core, state);
+ this.initialTransaction = initialTransaction;
+ /** The state of the offer/answer exchange. */
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial;
+ /** True if waiting for an ACK to the initial transaction 2xx (UAS only). */
+ this.ackWait = false;
+ /** True if processing an ACK to the initial transaction 2xx (UAS only). */
+ this.ackProcessing = false;
+ this.delegate = delegate;
+ if (initialTransaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ // If we're created by an invite server transaction, we're
+ // going to be waiting for an ACK if are to be confirmed.
+ this.ackWait = true;
+ }
+ // If we're confirmed upon creation start the retransmitting whatever
+ // the 2xx final response was that confirmed us into existence.
+ if (!this.early) {
+ this.start2xxRetransmissionTimer();
+ }
+ this.signalingStateTransition(initialTransaction.request);
+ this.logger = core.loggerFactory.getLogger("sip.invite-dialog");
+ this.logger.log(`INVITE dialog ${this.id} constructed`);
+ }
+ dispose() {
+ super.dispose();
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Closed;
+ this._offer = undefined;
+ this._answer = undefined;
+ if (this.invite2xxTimer) {
+ clearTimeout(this.invite2xxTimer);
+ this.invite2xxTimer = undefined;
+ }
+ // The UAS MUST still respond to any pending requests received for that
+ // dialog. It is RECOMMENDED that a 487 (Request Terminated) response
+ // be generated to those pending requests.
+ // https://tools.ietf.org/html/rfc3261#section-15.1.2
+ // TODO:
+ // this.userAgentServers.forEach((uas) => uas.reply(487));
+ this.logger.log(`INVITE dialog ${this.id} destroyed`);
+ }
+ // FIXME: Need real state machine
+ get sessionState() {
+ if (this.early) {
+ return _session__WEBPACK_IMPORTED_MODULE_1__["SessionState"].Early;
+ }
+ else if (this.ackWait) {
+ return _session__WEBPACK_IMPORTED_MODULE_1__["SessionState"].AckWait;
+ }
+ else if (this._signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Closed) {
+ return _session__WEBPACK_IMPORTED_MODULE_1__["SessionState"].Terminated;
+ }
+ else {
+ return _session__WEBPACK_IMPORTED_MODULE_1__["SessionState"].Confirmed;
+ }
+ }
+ /** The state of the offer/answer exchange. */
+ get signalingState() {
+ return this._signalingState;
+ }
+ /** The current offer. Undefined unless signaling state HaveLocalOffer, HaveRemoteOffer, of Stable. */
+ get offer() {
+ return this._offer;
+ }
+ /** The current answer. Undefined unless signaling state Stable. */
+ get answer() {
+ return this._answer;
+ }
+ /** Confirm the dialog. Only matters if dialog is currently early. */
+ confirm() {
+ // When we're confirmed start the retransmitting whatever
+ // the 2xx final response that may have confirmed us.
+ if (this.early) {
+ this.start2xxRetransmissionTimer();
+ }
+ super.confirm();
+ }
+ /** Re-confirm the dialog. Only matters if handling re-INVITE request. */
+ reConfirm() {
+ // When we're confirmed start the retransmitting whatever
+ // the 2xx final response that may have confirmed us.
+ if (this.reinviteUserAgentServer) {
+ this.startReInvite2xxRetransmissionTimer();
+ }
+ }
+ /**
+ * The UAC core MUST generate an ACK request for each 2xx received from
+ * the transaction layer. The header fields of the ACK are constructed
+ * in the same way as for any request sent within a dialog (see Section
+ * 12) with the exception of the CSeq and the header fields related to
+ * authentication. The sequence number of the CSeq header field MUST be
+ * the same as the INVITE being acknowledged, but the CSeq method MUST
+ * be ACK. The ACK MUST contain the same credentials as the INVITE. If
+ * the 2xx contains an offer (based on the rules above), the ACK MUST
+ * carry an answer in its body. If the offer in the 2xx response is not
+ * acceptable, the UAC core MUST generate a valid answer in the ACK and
+ * then send a BYE immediately.
+ * https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ * @param options - ACK options bucket.
+ */
+ ack(options = {}) {
+ this.logger.log(`INVITE dialog ${this.id} sending ACK request`);
+ let transaction;
+ if (this.reinviteUserAgentClient) {
+ // We're sending ACK for a re-INVITE
+ if (!(this.reinviteUserAgentClient.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteClientTransaction"])) {
+ throw new Error("Transaction not instance of InviteClientTransaction.");
+ }
+ transaction = this.reinviteUserAgentClient.transaction;
+ this.reinviteUserAgentClient = undefined;
+ }
+ else {
+ // We're sending ACK for the initial INVITE
+ if (!(this.initialTransaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteClientTransaction"])) {
+ throw new Error("Initial transaction not instance of InviteClientTransaction.");
+ }
+ transaction = this.initialTransaction;
+ }
+ const message = this.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK, {
+ cseq: transaction.request.cseq,
+ extraHeaders: options.extraHeaders,
+ body: options.body
+ });
+ transaction.ackResponse(message); // See InviteClientTransaction for details.
+ this.signalingStateTransition(message);
+ return { message };
+ }
+ /**
+ * Terminating a Session
+ *
+ * This section describes the procedures for terminating a session
+ * established by SIP. The state of the session and the state of the
+ * dialog are very closely related. When a session is initiated with an
+ * INVITE, each 1xx or 2xx response from a distinct UAS creates a
+ * dialog, and if that response completes the offer/answer exchange, it
+ * also creates a session. As a result, each session is "associated"
+ * with a single dialog - the one which resulted in its creation. If an
+ * initial INVITE generates a non-2xx final response, that terminates
+ * all sessions (if any) and all dialogs (if any) that were created
+ * through responses to the request. By virtue of completing the
+ * transaction, a non-2xx final response also prevents further sessions
+ * from being created as a result of the INVITE. The BYE request is
+ * used to terminate a specific session or attempted session. In this
+ * case, the specific session is the one with the peer UA on the other
+ * side of the dialog. When a BYE is received on a dialog, any session
+ * associated with that dialog SHOULD terminate. A UA MUST NOT send a
+ * BYE outside of a dialog. The caller's UA MAY send a BYE for either
+ * confirmed or early dialogs, and the callee's UA MAY send a BYE on
+ * confirmed dialogs, but MUST NOT send a BYE on early dialogs.
+ *
+ * However, the callee's UA MUST NOT send a BYE on a confirmed dialog
+ * until it has received an ACK for its 2xx response or until the server
+ * transaction times out. If no SIP extensions have defined other
+ * application layer states associated with the dialog, the BYE also
+ * terminates the dialog.
+ *
+ * https://tools.ietf.org/html/rfc3261#section-15
+ * FIXME: Make these proper Exceptions...
+ * @param options - BYE options bucket.
+ * @returns
+ * Throws `Error` if callee's UA attempts a BYE on an early dialog.
+ * Throws `Error` if callee's UA attempts a BYE on a confirmed dialog
+ * while it's waiting on the ACK for its 2xx response.
+ */
+ bye(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending BYE request`);
+ // The caller's UA MAY send a BYE for either
+ // confirmed or early dialogs, and the callee's UA MAY send a BYE on
+ // confirmed dialogs, but MUST NOT send a BYE on early dialogs.
+ //
+ // However, the callee's UA MUST NOT send a BYE on a confirmed dialog
+ // until it has received an ACK for its 2xx response or until the server
+ // transaction times out.
+ // https://tools.ietf.org/html/rfc3261#section-15
+ if (this.initialTransaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ if (this.early) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("UAS MUST NOT send a BYE on early dialogs.");
+ }
+ if (this.ackWait && this.initialTransaction.state !== _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("UAS MUST NOT send a BYE on a confirmed dialog " +
+ "until it has received an ACK for its 2xx response " +
+ "or until the server transaction times out.");
+ }
+ }
+ // A BYE request is constructed as would any other request within a
+ // dialog, as described in Section 12.
+ //
+ // Once the BYE is constructed, the UAC core creates a new non-INVITE
+ // client transaction, and passes it the BYE request. The UAC MUST
+ // consider the session terminated (and therefore stop sending or
+ // listening for media) as soon as the BYE request is passed to the
+ // client transaction. If the response for the BYE is a 481
+ // (Call/Transaction Does Not Exist) or a 408 (Request Timeout) or no
+ // response at all is received for the BYE (that is, a timeout is
+ // returned by the client transaction), the UAC MUST consider the
+ // session and the dialog terminated.
+ // https://tools.ietf.org/html/rfc3261#section-15.1.1
+ return new _user_agents_bye_user_agent_client__WEBPACK_IMPORTED_MODULE_4__["ByeUserAgentClient"](this, delegate, options);
+ }
+ /**
+ * An INFO request can be associated with an Info Package (see
+ * Section 5), or associated with a legacy INFO usage (see Section 2).
+ *
+ * The construction of the INFO request is the same as any other
+ * non-target refresh request within an existing invite dialog usage as
+ * described in Section 12.2 of RFC 3261.
+ * https://tools.ietf.org/html/rfc6086#section-4.2.1
+ * @param options - Options bucket.
+ */
+ info(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending INFO request`);
+ if (this.early) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("Dialog not confirmed.");
+ }
+ return new _user_agents_info_user_agent_client__WEBPACK_IMPORTED_MODULE_6__["InfoUserAgentClient"](this, delegate, options);
+ }
+ /**
+ * Modifying an Existing Session
+ *
+ * A successful INVITE request (see Section 13) establishes both a
+ * dialog between two user agents and a session using the offer-answer
+ * model. Section 12 explains how to modify an existing dialog using a
+ * target refresh request (for example, changing the remote target URI
+ * of the dialog). This section describes how to modify the actual
+ * session. This modification can involve changing addresses or ports,
+ * adding a media stream, deleting a media stream, and so on. This is
+ * accomplished by sending a new INVITE request within the same dialog
+ * that established the session. An INVITE request sent within an
+ * existing dialog is known as a re-INVITE.
+ *
+ * Note that a single re-INVITE can modify the dialog and the
+ * parameters of the session at the same time.
+ *
+ * Either the caller or callee can modify an existing session.
+ * https://tools.ietf.org/html/rfc3261#section-14
+ * @param options - Options bucket
+ */
+ invite(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending INVITE request`);
+ if (this.early) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("Dialog not confirmed.");
+ }
+ // Note that a UAC MUST NOT initiate a new INVITE transaction within a
+ // dialog while another INVITE transaction is in progress in either
+ // direction.
+ //
+ // 1. If there is an ongoing INVITE client transaction, the TU MUST
+ // wait until the transaction reaches the completed or terminated
+ // state before initiating the new INVITE.
+ //
+ // 2. If there is an ongoing INVITE server transaction, the TU MUST
+ // wait until the transaction reaches the confirmed or terminated
+ // state before initiating the new INVITE.
+ //
+ // However, a UA MAY initiate a regular transaction while an INVITE
+ // transaction is in progress. A UA MAY also initiate an INVITE
+ // transaction while a regular transaction is in progress.
+ // https://tools.ietf.org/html/rfc3261#section-14.1
+ if (this.reinviteUserAgentClient) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("There is an ongoing re-INVITE client transaction.");
+ }
+ if (this.reinviteUserAgentServer) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("There is an ongoing re-INVITE server transaction.");
+ }
+ return new _user_agents_re_invite_user_agent_client__WEBPACK_IMPORTED_MODULE_14__["ReInviteUserAgentClient"](this, delegate, options);
+ }
+ /**
+ * A UAC MAY associate a MESSAGE request with an existing dialog. If a
+ * MESSAGE request is sent within a dialog, it is "associated" with any
+ * media session or sessions associated with that dialog.
+ * https://tools.ietf.org/html/rfc3428#section-4
+ * @param options - Options bucket.
+ */
+ message(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending MESSAGE request`);
+ if (this.early) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("Dialog not confirmed.");
+ }
+ const message = this.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].MESSAGE, options);
+ return new _user_agents_message_user_agent_client__WEBPACK_IMPORTED_MODULE_8__["MessageUserAgentClient"](this.core, message, delegate);
+ }
+ /**
+ * The NOTIFY mechanism defined in [2] MUST be used to inform the agent
+ * sending the REFER of the status of the reference.
+ * https://tools.ietf.org/html/rfc3515#section-2.4.4
+ * @param options - Options bucket.
+ */
+ notify(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending NOTIFY request`);
+ if (this.early) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("Dialog not confirmed.");
+ }
+ return new _user_agents_notify_user_agent_client__WEBPACK_IMPORTED_MODULE_10__["NotifyUserAgentClient"](this, delegate, options);
+ }
+ /**
+ * Assuming the response is to be transmitted reliably, the UAC MUST
+ * create a new request with method PRACK. This request is sent within
+ * the dialog associated with the provisional response (indeed, the
+ * provisional response may have created the dialog). PRACK requests
+ * MAY contain bodies, which are interpreted according to their type and
+ * disposition.
+ * https://tools.ietf.org/html/rfc3262#section-4
+ * @param options - Options bucket.
+ */
+ prack(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending PRACK request`);
+ return new _user_agents_prack_user_agent_client__WEBPACK_IMPORTED_MODULE_12__["PrackUserAgentClient"](this, delegate, options);
+ }
+ /**
+ * REFER is a SIP request and is constructed as defined in [1]. A REFER
+ * request MUST contain exactly one Refer-To header field value.
+ * https://tools.ietf.org/html/rfc3515#section-2.4.1
+ * @param options - Options bucket.
+ */
+ refer(delegate, options) {
+ this.logger.log(`INVITE dialog ${this.id} sending REFER request`);
+ if (this.early) {
+ // FIXME: TODO: This should throw a proper exception.
+ throw new Error("Dialog not confirmed.");
+ }
+ // FIXME: TODO: Validate Refer-To header field value.
+ return new _user_agents_refer_user_agent_client__WEBPACK_IMPORTED_MODULE_16__["ReferUserAgentClient"](this, delegate, options);
+ }
+ /**
+ * Requests sent within a dialog, as any other requests, are atomic. If
+ * a particular request is accepted by the UAS, all the state changes
+ * associated with it are performed. If the request is rejected, none
+ * of the state changes are performed.
+ * https://tools.ietf.org/html/rfc3261#section-12.2.2
+ * @param message - Incoming request message within this dialog.
+ */
+ receiveRequest(message) {
+ this.logger.log(`INVITE dialog ${this.id} received ${message.method} request`);
+ // Response retransmissions cease when an ACK request for the
+ // response is received. This is independent of whatever transport
+ // protocols are used to send the response.
+ // https://tools.ietf.org/html/rfc6026#section-8.1
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ // If ackWait is true, then this is the ACK to the initial INVITE,
+ // otherwise this is an ACK to an in dialog INVITE. In either case,
+ // guard to make sure the sequence number of the ACK matches the INVITE.
+ if (this.ackWait) {
+ if (this.initialTransaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteClientTransaction"]) {
+ this.logger.warn(`INVITE dialog ${this.id} received unexpected ${message.method} request, dropping.`);
+ return;
+ }
+ if (this.initialTransaction.request.cseq !== message.cseq) {
+ this.logger.warn(`INVITE dialog ${this.id} received unexpected ${message.method} request, dropping.`);
+ return;
+ }
+ // Update before the delegate has a chance to handle the
+ // message as delegate may callback into this dialog.
+ this.ackWait = false;
+ }
+ else {
+ if (!this.reinviteUserAgentServer) {
+ this.logger.warn(`INVITE dialog ${this.id} received unexpected ${message.method} request, dropping.`);
+ return;
+ }
+ if (this.reinviteUserAgentServer.transaction.request.cseq !== message.cseq) {
+ this.logger.warn(`INVITE dialog ${this.id} received unexpected ${message.method} request, dropping.`);
+ return;
+ }
+ this.reinviteUserAgentServer = undefined;
+ }
+ this.signalingStateTransition(message);
+ if (this.delegate && this.delegate.onAck) {
+ const promiseOrVoid = this.delegate.onAck({ message });
+ if (promiseOrVoid instanceof Promise) {
+ this.ackProcessing = true; // make sure this is always reset to false
+ promiseOrVoid.then(() => (this.ackProcessing = false)).catch(() => (this.ackProcessing = false));
+ }
+ }
+ return;
+ }
+ // Request within a dialog out of sequence guard.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ if (!this.sequenceGuard(message)) {
+ this.logger.log(`INVITE dialog ${this.id} rejected out of order ${message.method} request.`);
+ return;
+ }
+ // Request within a dialog common processing.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ super.receiveRequest(message);
+ // Handle various INVITE related cross-over, glare and race conditions
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE) {
+ // Hopefully this message is helpful...
+ const warning = () => {
+ const reason = this.ackWait ? "waiting for initial ACK" : "processing initial ACK";
+ this.logger.warn(`INVITE dialog ${this.id} received re-INVITE while ${reason}`);
+ let msg = "RFC 5407 suggests the following to avoid this race condition... ";
+ msg += " Note: Implementation issues are outside the scope of this document,";
+ msg += " but the following tip is provided for avoiding race conditions of";
+ msg += " this type. The caller can delay sending re-INVITE F6 for some period";
+ msg += " of time (2 seconds, perhaps), after which the caller can reasonably";
+ msg += " assume that its ACK has been received. Implementors can decouple the";
+ msg += " actions of the user (e.g., pressing the hold button) from the actions";
+ msg += " of the protocol (the sending of re-INVITE F6), so that the UA can";
+ msg += " behave like this. In this case, it is the implementor's choice as to";
+ msg += " how long to wait. In most cases, such an implementation may be";
+ msg += " useful to prevent the type of race condition shown in this section.";
+ msg += " This document expresses no preference about whether or not they";
+ msg += " should wait for an ACK to be delivered. After considering the impact";
+ msg += " on user experience, implementors should decide whether or not to wait";
+ msg += " for a while, because the user experience depends on the";
+ msg += " implementation and has no direct bearing on protocol behavior.";
+ this.logger.warn(msg);
+ return; // drop re-INVITE request message
+ };
+ // A UAS that receives a second INVITE before it sends the final
+ // response to a first INVITE with a lower CSeq sequence number on the
+ // same dialog MUST return a 500 (Server Internal Error) response to the
+ // second INVITE and MUST include a Retry-After header field with a
+ // randomly chosen value of between 0 and 10 seconds.
+ // https://tools.ietf.org/html/rfc3261#section-14.2
+ const retryAfter = Math.floor(Math.random() * 10) + 1;
+ const extraHeaders = [`Retry-After: ${retryAfter}`];
+ // There may be ONLY ONE offer/answer negotiation in progress for a
+ // single dialog at any point in time. Section 4 explains how to ensure
+ // this.
+ // https://tools.ietf.org/html/rfc6337#section-2.2
+ if (this.ackProcessing) {
+ // UAS-IsI: While an INVITE server transaction is incomplete or ACK
+ // transaction associated with an offer/answer is incomplete,
+ // a UA must reject another INVITE request with a 500
+ // response.
+ // https://tools.ietf.org/html/rfc6337#section-4.3
+ this.core.replyStateless(message, { statusCode: 500, extraHeaders });
+ warning();
+ return;
+ }
+ // 3.1.4. Callee Receives re-INVITE (Established State) While in the
+ // Moratorium State (Case 1)
+ // https://tools.ietf.org/html/rfc5407#section-3.1.4
+ // 3.1.5. Callee Receives re-INVITE (Established State) While in the
+ // Moratorium State (Case 2)
+ // https://tools.ietf.org/html/rfc5407#section-3.1.5
+ if (this.ackWait && this.signalingState !== _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable) {
+ // This scenario is basically the same as that of Section 3.1.4, but
+ // differs in sending an offer in the 200 and an answer in the ACK. In
+ // contrast to the previous case, the offer in the 200 (F3) and the
+ // offer in the re-INVITE (F6) collide with each other.
+ //
+ // Bob sends a 491 to the re-INVITE (F6) since he is not able to
+ // properly handle a new request until he receives an answer. (Note:
+ // 500 with a Retry-After header may be returned if the 491 response is
+ // understood to indicate request collision. However, 491 is
+ // recommended here because 500 applies to so many cases that it is
+ // difficult to determine what the real problem was.)
+ // https://tools.ietf.org/html/rfc5407#section-3.1.5
+ // UAS-IsI: While an INVITE server transaction is incomplete or ACK
+ // transaction associated with an offer/answer is incomplete,
+ // a UA must reject another INVITE request with a 500
+ // response.
+ // https://tools.ietf.org/html/rfc6337#section-4.3
+ this.core.replyStateless(message, { statusCode: 500, extraHeaders });
+ warning();
+ return;
+ }
+ // A UAS that receives a second INVITE before it sends the final
+ // response to a first INVITE with a lower CSeq sequence number on the
+ // same dialog MUST return a 500 (Server Internal Error) response to the
+ // second INVITE and MUST include a Retry-After header field with a
+ // randomly chosen value of between 0 and 10 seconds.
+ // https://tools.ietf.org/html/rfc3261#section-14.2
+ if (this.reinviteUserAgentServer) {
+ this.core.replyStateless(message, { statusCode: 500, extraHeaders });
+ return;
+ }
+ // A UAS that receives an INVITE on a dialog while an INVITE it had sent
+ // on that dialog is in progress MUST return a 491 (Request Pending)
+ // response to the received INVITE.
+ // https://tools.ietf.org/html/rfc3261#section-14.2
+ if (this.reinviteUserAgentClient) {
+ this.core.replyStateless(message, { statusCode: 491 });
+ return;
+ }
+ }
+ // Requests within a dialog MAY contain Record-Route and Contact header
+ // fields. However, these requests do not cause the dialog's route set
+ // to be modified, although they may modify the remote target URI.
+ // Specifically, requests that are not target refresh requests do not
+ // modify the dialog's remote target URI, and requests that are target
+ // refresh requests do. For dialogs that have been established with an
+ // INVITE, the only target refresh request defined is re-INVITE (see
+ // Section 14). Other extensions may define different target refresh
+ // requests for dialogs established in other ways.
+ //
+ // Note that an ACK is NOT a target refresh request.
+ //
+ // Target refresh requests only update the dialog's remote target URI,
+ // and not the route set formed from the Record-Route. Updating the
+ // latter would introduce severe backwards compatibility problems with
+ // RFC 2543-compliant systems.
+ // https://tools.ietf.org/html/rfc3261#section-15
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE) {
+ // FIXME: parser needs to be typed...
+ const contact = message.parseHeader("contact");
+ if (!contact) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("Contact undefined.");
+ }
+ if (!(contact instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"])) {
+ throw new Error("Contact not instance of NameAddrHeader.");
+ }
+ this.dialogState.remoteTarget = contact.uri;
+ }
+ // Switch on method and then delegate.
+ switch (message.method) {
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].BYE:
+ // A UAS core receiving a BYE request for an existing dialog MUST follow
+ // the procedures of Section 12.2.2 to process the request. Once done,
+ // the UAS SHOULD terminate the session (and therefore stop sending and
+ // listening for media). The only case where it can elect not to are
+ // multicast sessions, where participation is possible even if the other
+ // participant in the dialog has terminated its involvement in the
+ // session. Whether or not it ends its participation on the session,
+ // the UAS core MUST generate a 2xx response to the BYE, and MUST pass
+ // that to the server transaction for transmission.
+ //
+ // The UAS MUST still respond to any pending requests received for that
+ // dialog. It is RECOMMENDED that a 487 (Request Terminated) response
+ // be generated to those pending requests.
+ // https://tools.ietf.org/html/rfc3261#section-15.1.2
+ {
+ const uas = new _user_agents_bye_user_agent_server__WEBPACK_IMPORTED_MODULE_5__["ByeUserAgentServer"](this, message);
+ this.delegate && this.delegate.onBye ? this.delegate.onBye(uas) : uas.accept();
+ this.dispose();
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INFO:
+ // If a UA receives an INFO request associated with an Info Package that
+ // the UA has not indicated willingness to receive, the UA MUST send a
+ // 469 (Bad Info Package) response (see Section 11.6), which contains a
+ // Recv-Info header field with Info Packages for which the UA is willing
+ // to receive INFO requests.
+ {
+ const uas = new _user_agents_info_user_agent_server__WEBPACK_IMPORTED_MODULE_7__["InfoUserAgentServer"](this, message);
+ this.delegate && this.delegate.onInfo
+ ? this.delegate.onInfo(uas)
+ : uas.reject({
+ statusCode: 469,
+ extraHeaders: ["Recv-Info :"]
+ });
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE:
+ // If the new session description is not acceptable, the UAS can reject
+ // it by returning a 488 (Not Acceptable Here) response for the re-
+ // INVITE. This response SHOULD include a Warning header field.
+ // https://tools.ietf.org/html/rfc3261#section-14.2
+ {
+ const uas = new _user_agents_re_invite_user_agent_server__WEBPACK_IMPORTED_MODULE_15__["ReInviteUserAgentServer"](this, message);
+ this.signalingStateTransition(message);
+ this.delegate && this.delegate.onInvite ? this.delegate.onInvite(uas) : uas.reject({ statusCode: 488 }); // TODO: Warning header field.
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].MESSAGE:
+ {
+ const uas = new _user_agents_message_user_agent_server__WEBPACK_IMPORTED_MODULE_9__["MessageUserAgentServer"](this.core, message);
+ this.delegate && this.delegate.onMessage ? this.delegate.onMessage(uas) : uas.accept();
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].NOTIFY:
+ // https://tools.ietf.org/html/rfc3515#section-2.4.4
+ {
+ const uas = new _user_agents_notify_user_agent_server__WEBPACK_IMPORTED_MODULE_11__["NotifyUserAgentServer"](this, message);
+ this.delegate && this.delegate.onNotify ? this.delegate.onNotify(uas) : uas.accept();
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].PRACK:
+ // https://tools.ietf.org/html/rfc3262#section-4
+ {
+ const uas = new _user_agents_prack_user_agent_server__WEBPACK_IMPORTED_MODULE_13__["PrackUserAgentServer"](this, message);
+ this.delegate && this.delegate.onPrack ? this.delegate.onPrack(uas) : uas.accept();
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].REFER:
+ // https://tools.ietf.org/html/rfc3515#section-2.4.2
+ {
+ const uas = new _user_agents_refer_user_agent_server__WEBPACK_IMPORTED_MODULE_17__["ReferUserAgentServer"](this, message);
+ this.delegate && this.delegate.onRefer ? this.delegate.onRefer(uas) : uas.reject();
+ }
+ break;
+ default:
+ {
+ this.logger.log(`INVITE dialog ${this.id} received unimplemented ${message.method} request`);
+ this.core.replyStateless(message, { statusCode: 501 });
+ }
+ break;
+ }
+ }
+ /**
+ * Guard against out of order reliable provisional responses and retransmissions.
+ * Returns false if the response should be discarded, otherwise true.
+ * @param message - Incoming response message within this dialog.
+ */
+ reliableSequenceGuard(message) {
+ const statusCode = message.statusCode;
+ if (!statusCode) {
+ throw new Error("Status code undefined");
+ }
+ if (statusCode > 100 && statusCode < 200) {
+ // If a provisional response is received for an initial request, and
+ // that response contains a Require header field containing the option
+ // tag 100rel, the response is to be sent reliably. If the response is
+ // a 100 (Trying) (as opposed to 101 to 199), this option tag MUST be
+ // ignored, and the procedures below MUST NOT be used.
+ // https://tools.ietf.org/html/rfc3262#section-4
+ const requireHeader = message.getHeader("require");
+ const rseqHeader = message.getHeader("rseq");
+ const rseq = requireHeader && requireHeader.includes("100rel") && rseqHeader ? Number(rseqHeader) : undefined;
+ if (rseq) {
+ // Handling of subsequent reliable provisional responses for the same
+ // initial request follows the same rules as above, with the following
+ // difference: reliable provisional responses are guaranteed to be in
+ // order. As a result, if the UAC receives another reliable provisional
+ // response to the same request, and its RSeq value is not one higher
+ // than the value of the sequence number, that response MUST NOT be
+ // acknowledged with a PRACK, and MUST NOT be processed further by the
+ // UAC. An implementation MAY discard the response, or MAY cache the
+ // response in the hopes of receiving the missing responses.
+ // https://tools.ietf.org/html/rfc3262#section-4
+ if (this.rseq && this.rseq + 1 !== rseq) {
+ return false;
+ }
+ // Once a reliable provisional response is received, retransmissions of
+ // that response MUST be discarded. A response is a retransmission when
+ // its dialog ID, CSeq, and RSeq match the original response. The UAC
+ // MUST maintain a sequence number that indicates the most recently
+ // received in-order reliable provisional response for the initial
+ // request. This sequence number MUST be maintained until a final
+ // response is received for the initial request. Its value MUST be
+ // initialized to the RSeq header field in the first reliable
+ // provisional response received for the initial request.
+ // https://tools.ietf.org/html/rfc3262#section-4
+ this.rseq = this.rseq ? this.rseq + 1 : rseq;
+ }
+ }
+ return true;
+ }
+ /**
+ * If not in a stable signaling state, rollback to prior stable signaling state.
+ */
+ signalingStateRollback() {
+ if (this._signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer ||
+ this.signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer) {
+ if (this._rollbackOffer && this._rollbackAnswer) {
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable;
+ this._offer = this._rollbackOffer;
+ this._answer = this._rollbackAnswer;
+ }
+ }
+ }
+ /**
+ * Update the signaling state of the dialog.
+ * @param message - The message to base the update off of.
+ */
+ signalingStateTransition(message) {
+ const body = Object(_messages__WEBPACK_IMPORTED_MODULE_0__["getBody"])(message);
+ // No body, no session. No, woman, no cry.
+ if (!body || body.contentDisposition !== "session") {
+ return;
+ }
+ // We've got an existing offer and answer which we may wish to rollback to
+ if (this._signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable) {
+ this._rollbackOffer = this._offer;
+ this._rollbackAnswer = this._answer;
+ }
+ // We're in UAS role, receiving incoming request with session description
+ if (message instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["IncomingRequestMessage"]) {
+ switch (this._signalingState) {
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial:
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer;
+ this._offer = body;
+ this._answer = undefined;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable;
+ this._answer = body;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer:
+ // You cannot make a new offer while one is in progress.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ // FIXME: What to do here?
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Closed:
+ break;
+ default:
+ throw new Error("Unexpected signaling state.");
+ }
+ }
+ // We're in UAC role, receiving incoming response with session description
+ if (message instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["IncomingResponseMessage"]) {
+ switch (this._signalingState) {
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial:
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer;
+ this._offer = body;
+ this._answer = undefined;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable;
+ this._answer = body;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer:
+ // You cannot make a new offer while one is in progress.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ // FIXME: What to do here?
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Closed:
+ break;
+ default:
+ throw new Error("Unexpected signaling state.");
+ }
+ }
+ // We're in UAC role, sending outgoing request with session description
+ if (message instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["OutgoingRequestMessage"]) {
+ switch (this._signalingState) {
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial:
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer;
+ this._offer = body;
+ this._answer = undefined;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer:
+ // You cannot make a new offer while one is in progress.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ // FIXME: What to do here?
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable;
+ this._answer = body;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Closed:
+ break;
+ default:
+ throw new Error("Unexpected signaling state.");
+ }
+ }
+ // We're in UAS role, sending outgoing response with session description
+ if (Object(_messages__WEBPACK_IMPORTED_MODULE_0__["isBody"])(message)) {
+ switch (this._signalingState) {
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial:
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer;
+ this._offer = body;
+ this._answer = undefined;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer:
+ // You cannot make a new offer while one is in progress.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ // FIXME: What to do here?
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveRemoteOffer:
+ this._signalingState = _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Stable;
+ this._answer = body;
+ break;
+ case _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Closed:
+ break;
+ default:
+ throw new Error("Unexpected signaling state.");
+ }
+ }
+ }
+ start2xxRetransmissionTimer() {
+ if (this.initialTransaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ const transaction = this.initialTransaction;
+ // Once the response has been constructed, it is passed to the INVITE
+ // server transaction. In order to ensure reliable end-to-end
+ // transport of the response, it is necessary to periodically pass
+ // the response directly to the transport until the ACK arrives. The
+ // 2xx response is passed to the transport with an interval that
+ // starts at T1 seconds and doubles for each retransmission until it
+ // reaches T2 seconds (T1 and T2 are defined in Section 17).
+ // Response retransmissions cease when an ACK request for the
+ // response is received. This is independent of whatever transport
+ // protocols are used to send the response.
+ // https://tools.ietf.org/html/rfc6026#section-8.1
+ let timeout = _timers__WEBPACK_IMPORTED_MODULE_2__["Timers"].T1;
+ const retransmission = () => {
+ if (!this.ackWait) {
+ this.invite2xxTimer = undefined;
+ return;
+ }
+ this.logger.log("No ACK for 2xx response received, attempting retransmission");
+ transaction.retransmitAcceptedResponse();
+ timeout = Math.min(timeout * 2, _timers__WEBPACK_IMPORTED_MODULE_2__["Timers"].T2);
+ this.invite2xxTimer = setTimeout(retransmission, timeout);
+ };
+ this.invite2xxTimer = setTimeout(retransmission, timeout);
+ // If the server retransmits the 2xx response for 64*T1 seconds without
+ // receiving an ACK, the dialog is confirmed, but the session SHOULD be
+ // terminated. This is accomplished with a BYE, as described in Section 15.
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ const stateChanged = () => {
+ if (transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated) {
+ transaction.removeStateChangeListener(stateChanged);
+ if (this.invite2xxTimer) {
+ clearTimeout(this.invite2xxTimer);
+ this.invite2xxTimer = undefined;
+ }
+ if (this.ackWait) {
+ if (this.delegate && this.delegate.onAckTimeout) {
+ this.delegate.onAckTimeout();
+ }
+ else {
+ this.bye();
+ }
+ }
+ }
+ };
+ transaction.addStateChangeListener(stateChanged);
+ }
+ }
+ // FIXME: Refactor
+ startReInvite2xxRetransmissionTimer() {
+ if (this.reinviteUserAgentServer && this.reinviteUserAgentServer.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ const transaction = this.reinviteUserAgentServer.transaction;
+ // Once the response has been constructed, it is passed to the INVITE
+ // server transaction. In order to ensure reliable end-to-end
+ // transport of the response, it is necessary to periodically pass
+ // the response directly to the transport until the ACK arrives. The
+ // 2xx response is passed to the transport with an interval that
+ // starts at T1 seconds and doubles for each retransmission until it
+ // reaches T2 seconds (T1 and T2 are defined in Section 17).
+ // Response retransmissions cease when an ACK request for the
+ // response is received. This is independent of whatever transport
+ // protocols are used to send the response.
+ // https://tools.ietf.org/html/rfc6026#section-8.1
+ let timeout = _timers__WEBPACK_IMPORTED_MODULE_2__["Timers"].T1;
+ const retransmission = () => {
+ if (!this.reinviteUserAgentServer) {
+ this.invite2xxTimer = undefined;
+ return;
+ }
+ this.logger.log("No ACK for 2xx response received, attempting retransmission");
+ transaction.retransmitAcceptedResponse();
+ timeout = Math.min(timeout * 2, _timers__WEBPACK_IMPORTED_MODULE_2__["Timers"].T2);
+ this.invite2xxTimer = setTimeout(retransmission, timeout);
+ };
+ this.invite2xxTimer = setTimeout(retransmission, timeout);
+ // If the server retransmits the 2xx response for 64*T1 seconds without
+ // receiving an ACK, the dialog is confirmed, but the session SHOULD be
+ // terminated. This is accomplished with a BYE, as described in Section 15.
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ const stateChanged = () => {
+ if (transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated) {
+ transaction.removeStateChangeListener(stateChanged);
+ if (this.invite2xxTimer) {
+ clearTimeout(this.invite2xxTimer);
+ this.invite2xxTimer = undefined;
+ }
+ if (this.reinviteUserAgentServer) {
+ // FIXME: TODO: What to do here
+ }
+ }
+ };
+ transaction.addStateChangeListener(stateChanged);
+ }
+ }
+}
+
+
+/***/ }),
+/* 44 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(45);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionState", function() { return _session__WEBPACK_IMPORTED_MODULE_0__["SessionState"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SignalingState", function() { return _session__WEBPACK_IMPORTED_MODULE_0__["SignalingState"]; });
+
+/* harmony import */ var _session_delegate__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(46);
+/* harmony import */ var _session_delegate__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_session_delegate__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_delegate__WEBPACK_IMPORTED_MODULE_1__) if(["SessionState","SignalingState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_delegate__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+
+
+
+
+/***/ }),
+/* 45 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SessionState", function() { return SessionState; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SignalingState", function() { return SignalingState; });
+/**
+ * Session state.
+ * @remarks
+ * https://tools.ietf.org/html/rfc3261#section-13
+ * @public
+ */
+var SessionState;
+(function (SessionState) {
+ SessionState["Initial"] = "Initial";
+ SessionState["Early"] = "Early";
+ SessionState["AckWait"] = "AckWait";
+ SessionState["Confirmed"] = "Confirmed";
+ SessionState["Terminated"] = "Terminated";
+})(SessionState || (SessionState = {}));
+/**
+ * Offer/Answer state.
+ * @remarks
+ * ```txt
+ * Offer Answer RFC Ini Est Early
+ * -------------------------------------------------------------------
+ * 1. INVITE Req. 2xx INVITE Resp. RFC 3261 Y Y N
+ * 2. 2xx INVITE Resp. ACK Req. RFC 3261 Y Y N
+ * 3. INVITE Req. 1xx-rel INVITE Resp. RFC 3262 Y Y N
+ * 4. 1xx-rel INVITE Resp. PRACK Req. RFC 3262 Y Y N
+ * 5. PRACK Req. 200 PRACK Resp. RFC 3262 N Y Y
+ * 6. UPDATE Req. 2xx UPDATE Resp. RFC 3311 N Y Y
+ *
+ * Table 1: Summary of SIP Usage of the Offer/Answer Model
+ * ```
+ * https://tools.ietf.org/html/rfc6337#section-2.2
+ * @public
+ */
+var SignalingState;
+(function (SignalingState) {
+ SignalingState["Initial"] = "Initial";
+ SignalingState["HaveLocalOffer"] = "HaveLocalOffer";
+ SignalingState["HaveRemoteOffer"] = "HaveRemoteOffer";
+ SignalingState["Stable"] = "Stable";
+ SignalingState["Closed"] = "Closed";
+})(SignalingState || (SignalingState = {}));
+
+
+/***/ }),
+/* 46 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 47 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Timers", function() { return Timers; });
+const T1 = 500;
+const T2 = 4000;
+const T4 = 5000;
+/**
+ * Timers.
+ * @public
+ */
+const Timers = {
+ T1,
+ T2,
+ T4,
+ TIMER_B: 64 * T1,
+ TIMER_D: 0 * T1,
+ TIMER_F: 64 * T1,
+ TIMER_H: 64 * T1,
+ TIMER_I: 0 * T4,
+ TIMER_J: 0 * T1,
+ TIMER_K: 0 * T4,
+ TIMER_L: 64 * T1,
+ TIMER_M: 64 * T1,
+ TIMER_N: 64 * T1,
+ PROVISIONAL_RESPONSE_INTERVAL: 60000 // See RFC 3261 Section 13.3.1.1
+};
+
+
+/***/ }),
+/* 48 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _client_transaction__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(49);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ClientTransaction", function() { return _client_transaction__WEBPACK_IMPORTED_MODULE_0__["ClientTransaction"]; });
+
+/* harmony import */ var _invite_client_transaction__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(55);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InviteClientTransaction", function() { return _invite_client_transaction__WEBPACK_IMPORTED_MODULE_1__["InviteClientTransaction"]; });
+
+/* harmony import */ var _invite_server_transaction__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(57);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InviteServerTransaction", function() { return _invite_server_transaction__WEBPACK_IMPORTED_MODULE_2__["InviteServerTransaction"]; });
+
+/* harmony import */ var _non_invite_client_transaction__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(59);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NonInviteClientTransaction", function() { return _non_invite_client_transaction__WEBPACK_IMPORTED_MODULE_3__["NonInviteClientTransaction"]; });
+
+/* harmony import */ var _non_invite_server_transaction__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(60);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NonInviteServerTransaction", function() { return _non_invite_server_transaction__WEBPACK_IMPORTED_MODULE_4__["NonInviteServerTransaction"]; });
+
+/* empty/unused harmony star reexport *//* harmony import */ var _server_transaction__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(58);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ServerTransaction", function() { return _server_transaction__WEBPACK_IMPORTED_MODULE_5__["ServerTransaction"]; });
+
+/* harmony import */ var _transaction_state__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(56);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TransactionState", function() { return _transaction_state__WEBPACK_IMPORTED_MODULE_6__["TransactionState"]; });
+
+/* harmony import */ var _transaction_user__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(61);
+/* harmony import */ var _transaction_user__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(_transaction_user__WEBPACK_IMPORTED_MODULE_7__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _transaction_user__WEBPACK_IMPORTED_MODULE_7__) if(["ClientTransaction","InviteClientTransaction","InviteServerTransaction","NonInviteClientTransaction","NonInviteServerTransaction","ServerTransaction","TransactionState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _transaction_user__WEBPACK_IMPORTED_MODULE_7__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _transaction__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(50);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Transaction", function() { return _transaction__WEBPACK_IMPORTED_MODULE_8__["Transaction"]; });
+
+
+
+
+
+
+
+
+
+
+
+
+
+/***/ }),
+/* 49 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ClientTransaction", function() { return ClientTransaction; });
+/* harmony import */ var _transaction__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(50);
+
+/**
+ * Client Transaction.
+ * @remarks
+ * The client transaction provides its functionality through the
+ * maintenance of a state machine.
+ *
+ * The TU communicates with the client transaction through a simple
+ * interface. When the TU wishes to initiate a new transaction, it
+ * creates a client transaction and passes it the SIP request to send
+ * and an IP address, port, and transport to which to send it. The
+ * client transaction begins execution of its state machine. Valid
+ * responses are passed up to the TU from the client transaction.
+ * https://tools.ietf.org/html/rfc3261#section-17.1
+ * @public
+ */
+class ClientTransaction extends _transaction__WEBPACK_IMPORTED_MODULE_0__["Transaction"] {
+ constructor(_request, transport, user, state, loggerCategory) {
+ super(transport, user, ClientTransaction.makeId(_request), state, loggerCategory);
+ this._request = _request;
+ this.user = user;
+ // The Via header field indicates the transport used for the transaction
+ // and identifies the location where the response is to be sent. A Via
+ // header field value is added only after the transport that will be
+ // used to reach the next hop has been selected (which may involve the
+ // usage of the procedures in [4]).
+ // https://tools.ietf.org/html/rfc3261#section-8.1.1.7
+ _request.setViaHeader(this.id, transport.protocol);
+ }
+ static makeId(request) {
+ if (request.method === "CANCEL") {
+ if (!request.branch) {
+ throw new Error("Outgoing CANCEL request without a branch.");
+ }
+ return request.branch;
+ }
+ else {
+ return "z9hG4bK" + Math.floor(Math.random() * 10000000);
+ }
+ }
+ /** The outgoing request the transaction handling. */
+ get request() {
+ return this._request;
+ }
+ /**
+ * A 408 to non-INVITE will always arrive too late to be useful ([3]),
+ * The client already has full knowledge of the timeout. The only
+ * information this message would convey is whether or not the server
+ * believed the transaction timed out. However, with the current design
+ * of the NIT, a client cannot do anything with this knowledge. Thus,
+ * the 408 is simply wasting network resources and contributes to the
+ * response bombardment illustrated in [3].
+ * https://tools.ietf.org/html/rfc4320#section-4.1
+ */
+ onRequestTimeout() {
+ if (this.user.onRequestTimeout) {
+ this.user.onRequestTimeout();
+ }
+ }
+}
+
+
+/***/ }),
+/* 50 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Transaction", function() { return Transaction; });
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(51);
+
+/**
+ * Transaction.
+ * @remarks
+ * SIP is a transactional protocol: interactions between components take
+ * place in a series of independent message exchanges. Specifically, a
+ * SIP transaction consists of a single request and any responses to
+ * that request, which include zero or more provisional responses and
+ * one or more final responses. In the case of a transaction where the
+ * request was an INVITE (known as an INVITE transaction), the
+ * transaction also includes the ACK only if the final response was not
+ * a 2xx response. If the response was a 2xx, the ACK is not considered
+ * part of the transaction.
+ * https://tools.ietf.org/html/rfc3261#section-17
+ * @public
+ */
+class Transaction {
+ constructor(_transport, _user, _id, _state, loggerCategory) {
+ this._transport = _transport;
+ this._user = _user;
+ this._id = _id;
+ this._state = _state;
+ this.listeners = new Array();
+ this.logger = _user.loggerFactory.getLogger(loggerCategory, _id);
+ this.logger.debug(`Constructing ${this.typeToString()} with id ${this.id}.`);
+ }
+ /**
+ * Destructor.
+ * Once the transaction is in the "terminated" state, it is destroyed
+ * immediately and there is no need to call `dispose`. However, if a
+ * transaction needs to be ended prematurely, the transaction user may
+ * do so by calling this method (for example, perhaps the UA is shutting down).
+ * No state transition will occur upon calling this method, all outstanding
+ * transmission timers will be cancelled, and use of the transaction after
+ * calling `dispose` is undefined.
+ */
+ dispose() {
+ this.logger.debug(`Destroyed ${this.typeToString()} with id ${this.id}.`);
+ }
+ /** Transaction id. */
+ get id() {
+ return this._id;
+ }
+ /** Transaction kind. Deprecated. */
+ get kind() {
+ throw new Error("Invalid kind.");
+ }
+ /** Transaction state. */
+ get state() {
+ return this._state;
+ }
+ /** Transaction transport. */
+ get transport() {
+ return this._transport;
+ }
+ /**
+ * Sets up a function that will be called whenever the transaction state changes.
+ * @param listener - Callback function.
+ * @param options - An options object that specifies characteristics about the listener.
+ * If once true, indicates that the listener should be invoked at most once after being added.
+ * If once true, the listener would be automatically removed when invoked.
+ */
+ addStateChangeListener(listener, options) {
+ const onceWrapper = () => {
+ this.removeStateChangeListener(onceWrapper);
+ listener();
+ };
+ (options === null || options === void 0 ? void 0 : options.once) === true ? this.listeners.push(onceWrapper) : this.listeners.push(listener);
+ }
+ /**
+ * This is currently public so tests may spy on it.
+ * @internal
+ */
+ notifyStateChangeListeners() {
+ this.listeners.slice().forEach((listener) => listener());
+ }
+ /**
+ * Removes a listener previously registered with addStateListener.
+ * @param listener - Callback function.
+ */
+ removeStateChangeListener(listener) {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+ logTransportError(error, message) {
+ this.logger.error(error.message);
+ this.logger.error(`Transport error occurred in ${this.typeToString()} with id ${this.id}.`);
+ this.logger.error(message);
+ }
+ /**
+ * Pass message to transport for transmission. If transport fails,
+ * the transaction user is notified by callback to onTransportError().
+ * @returns
+ * Rejects with `TransportError` if transport fails.
+ */
+ send(message) {
+ return this.transport.send(message).catch((error) => {
+ // If the transport rejects, it SHOULD reject with a TransportError.
+ // But the transport may be external code, so we are careful
+ // make sure we convert it to a TransportError if need be.
+ if (error instanceof _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransportError"]) {
+ this.onTransportError(error);
+ throw error;
+ }
+ let transportError;
+ if (error && typeof error.message === "string") {
+ transportError = new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransportError"](error.message);
+ }
+ else {
+ transportError = new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransportError"]();
+ }
+ this.onTransportError(transportError);
+ throw transportError;
+ });
+ }
+ setState(state) {
+ this.logger.debug(`State change to "${state}" on ${this.typeToString()} with id ${this.id}.`);
+ this._state = state;
+ if (this._user.onStateChange) {
+ this._user.onStateChange(state);
+ }
+ this.notifyStateChangeListeners();
+ }
+ typeToString() {
+ return "UnknownType";
+ }
+}
+
+
+/***/ }),
+/* 51 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _exception__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(52);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Exception", function() { return _exception__WEBPACK_IMPORTED_MODULE_0__["Exception"]; });
+
+/* harmony import */ var _transaction_state_error__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(53);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TransactionStateError", function() { return _transaction_state_error__WEBPACK_IMPORTED_MODULE_1__["TransactionStateError"]; });
+
+/* harmony import */ var _transport_error__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(54);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "TransportError", function() { return _transport_error__WEBPACK_IMPORTED_MODULE_2__["TransportError"]; });
+
+
+
+
+
+
+/***/ }),
+/* 52 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Exception", function() { return Exception; });
+/**
+ * An Exception is considered a condition that a reasonable application may wish to catch.
+ * An Error indicates serious problems that a reasonable application should not try to catch.
+ * @public
+ */
+class Exception extends Error {
+ constructor(message) {
+ super(message); // 'Error' breaks prototype chain here
+ Object.setPrototypeOf(this, new.target.prototype); // restore prototype chain
+ }
+}
+
+
+/***/ }),
+/* 53 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TransactionStateError", function() { return TransactionStateError; });
+/* harmony import */ var _exception__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(52);
+
+/**
+ * Indicates that the operation could not be completed given the current transaction state.
+ * @public
+ */
+class TransactionStateError extends _exception__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ constructor(message) {
+ super(message ? message : "Transaction state error.");
+ }
+}
+
+
+/***/ }),
+/* 54 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TransportError", function() { return TransportError; });
+/* harmony import */ var _exception__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(52);
+
+/**
+ * Transport error.
+ * @public
+ */
+class TransportError extends _exception__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ constructor(message) {
+ super(message ? message : "Unspecified transport error.");
+ }
+}
+
+
+/***/ }),
+/* 55 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "InviteClientTransaction", function() { return InviteClientTransaction; });
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(47);
+/* harmony import */ var _client_transaction__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(49);
+/* harmony import */ var _transaction_state__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(56);
+
+
+
+/**
+ * INVITE Client Transaction.
+ * @remarks
+ * The INVITE transaction consists of a three-way handshake. The client
+ * transaction sends an INVITE, the server transaction sends responses,
+ * and the client transaction sends an ACK.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.1
+ * @public
+ */
+class InviteClientTransaction extends _client_transaction__WEBPACK_IMPORTED_MODULE_1__["ClientTransaction"] {
+ /**
+ * Constructor.
+ * Upon construction, the outgoing request's Via header is updated by calling `setViaHeader`.
+ * Then `toString` is called on the outgoing request and the message is sent via the transport.
+ * After construction the transaction will be in the "calling" state and the transaction id
+ * will equal the branch parameter set in the Via header of the outgoing request.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.1
+ * @param request - The outgoing INVITE request.
+ * @param transport - The transport.
+ * @param user - The transaction user.
+ */
+ constructor(request, transport, user) {
+ super(request, transport, user, _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling, "sip.transaction.ict");
+ /**
+ * Map of 2xx to-tag to ACK.
+ * If value is not undefined, value is the ACK which was sent.
+ * If key exists but value is undefined, a 2xx was received but the ACK not yet sent.
+ * Otherwise, a 2xx was not (yet) received for this transaction.
+ */
+ this.ackRetransmissionCache = new Map();
+ // FIXME: Timer A for unreliable transport not implemented
+ //
+ // If an unreliable transport is being used, the client transaction
+ // MUST start timer A with a value of T1. If a reliable transport is being used,
+ // the client transaction SHOULD NOT start timer A (Timer A controls request retransmissions).
+ // For any transport, the client transaction MUST start timer B with a value
+ // of 64*T1 seconds (Timer B controls transaction timeouts).
+ // https://tools.ietf.org/html/rfc3261#section-17.1.1.2
+ //
+ // While not spelled out in the RFC, Timer B is the maximum amount of time that a sender
+ // will wait for an INVITE message to be acknowledged (a SIP response message is received).
+ // So Timer B should be cleared when the transaction state proceeds from "Calling".
+ this.B = setTimeout(() => this.timerB(), _timers__WEBPACK_IMPORTED_MODULE_0__["Timers"].TIMER_B);
+ this.send(request.toString()).catch((error) => {
+ this.logTransportError(error, "Failed to send initial outgoing request.");
+ });
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ if (this.B) {
+ clearTimeout(this.B);
+ this.B = undefined;
+ }
+ if (this.D) {
+ clearTimeout(this.D);
+ this.D = undefined;
+ }
+ if (this.M) {
+ clearTimeout(this.M);
+ this.M = undefined;
+ }
+ super.dispose();
+ }
+ /** Transaction kind. Deprecated. */
+ get kind() {
+ return "ict";
+ }
+ /**
+ * ACK a 2xx final response.
+ *
+ * The transaction includes the ACK only if the final response was not a 2xx response (the
+ * transaction will generate and send the ACK to the transport automagically). If the
+ * final response was a 2xx, the ACK is not considered part of the transaction (the
+ * transaction user needs to generate and send the ACK).
+ *
+ * This library is not strictly RFC compliant with regard to ACK handling for 2xx final
+ * responses. Specifically, retransmissions of ACKs to a 2xx final responses is handled
+ * by the transaction layer (instead of the UAC core). The "standard" approach is for
+ * the UAC core to receive all 2xx responses and manage sending ACK retransmissions to
+ * the transport directly. Herein the transaction layer manages sending ACKs to 2xx responses
+ * and any retransmissions of those ACKs as needed.
+ *
+ * @param ack - The outgoing ACK request.
+ */
+ ackResponse(ack) {
+ const toTag = ack.toTag;
+ if (!toTag) {
+ throw new Error("To tag undefined.");
+ }
+ const id = "z9hG4bK" + Math.floor(Math.random() * 10000000);
+ ack.setViaHeader(id, this.transport.protocol);
+ this.ackRetransmissionCache.set(toTag, ack); // Add to ACK retransmission cache
+ this.send(ack.toString()).catch((error) => {
+ this.logTransportError(error, "Failed to send ACK to 2xx response.");
+ });
+ }
+ /**
+ * Handler for incoming responses from the transport which match this transaction.
+ * @param response - The incoming response.
+ */
+ receiveResponse(response) {
+ const statusCode = response.statusCode;
+ if (!statusCode || statusCode < 100 || statusCode > 699) {
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ switch (this.state) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling:
+ // If the client transaction receives a provisional response while in
+ // the "Calling" state, it transitions to the "Proceeding" state. In the
+ // "Proceeding" state, the client transaction SHOULD NOT retransmit the
+ // request any longer. Furthermore, the provisional response MUST be
+ // passed to the TU. Any further provisional responses MUST be passed
+ // up to the TU while in the "Proceeding" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.1.2
+ if (statusCode >= 100 && statusCode <= 199) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding);
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ // When a 2xx response is received while in either the "Calling" or
+ // "Proceeding" states, the client transaction MUST transition to
+ // the "Accepted" state... The 2xx response MUST be passed up to the TU.
+ // The client transaction MUST NOT generate an ACK to the 2xx response -- its
+ // handling is delegated to the TU. A UAC core will send an ACK to
+ // the 2xx response using a new transaction.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (statusCode >= 200 && statusCode <= 299) {
+ this.ackRetransmissionCache.set(response.toTag, undefined); // Prime the ACK cache
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted);
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ // When in either the "Calling" or "Proceeding" states, reception of
+ // a response with status code from 300-699 MUST cause the client
+ // transaction to transition to "Completed". The client transaction
+ // MUST pass the received response up to the TU, and the client
+ // transaction MUST generate an ACK request, even if the transport is
+ // reliable (guidelines for constructing the ACK from the response
+ // are given in Section 17.1.1.3), and then pass the ACK to the
+ // transport layer for transmission. The ACK MUST be sent to the
+ // same address, port, and transport to which the original request was sent.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (statusCode >= 300 && statusCode <= 699) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed);
+ this.ack(response);
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ // In the "Proceeding" state, the client transaction SHOULD NOT retransmit the
+ // request any longer. Furthermore, the provisional response MUST be
+ // passed to the TU. Any further provisional responses MUST be passed
+ // up to the TU while in the "Proceeding" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.1.2
+ if (statusCode >= 100 && statusCode <= 199) {
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ // When a 2xx response is received while in either the "Calling" or "Proceeding" states,
+ // the client transaction MUST transition to the "Accepted" state...
+ // The 2xx response MUST be passed up to the TU. The client
+ // transaction MUST NOT generate an ACK to the 2xx response -- its
+ // handling is delegated to the TU. A UAC core will send an ACK to
+ // the 2xx response using a new transaction.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (statusCode >= 200 && statusCode <= 299) {
+ this.ackRetransmissionCache.set(response.toTag, undefined); // Prime the ACK cache
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted);
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ // When in either the "Calling" or "Proceeding" states, reception of
+ // a response with status code from 300-699 MUST cause the client
+ // transaction to transition to "Completed". The client transaction
+ // MUST pass the received response up to the TU, and the client
+ // transaction MUST generate an ACK request, even if the transport is
+ // reliable (guidelines for constructing the ACK from the response
+ // are given in Section 17.1.1.3), and then pass the ACK to the
+ // transport layer for transmission. The ACK MUST be sent to the
+ // same address, port, and transport to which the original request was sent.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (statusCode >= 300 && statusCode <= 699) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed);
+ this.ack(response);
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted:
+ // The purpose of the "Accepted" state is to allow the client
+ // transaction to continue to exist to receive, and pass to the TU,
+ // any retransmissions of the 2xx response and any additional 2xx
+ // responses from other branches of the INVITE if it forked
+ // downstream. Timer M reflects the amount of time that the
+ // transaction user will wait for such messages.
+ //
+ // Any 2xx responses that match this client transaction and that are
+ // received while in the "Accepted" state MUST be passed up to the
+ // TU. The client transaction MUST NOT generate an ACK to the 2xx
+ // response. The client transaction takes no further action.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (statusCode >= 200 && statusCode <= 299) {
+ // NOTE: This implementation herein is intentionally not RFC compliant.
+ // While the first 2xx response for a given branch is passed up to the TU,
+ // retransmissions of 2xx responses are absorbed and the ACK associated
+ // with the original response is resent. This approach is taken because
+ // our current transaction users are not currently in a good position to
+ // deal with 2xx retransmission. This SHOULD NOT cause any compliance issues - ;)
+ //
+ // If we don't have a cache hit, pass the response to the TU.
+ if (!this.ackRetransmissionCache.has(response.toTag)) {
+ this.ackRetransmissionCache.set(response.toTag, undefined); // Prime the ACK cache
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ // If we have a cache hit, try pulling the ACK from cache and retransmitting it.
+ const ack = this.ackRetransmissionCache.get(response.toTag);
+ if (ack) {
+ this.send(ack.toString()).catch((error) => {
+ this.logTransportError(error, "Failed to send retransmission of ACK to 2xx response.");
+ });
+ return;
+ }
+ // If an ACK was not found in cache then we have received a retransmitted 2xx
+ // response before the TU responded to the original response (we don't have an ACK yet).
+ // So discard this response under the assumption that the TU will eventually
+ // get us a ACK for the original response.
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ // Any retransmissions of a response with status code 300-699 that
+ // are received while in the "Completed" state MUST cause the ACK to
+ // be re-passed to the transport layer for retransmission, but the
+ // newly received response MUST NOT be passed up to the TU.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (statusCode >= 300 && statusCode <= 699) {
+ this.ack(response);
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ break;
+ default:
+ throw new Error(`Invalid state ${this.state}`);
+ }
+ // Any response received that does not match an existing client
+ // transaction state machine is simply dropped. (Implementations are,
+ // of course, free to log or do other implementation-specific things
+ // with such responses, but the implementer should be sure to consider
+ // the impact of large numbers of malicious stray responses.)
+ // https://tools.ietf.org/html/rfc6026#section-7.2
+ const message = `Received unexpected ${statusCode} response while in state ${this.state}.`;
+ this.logger.warn(message);
+ return;
+ }
+ /**
+ * The client transaction SHOULD inform the TU that a transport failure
+ * has occurred, and the client transaction SHOULD transition directly
+ * to the "Terminated" state. The TU will handle the failover
+ * mechanisms described in [4].
+ * https://tools.ietf.org/html/rfc3261#section-17.1.4
+ * @param error - The error.
+ */
+ onTransportError(error) {
+ if (this.user.onTransportError) {
+ this.user.onTransportError(error);
+ }
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated, true);
+ }
+ /** For logging. */
+ typeToString() {
+ return "INVITE client transaction";
+ }
+ ack(response) {
+ // The ACK request constructed by the client transaction MUST contain
+ // values for the Call-ID, From, and Request-URI that are equal to the
+ // values of those header fields in the request passed to the transport
+ // by the client transaction (call this the "original request"). The To
+ // header field in the ACK MUST equal the To header field in the
+ // response being acknowledged, and therefore will usually differ from
+ // the To header field in the original request by the addition of the
+ // tag parameter. The ACK MUST contain a single Via header field, and
+ // this MUST be equal to the top Via header field of the original
+ // request. The CSeq header field in the ACK MUST contain the same
+ // value for the sequence number as was present in the original request,
+ // but the method parameter MUST be equal to "ACK".
+ //
+ // If the INVITE request whose response is being acknowledged had Route
+ // header fields, those header fields MUST appear in the ACK. This is
+ // to ensure that the ACK can be routed properly through any downstream
+ // stateless proxies.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.1.3
+ const ruri = this.request.ruri;
+ const callId = this.request.callId;
+ const cseq = this.request.cseq;
+ const from = this.request.getHeader("from");
+ const to = response.getHeader("to");
+ const via = this.request.getHeader("via");
+ const route = this.request.getHeader("route");
+ if (!from) {
+ throw new Error("From undefined.");
+ }
+ if (!to) {
+ throw new Error("To undefined.");
+ }
+ if (!via) {
+ throw new Error("Via undefined.");
+ }
+ let ack = `ACK ${ruri} SIP/2.0\r\n`;
+ if (route) {
+ ack += `Route: ${route}\r\n`;
+ }
+ ack += `Via: ${via}\r\n`;
+ ack += `To: ${to}\r\n`;
+ ack += `From: ${from}\r\n`;
+ ack += `Call-ID: ${callId}\r\n`;
+ ack += `CSeq: ${cseq} ACK\r\n`;
+ ack += `Max-Forwards: 70\r\n`;
+ ack += `Content-Length: 0\r\n\r\n`;
+ // TOOO: "User-Agent" header
+ this.send(ack).catch((error) => {
+ this.logTransportError(error, "Failed to send ACK to non-2xx response.");
+ });
+ return;
+ }
+ /**
+ * Execute a state transition.
+ * @param newState - New state.
+ */
+ stateTransition(newState, dueToTransportError = false) {
+ // Assert valid state transitions.
+ const invalidStateTransition = () => {
+ throw new Error(`Invalid state transition from ${this.state} to ${newState}`);
+ };
+ switch (newState) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling:
+ invalidStateTransition();
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted:
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling && this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling &&
+ this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted &&
+ this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ if (!dueToTransportError) {
+ invalidStateTransition();
+ }
+ }
+ break;
+ default:
+ invalidStateTransition();
+ }
+ // While not spelled out in the RFC, Timer B is the maximum amount of time that a sender
+ // will wait for an INVITE message to be acknowledged (a SIP response message is received).
+ // So Timer B should be cleared when the transaction state proceeds from "Calling".
+ if (this.B) {
+ clearTimeout(this.B);
+ this.B = undefined;
+ }
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding) {
+ // Timers have no effect on "Proceeding" state.
+ // In the "Proceeding" state, the client transaction
+ // SHOULD NOT retransmit the request any longer.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.1.2
+ }
+ // The client transaction MUST start Timer D when it enters the "Completed" state
+ // for any reason, with a value of at least 32 seconds for unreliable transports,
+ // and a value of zero seconds for reliable transports.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ this.D = setTimeout(() => this.timerD(), _timers__WEBPACK_IMPORTED_MODULE_0__["Timers"].TIMER_D);
+ }
+ // The client transaction MUST transition to the "Accepted" state,
+ // and Timer M MUST be started with a value of 64*T1.
+ // https://tools.ietf.org/html/rfc6026#section-8.4
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted) {
+ this.M = setTimeout(() => this.timerM(), _timers__WEBPACK_IMPORTED_MODULE_0__["Timers"].TIMER_M);
+ }
+ // Once the transaction is in the "Terminated" state, it MUST be destroyed immediately.
+ // https://tools.ietf.org/html/rfc6026#section-8.7
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated) {
+ this.dispose();
+ }
+ // Update state.
+ this.setState(newState);
+ }
+ /**
+ * When timer A fires, the client transaction MUST retransmit the
+ * request by passing it to the transport layer, and MUST reset the
+ * timer with a value of 2*T1.
+ * When timer A fires 2*T1 seconds later, the request MUST be
+ * retransmitted again (assuming the client transaction is still in this
+ * state). This process MUST continue so that the request is
+ * retransmitted with intervals that double after each transmission.
+ * These retransmissions SHOULD only be done while the client
+ * transaction is in the "Calling" state.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.1.2
+ */
+ timerA() {
+ // TODO
+ }
+ /**
+ * If the client transaction is still in the "Calling" state when timer
+ * B fires, the client transaction SHOULD inform the TU that a timeout
+ * has occurred. The client transaction MUST NOT generate an ACK.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.1.2
+ */
+ timerB() {
+ this.logger.debug(`Timer B expired for INVITE client transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling) {
+ this.onRequestTimeout();
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated);
+ }
+ }
+ /**
+ * If Timer D fires while the client transaction is in the "Completed" state,
+ * the client transaction MUST move to the "Terminated" state.
+ * https://tools.ietf.org/html/rfc6026#section-8.4
+ */
+ timerD() {
+ this.logger.debug(`Timer D expired for INVITE client transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated);
+ }
+ }
+ /**
+ * If Timer M fires while the client transaction is in the "Accepted"
+ * state, the client transaction MUST move to the "Terminated" state.
+ * https://tools.ietf.org/html/rfc6026#section-8.4
+ */
+ timerM() {
+ this.logger.debug(`Timer M expired for INVITE client transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Accepted) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated);
+ }
+ }
+}
+
+
+/***/ }),
+/* 56 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TransactionState", function() { return TransactionState; });
+/**
+ * Transaction state.
+ * @public
+ */
+var TransactionState;
+(function (TransactionState) {
+ TransactionState["Accepted"] = "Accepted";
+ TransactionState["Calling"] = "Calling";
+ TransactionState["Completed"] = "Completed";
+ TransactionState["Confirmed"] = "Confirmed";
+ TransactionState["Proceeding"] = "Proceeding";
+ TransactionState["Terminated"] = "Terminated";
+ TransactionState["Trying"] = "Trying";
+})(TransactionState || (TransactionState = {}));
+
+
+/***/ }),
+/* 57 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "InviteServerTransaction", function() { return InviteServerTransaction; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(47);
+/* harmony import */ var _server_transaction__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(58);
+/* harmony import */ var _transaction_state__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(56);
+
+
+
+
+/**
+ * INVITE Server Transaction.
+ * @remarks
+ * https://tools.ietf.org/html/rfc3261#section-17.2.1
+ * @public
+ */
+class InviteServerTransaction extends _server_transaction__WEBPACK_IMPORTED_MODULE_2__["ServerTransaction"] {
+ /**
+ * Constructor.
+ * Upon construction, a "100 Trying" reply will be immediately sent.
+ * After construction the transaction will be in the "proceeding" state and the transaction
+ * `id` will equal the branch parameter set in the Via header of the incoming request.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.1
+ * @param request - Incoming INVITE request from the transport.
+ * @param transport - The transport.
+ * @param user - The transaction user.
+ */
+ constructor(request, transport, user) {
+ super(request, transport, user, _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding, "sip.transaction.ist");
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ this.stopProgressExtensionTimer();
+ if (this.H) {
+ clearTimeout(this.H);
+ this.H = undefined;
+ }
+ if (this.I) {
+ clearTimeout(this.I);
+ this.I = undefined;
+ }
+ if (this.L) {
+ clearTimeout(this.L);
+ this.L = undefined;
+ }
+ super.dispose();
+ }
+ /** Transaction kind. Deprecated. */
+ get kind() {
+ return "ist";
+ }
+ /**
+ * Receive requests from transport matching this transaction.
+ * @param request - Request matching this transaction.
+ */
+ receiveRequest(request) {
+ switch (this.state) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding:
+ // If a request retransmission is received while in the "Proceeding" state, the most
+ // recent provisional response that was received from the TU MUST be passed to the
+ // transport layer for retransmission.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE) {
+ if (this.lastProvisionalResponse) {
+ this.send(this.lastProvisionalResponse).catch((error) => {
+ this.logTransportError(error, "Failed to send retransmission of provisional response.");
+ });
+ }
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted:
+ // While in the "Accepted" state, any retransmissions of the INVITE
+ // received will match this transaction state machine and will be
+ // absorbed by the machine without changing its state. These
+ // retransmissions are not passed onto the TU.
+ // https://tools.ietf.org/html/rfc6026#section-7.1
+ if (request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE) {
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed:
+ // Furthermore, while in the "Completed" state, if a request retransmission is
+ // received, the server SHOULD pass the response to the transport for retransmission.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE) {
+ if (!this.lastFinalResponse) {
+ throw new Error("Last final response undefined.");
+ }
+ this.send(this.lastFinalResponse).catch((error) => {
+ this.logTransportError(error, "Failed to send retransmission of final response.");
+ });
+ return;
+ }
+ // If an ACK is received while the server transaction is in the "Completed" state,
+ // the server transaction MUST transition to the "Confirmed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Confirmed);
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Confirmed:
+ // The purpose of the "Confirmed" state is to absorb any additional ACK messages that arrive,
+ // triggered from retransmissions of the final response.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE || request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated:
+ // For good measure absorb any additional messages that arrive (should not happen).
+ if (request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE || request.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ return;
+ }
+ break;
+ default:
+ throw new Error(`Invalid state ${this.state}`);
+ }
+ const message = `INVITE server transaction received unexpected ${request.method} request while in state ${this.state}.`;
+ this.logger.warn(message);
+ return;
+ }
+ /**
+ * Receive responses from TU for this transaction.
+ * @param statusCode - Status code of response.
+ * @param response - Response.
+ */
+ receiveResponse(statusCode, response) {
+ if (statusCode < 100 || statusCode > 699) {
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ switch (this.state) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding:
+ // The TU passes any number of provisional responses to the server
+ // transaction. So long as the server transaction is in the
+ // "Proceeding" state, each of these MUST be passed to the transport
+ // layer for transmission. They are not sent reliably by the
+ // transaction layer (they are not retransmitted by it) and do not cause
+ // a change in the state of the server transaction.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (statusCode >= 100 && statusCode <= 199) {
+ this.lastProvisionalResponse = response;
+ // Start the progress extension timer only for a non-100 provisional response.
+ if (statusCode > 100) {
+ this.startProgressExtensionTimer(); // FIXME: remove
+ }
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send 1xx response.");
+ });
+ return;
+ }
+ // If, while in the "Proceeding" state, the TU passes a 2xx response
+ // to the server transaction, the server transaction MUST pass this
+ // response to the transport layer for transmission. It is not
+ // retransmitted by the server transaction; retransmissions of 2xx
+ // responses are handled by the TU. The server transaction MUST then
+ // transition to the "Accepted" state.
+ // https://tools.ietf.org/html/rfc6026#section-8.5
+ if (statusCode >= 200 && statusCode <= 299) {
+ this.lastFinalResponse = response;
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted);
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send 2xx response.");
+ });
+ return;
+ }
+ // While in the "Proceeding" state, if the TU passes a response with
+ // status code from 300 to 699 to the server transaction, the response
+ // MUST be passed to the transport layer for transmission, and the state
+ // machine MUST enter the "Completed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (statusCode >= 300 && statusCode <= 699) {
+ this.lastFinalResponse = response;
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed);
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send non-2xx final response.");
+ });
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted:
+ // While in the "Accepted" state, if the TU passes a 2xx response,
+ // the server transaction MUST pass the response to the transport layer for transmission.
+ // https://tools.ietf.org/html/rfc6026#section-8.7
+ if (statusCode >= 200 && statusCode <= 299) {
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send 2xx response.");
+ });
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed:
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Confirmed:
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated:
+ break;
+ default:
+ throw new Error(`Invalid state ${this.state}`);
+ }
+ const message = `INVITE server transaction received unexpected ${statusCode} response from TU while in state ${this.state}.`;
+ this.logger.error(message);
+ throw new Error(message);
+ }
+ /**
+ * Retransmit the last 2xx response. This is a noop if not in the "accepted" state.
+ */
+ retransmitAcceptedResponse() {
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted && this.lastFinalResponse) {
+ this.send(this.lastFinalResponse).catch((error) => {
+ this.logTransportError(error, "Failed to send 2xx response.");
+ });
+ }
+ }
+ /**
+ * First, the procedures in [4] are followed, which attempt to deliver the response to a backup.
+ * If those should all fail, based on the definition of failure in [4], the server transaction SHOULD
+ * inform the TU that a failure has occurred, and MUST remain in the current state.
+ * https://tools.ietf.org/html/rfc6026#section-8.8
+ */
+ onTransportError(error) {
+ if (this.user.onTransportError) {
+ this.user.onTransportError(error);
+ }
+ }
+ /** For logging. */
+ typeToString() {
+ return "INVITE server transaction";
+ }
+ /**
+ * Execute a state transition.
+ * @param newState - New state.
+ */
+ stateTransition(newState) {
+ // Assert valid state transitions.
+ const invalidStateTransition = () => {
+ throw new Error(`Invalid state transition from ${this.state} to ${newState}`);
+ };
+ switch (newState) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding:
+ invalidStateTransition();
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted:
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Confirmed:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted &&
+ this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed &&
+ this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Confirmed) {
+ invalidStateTransition();
+ }
+ break;
+ default:
+ invalidStateTransition();
+ }
+ // On any state transition, stop resending provisional responses
+ this.stopProgressExtensionTimer();
+ // The purpose of the "Accepted" state is to absorb retransmissions of an accepted INVITE request.
+ // Any such retransmissions are absorbed entirely within the server transaction.
+ // They are not passed up to the TU since any downstream UAS cores that accepted the request have
+ // taken responsibility for reliability and will already retransmit their 2xx responses if necessary.
+ // https://tools.ietf.org/html/rfc6026#section-8.7
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted) {
+ this.L = setTimeout(() => this.timerL(), _timers__WEBPACK_IMPORTED_MODULE_1__["Timers"].TIMER_L);
+ }
+ // When the "Completed" state is entered, timer H MUST be set to fire in 64*T1 seconds for all transports.
+ // Timer H determines when the server transaction abandons retransmitting the response.
+ // If an ACK is received while the server transaction is in the "Completed" state,
+ // the server transaction MUST transition to the "Confirmed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed) {
+ // FIXME: Missing timer G for unreliable transports.
+ this.H = setTimeout(() => this.timerH(), _timers__WEBPACK_IMPORTED_MODULE_1__["Timers"].TIMER_H);
+ }
+ // The purpose of the "Confirmed" state is to absorb any additional ACK messages that arrive,
+ // triggered from retransmissions of the final response. When this state is entered, timer I
+ // is set to fire in T4 seconds for unreliable transports, and zero seconds for reliable
+ // transports. Once timer I fires, the server MUST transition to the "Terminated" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Confirmed) {
+ // FIXME: This timer is not getting set correctly for unreliable transports.
+ this.I = setTimeout(() => this.timerI(), _timers__WEBPACK_IMPORTED_MODULE_1__["Timers"].TIMER_I);
+ }
+ // Once the transaction is in the "Terminated" state, it MUST be destroyed immediately.
+ // https://tools.ietf.org/html/rfc6026#section-8.7
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated) {
+ this.dispose();
+ }
+ // Update state.
+ this.setState(newState);
+ }
+ /**
+ * FIXME: UAS Provisional Retransmission Timer. See RFC 3261 Section 13.3.1.1
+ * This is in the wrong place. This is not a transaction level thing. It's a UAS level thing.
+ */
+ startProgressExtensionTimer() {
+ // Start the progress extension timer only for the first non-100 provisional response.
+ if (this.progressExtensionTimer === undefined) {
+ this.progressExtensionTimer = setInterval(() => {
+ this.logger.debug(`Progress extension timer expired for INVITE server transaction ${this.id}.`);
+ if (!this.lastProvisionalResponse) {
+ throw new Error("Last provisional response undefined.");
+ }
+ this.send(this.lastProvisionalResponse).catch((error) => {
+ this.logTransportError(error, "Failed to send retransmission of provisional response.");
+ });
+ }, _timers__WEBPACK_IMPORTED_MODULE_1__["Timers"].PROVISIONAL_RESPONSE_INTERVAL);
+ }
+ }
+ /**
+ * FIXME: UAS Provisional Retransmission Timer id. See RFC 3261 Section 13.3.1.1
+ * This is in the wrong place. This is not a transaction level thing. It's a UAS level thing.
+ */
+ stopProgressExtensionTimer() {
+ if (this.progressExtensionTimer !== undefined) {
+ clearInterval(this.progressExtensionTimer);
+ this.progressExtensionTimer = undefined;
+ }
+ }
+ /**
+ * While in the "Proceeding" state, if the TU passes a response with status code
+ * from 300 to 699 to the server transaction, the response MUST be passed to the
+ * transport layer for transmission, and the state machine MUST enter the "Completed" state.
+ * For unreliable transports, timer G is set to fire in T1 seconds, and is not set to fire for
+ * reliable transports. If timer G fires, the response is passed to the transport layer once
+ * more for retransmission, and timer G is set to fire in MIN(2*T1, T2) seconds. From then on,
+ * when timer G fires, the response is passed to the transport again for transmission, and
+ * timer G is reset with a value that doubles, unless that value exceeds T2, in which case
+ * it is reset with the value of T2.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.1
+ */
+ timerG() {
+ // TODO
+ }
+ /**
+ * If timer H fires while in the "Completed" state, it implies that the ACK was never received.
+ * In this case, the server transaction MUST transition to the "Terminated" state, and MUST
+ * indicate to the TU that a transaction failure has occurred.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.1
+ */
+ timerH() {
+ this.logger.debug(`Timer H expired for INVITE server transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Completed) {
+ this.logger.warn("ACK to negative final response was never received, terminating transaction.");
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated);
+ }
+ }
+ /**
+ * Once timer I fires, the server MUST transition to the "Terminated" state.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.1
+ */
+ timerI() {
+ this.logger.debug(`Timer I expired for INVITE server transaction ${this.id}.`);
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated);
+ }
+ /**
+ * When Timer L fires and the state machine is in the "Accepted" state, the machine MUST
+ * transition to the "Terminated" state. Once the transaction is in the "Terminated" state,
+ * it MUST be destroyed immediately. Timer L reflects the amount of time the server
+ * transaction could receive 2xx responses for retransmission from the
+ * TU while it is waiting to receive an ACK.
+ * https://tools.ietf.org/html/rfc6026#section-7.1
+ * https://tools.ietf.org/html/rfc6026#section-8.7
+ */
+ timerL() {
+ this.logger.debug(`Timer L expired for INVITE server transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated);
+ }
+ }
+}
+
+
+/***/ }),
+/* 58 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ServerTransaction", function() { return ServerTransaction; });
+/* harmony import */ var _transaction__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(50);
+
+/**
+ * Server Transaction.
+ * @remarks
+ * The server transaction is responsible for the delivery of requests to
+ * the TU and the reliable transmission of responses. It accomplishes
+ * this through a state machine. Server transactions are created by the
+ * core when a request is received, and transaction handling is desired
+ * for that request (this is not always the case).
+ * https://tools.ietf.org/html/rfc3261#section-17.2
+ * @public
+ */
+class ServerTransaction extends _transaction__WEBPACK_IMPORTED_MODULE_0__["Transaction"] {
+ constructor(_request, transport, user, state, loggerCategory) {
+ super(transport, user, _request.viaBranch, state, loggerCategory);
+ this._request = _request;
+ this.user = user;
+ }
+ /** The incoming request the transaction handling. */
+ get request() {
+ return this._request;
+ }
+}
+
+
+/***/ }),
+/* 59 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "NonInviteClientTransaction", function() { return NonInviteClientTransaction; });
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(47);
+/* harmony import */ var _client_transaction__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(49);
+/* harmony import */ var _transaction_state__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(56);
+
+
+
+/**
+ * Non-INVITE Client Transaction.
+ * @remarks
+ * Non-INVITE transactions do not make use of ACK.
+ * They are simple request-response interactions.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.2
+ * @public
+ */
+class NonInviteClientTransaction extends _client_transaction__WEBPACK_IMPORTED_MODULE_1__["ClientTransaction"] {
+ /**
+ * Constructor
+ * Upon construction, the outgoing request's Via header is updated by calling `setViaHeader`.
+ * Then `toString` is called on the outgoing request and the message is sent via the transport.
+ * After construction the transaction will be in the "calling" state and the transaction id
+ * will equal the branch parameter set in the Via header of the outgoing request.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.2
+ * @param request - The outgoing Non-INVITE request.
+ * @param transport - The transport.
+ * @param user - The transaction user.
+ */
+ constructor(request, transport, user) {
+ super(request, transport, user, _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying, "sip.transaction.nict");
+ // FIXME: Timer E for unreliable transports not implemented.
+ //
+ // The "Trying" state is entered when the TU initiates a new client
+ // transaction with a request. When entering this state, the client
+ // transaction SHOULD set timer F to fire in 64*T1 seconds. The request
+ // MUST be passed to the transport layer for transmission.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ this.F = setTimeout(() => this.timerF(), _timers__WEBPACK_IMPORTED_MODULE_0__["Timers"].TIMER_F);
+ this.send(request.toString()).catch((error) => {
+ this.logTransportError(error, "Failed to send initial outgoing request.");
+ });
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ if (this.F) {
+ clearTimeout(this.F);
+ this.F = undefined;
+ }
+ if (this.K) {
+ clearTimeout(this.K);
+ this.K = undefined;
+ }
+ super.dispose();
+ }
+ /** Transaction kind. Deprecated. */
+ get kind() {
+ return "nict";
+ }
+ /**
+ * Handler for incoming responses from the transport which match this transaction.
+ * @param response - The incoming response.
+ */
+ receiveResponse(response) {
+ const statusCode = response.statusCode;
+ if (!statusCode || statusCode < 100 || statusCode > 699) {
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ switch (this.state) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying:
+ // If a provisional response is received while in the "Trying" state, the
+ // response MUST be passed to the TU, and then the client transaction
+ // SHOULD move to the "Proceeding" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ if (statusCode >= 100 && statusCode <= 199) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding);
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ // If a final response (status codes 200-699) is received while in the
+ // "Trying" state, the response MUST be passed to the TU, and the
+ // client transaction MUST transition to the "Completed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ if (statusCode >= 200 && statusCode <= 699) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed);
+ if (statusCode === 408) {
+ this.onRequestTimeout();
+ return;
+ }
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ // If a provisional response is received while in the "Proceeding" state,
+ // the response MUST be passed to the TU. (From Figure 6)
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ if (statusCode >= 100 && statusCode <= 199) {
+ if (this.user.receiveResponse) {
+ return this.user.receiveResponse(response);
+ }
+ }
+ // If a final response (status codes 200-699) is received while in the
+ // "Proceeding" state, the response MUST be passed to the TU, and the
+ // client transaction MUST transition to the "Completed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ if (statusCode >= 200 && statusCode <= 699) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed);
+ if (statusCode === 408) {
+ this.onRequestTimeout();
+ return;
+ }
+ if (this.user.receiveResponse) {
+ this.user.receiveResponse(response);
+ }
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ // The "Completed" state exists to buffer any additional response
+ // retransmissions that may be received (which is why the client
+ // transaction remains there only for unreliable transports).
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ return;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ // For good measure just absorb additional response retransmissions.
+ return;
+ default:
+ throw new Error(`Invalid state ${this.state}`);
+ }
+ const message = `Non-INVITE client transaction received unexpected ${statusCode} response while in state ${this.state}.`;
+ this.logger.warn(message);
+ return;
+ }
+ /**
+ * The client transaction SHOULD inform the TU that a transport failure has occurred,
+ * and the client transaction SHOULD transition directly to the "Terminated" state.
+ * The TU will handle the fail over mechanisms described in [4].
+ * https://tools.ietf.org/html/rfc3261#section-17.1.4
+ * @param error - Transport error
+ */
+ onTransportError(error) {
+ if (this.user.onTransportError) {
+ this.user.onTransportError(error);
+ }
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated, true);
+ }
+ /** For logging. */
+ typeToString() {
+ return "non-INVITE client transaction";
+ }
+ /**
+ * Execute a state transition.
+ * @param newState - New state.
+ */
+ stateTransition(newState, dueToTransportError = false) {
+ // Assert valid state transitions.
+ const invalidStateTransition = () => {
+ throw new Error(`Invalid state transition from ${this.state} to ${newState}`);
+ };
+ switch (newState) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying:
+ invalidStateTransition();
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying && this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying &&
+ this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding &&
+ this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ if (!dueToTransportError) {
+ invalidStateTransition();
+ }
+ }
+ break;
+ default:
+ invalidStateTransition();
+ }
+ // Once the client transaction enters the "Completed" state, it MUST set
+ // Timer K to fire in T4 seconds for unreliable transports, and zero
+ // seconds for reliable transports The "Completed" state exists to
+ // buffer any additional response retransmissions that may be received
+ // (which is why the client transaction remains there only for unreliable transports).
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ if (this.F) {
+ clearTimeout(this.F);
+ this.F = undefined;
+ }
+ this.K = setTimeout(() => this.timerK(), _timers__WEBPACK_IMPORTED_MODULE_0__["Timers"].TIMER_K);
+ }
+ // Once the transaction is in the terminated state, it MUST be destroyed immediately.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated) {
+ this.dispose();
+ }
+ // Update state.
+ this.setState(newState);
+ }
+ /**
+ * If Timer F fires while the client transaction is still in the
+ * "Trying" state, the client transaction SHOULD inform the TU about the
+ * timeout, and then it SHOULD enter the "Terminated" state.
+ * If timer F fires while in the "Proceeding" state, the TU MUST be informed of
+ * a timeout, and the client transaction MUST transition to the terminated state.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ */
+ timerF() {
+ this.logger.debug(`Timer F expired for non-INVITE client transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying || this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding) {
+ this.onRequestTimeout();
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated);
+ }
+ }
+ /**
+ * If Timer K fires while in this (COMPLETED) state, the client transaction
+ * MUST transition to the "Terminated" state.
+ * https://tools.ietf.org/html/rfc3261#section-17.1.2.2
+ */
+ timerK() {
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated);
+ }
+ }
+}
+
+
+/***/ }),
+/* 60 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "NonInviteServerTransaction", function() { return NonInviteServerTransaction; });
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(47);
+/* harmony import */ var _server_transaction__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(58);
+/* harmony import */ var _transaction_state__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(56);
+
+
+
+/**
+ * Non-INVITE Server Transaction.
+ * @remarks
+ * https://tools.ietf.org/html/rfc3261#section-17.2.2
+ * @public
+ */
+class NonInviteServerTransaction extends _server_transaction__WEBPACK_IMPORTED_MODULE_1__["ServerTransaction"] {
+ /**
+ * Constructor.
+ * After construction the transaction will be in the "trying": state and the transaction
+ * `id` will equal the branch parameter set in the Via header of the incoming request.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.2
+ * @param request - Incoming Non-INVITE request from the transport.
+ * @param transport - The transport.
+ * @param user - The transaction user.
+ */
+ constructor(request, transport, user) {
+ super(request, transport, user, _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying, "sip.transaction.nist");
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ if (this.J) {
+ clearTimeout(this.J);
+ this.J = undefined;
+ }
+ super.dispose();
+ }
+ /** Transaction kind. Deprecated. */
+ get kind() {
+ return "nist";
+ }
+ /**
+ * Receive requests from transport matching this transaction.
+ * @param request - Request matching this transaction.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ receiveRequest(request) {
+ switch (this.state) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying:
+ // Once in the "Trying" state, any further request retransmissions are discarded.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ // If a retransmission of the request is received while in the "Proceeding" state,
+ // the most recently sent provisional response MUST be passed to the transport layer for retransmission.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ if (!this.lastResponse) {
+ throw new Error("Last response undefined.");
+ }
+ this.send(this.lastResponse).catch((error) => {
+ this.logTransportError(error, "Failed to send retransmission of provisional response.");
+ });
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ // While in the "Completed" state, the server transaction MUST pass the final response to the transport
+ // layer for retransmission whenever a retransmission of the request is received. Any other final responses
+ // passed by the TU to the server transaction MUST be discarded while in the "Completed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ if (!this.lastResponse) {
+ throw new Error("Last response undefined.");
+ }
+ this.send(this.lastResponse).catch((error) => {
+ this.logTransportError(error, "Failed to send retransmission of final response.");
+ });
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ break;
+ default:
+ throw new Error(`Invalid state ${this.state}`);
+ }
+ }
+ /**
+ * Receive responses from TU for this transaction.
+ * @param statusCode - Status code of response. 101-199 not allowed per RFC 4320.
+ * @param response - Response to send.
+ */
+ receiveResponse(statusCode, response) {
+ if (statusCode < 100 || statusCode > 699) {
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ // An SIP element MUST NOT send any provisional response with a
+ // Status-Code other than 100 to a non-INVITE request.
+ // An SIP element MUST NOT respond to a non-INVITE request with a
+ // Status-Code of 100 over any unreliable transport, such as UDP,
+ // before the amount of time it takes a client transaction's Timer E to be reset to T2.
+ // An SIP element MAY respond to a non-INVITE request with a
+ // Status-Code of 100 over a reliable transport at any time.
+ // https://tools.ietf.org/html/rfc4320#section-4.1
+ if (statusCode > 100 && statusCode <= 199) {
+ throw new Error("Provisional response other than 100 not allowed.");
+ }
+ switch (this.state) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying:
+ // While in the "Trying" state, if the TU passes a provisional response
+ // to the server transaction, the server transaction MUST enter the "Proceeding" state.
+ // The response MUST be passed to the transport layer for transmission.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ this.lastResponse = response;
+ if (statusCode >= 100 && statusCode < 200) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding);
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send provisional response.");
+ });
+ return;
+ }
+ if (statusCode >= 200 && statusCode <= 699) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed);
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send final response.");
+ });
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ // Any further provisional responses that are received from the TU while
+ // in the "Proceeding" state MUST be passed to the transport layer for transmission.
+ // If the TU passes a final response (status codes 200-699) to the server while in
+ // the "Proceeding" state, the transaction MUST enter the "Completed" state, and
+ // the response MUST be passed to the transport layer for transmission.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ this.lastResponse = response;
+ if (statusCode >= 200 && statusCode <= 699) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed);
+ this.send(response).catch((error) => {
+ this.logTransportError(error, "Failed to send final response.");
+ });
+ return;
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ // Any other final responses passed by the TU to the server
+ // transaction MUST be discarded while in the "Completed" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ return;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ break;
+ default:
+ throw new Error(`Invalid state ${this.state}`);
+ }
+ const message = `Non-INVITE server transaction received unexpected ${statusCode} response from TU while in state ${this.state}.`;
+ this.logger.error(message);
+ throw new Error(message);
+ }
+ /**
+ * First, the procedures in [4] are followed, which attempt to deliver the response to a backup.
+ * If those should all fail, based on the definition of failure in [4], the server transaction SHOULD
+ * inform the TU that a failure has occurred, and SHOULD transition to the terminated state.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.4
+ */
+ onTransportError(error) {
+ if (this.user.onTransportError) {
+ this.user.onTransportError(error);
+ }
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated, true);
+ }
+ /** For logging. */
+ typeToString() {
+ return "non-INVITE server transaction";
+ }
+ stateTransition(newState, dueToTransportError = false) {
+ // Assert valid state transitions.
+ const invalidStateTransition = () => {
+ throw new Error(`Invalid state transition from ${this.state} to ${newState}`);
+ };
+ switch (newState) {
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying:
+ invalidStateTransition();
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Trying && this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding) {
+ invalidStateTransition();
+ }
+ break;
+ case _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated:
+ if (this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Proceeding && this.state !== _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ if (!dueToTransportError) {
+ invalidStateTransition();
+ }
+ }
+ break;
+ default:
+ invalidStateTransition();
+ }
+ // When the server transaction enters the "Completed" state, it MUST set Timer J to fire
+ // in 64*T1 seconds for unreliable transports, and zero seconds for reliable transports.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ this.J = setTimeout(() => this.timerJ(), _timers__WEBPACK_IMPORTED_MODULE_0__["Timers"].TIMER_J);
+ }
+ // The server transaction MUST be destroyed the instant it enters the "Terminated" state.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.2
+ if (newState === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated) {
+ this.dispose();
+ }
+ this.setState(newState);
+ }
+ /**
+ * The server transaction remains in this state until Timer J fires,
+ * at which point it MUST transition to the "Terminated" state.
+ * https://tools.ietf.org/html/rfc3261#section-17.2.2
+ */
+ timerJ() {
+ this.logger.debug(`Timer J expired for NON-INVITE server transaction ${this.id}.`);
+ if (this.state === _transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Completed) {
+ this.stateTransition(_transaction_state__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Terminated);
+ }
+ }
+}
+
+
+/***/ }),
+/* 61 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 62 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ByeUserAgentClient", function() { return ByeUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * BYE UAC.
+ * @public
+ */
+class ByeUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].BYE, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ dialog.dispose();
+ }
+}
+
+
+/***/ }),
+/* 63 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "UserAgentClient", function() { return UserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+
+
+/**
+ * User Agent Client (UAC).
+ * @remarks
+ * A user agent client is a logical entity
+ * that creates a new request, and then uses the client
+ * transaction state machinery to send it. The role of UAC lasts
+ * only for the duration of that transaction. In other words, if
+ * a piece of software initiates a request, it acts as a UAC for
+ * the duration of that transaction. If it receives a request
+ * later, it assumes the role of a user agent server for the
+ * processing of that transaction.
+ * https://tools.ietf.org/html/rfc3261#section-6
+ * @public
+ */
+class UserAgentClient {
+ constructor(transactionConstructor, core, message, delegate) {
+ this.transactionConstructor = transactionConstructor;
+ this.core = core;
+ this.message = message;
+ this.delegate = delegate;
+ this.challenged = false;
+ this.stale = false;
+ this.logger = this.loggerFactory.getLogger("sip.user-agent-client");
+ this.init();
+ }
+ dispose() {
+ this.transaction.dispose();
+ }
+ get loggerFactory() {
+ return this.core.loggerFactory;
+ }
+ /** The transaction associated with this request. */
+ get transaction() {
+ if (!this._transaction) {
+ throw new Error("Transaction undefined.");
+ }
+ return this._transaction;
+ }
+ /**
+ * Since requests other than INVITE are responded to immediately, sending a
+ * CANCEL for a non-INVITE request would always create a race condition.
+ * A CANCEL request SHOULD NOT be sent to cancel a request other than INVITE.
+ * https://tools.ietf.org/html/rfc3261#section-9.1
+ * @param options - Cancel options bucket.
+ */
+ cancel(reason, options = {}) {
+ if (!this.transaction) {
+ throw new Error("Transaction undefined.");
+ }
+ if (!this.message.to) {
+ throw new Error("To undefined.");
+ }
+ if (!this.message.from) {
+ throw new Error("From undefined.");
+ }
+ // The following procedures are used to construct a CANCEL request. The
+ // Request-URI, Call-ID, To, the numeric part of CSeq, and From header
+ // fields in the CANCEL request MUST be identical to those in the
+ // request being cancelled, including tags. A CANCEL constructed by a
+ // client MUST have only a single Via header field value matching the
+ // top Via value in the request being cancelled. Using the same values
+ // for these header fields allows the CANCEL to be matched with the
+ // request it cancels (Section 9.2 indicates how such matching occurs).
+ // However, the method part of the CSeq header field MUST have a value
+ // of CANCEL. This allows it to be identified and processed as a
+ // transaction in its own right (See Section 17).
+ // https://tools.ietf.org/html/rfc3261#section-9.1
+ const message = this.core.makeOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].CANCEL, this.message.ruri, this.message.from.uri, this.message.to.uri, {
+ toTag: this.message.toTag,
+ fromTag: this.message.fromTag,
+ callId: this.message.callId,
+ cseq: this.message.cseq
+ }, options.extraHeaders);
+ // TODO: Revisit this.
+ // The CANCEL needs to use the same branch parameter so that
+ // it matches the INVITE transaction, but this is a hacky way to do this.
+ // Or at the very least not well documented. If the the branch parameter
+ // is set on the outgoing request, the transaction will use it.
+ // Otherwise the transaction will make a new one.
+ message.branch = this.message.branch;
+ if (this.message.headers.Route) {
+ message.headers.Route = this.message.headers.Route;
+ }
+ if (reason) {
+ message.setHeader("Reason", reason);
+ }
+ // If no provisional response has been received, the CANCEL request MUST
+ // NOT be sent; rather, the client MUST wait for the arrival of a
+ // provisional response before sending the request. If the original
+ // request has generated a final response, the CANCEL SHOULD NOT be
+ // sent, as it is an effective no-op, since CANCEL has no effect on
+ // requests that have already generated a final response.
+ // https://tools.ietf.org/html/rfc3261#section-9.1
+ if (this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_1__["TransactionState"].Proceeding) {
+ new UserAgentClient(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], this.core, message);
+ }
+ else {
+ this.transaction.addStateChangeListener(() => {
+ if (this.transaction && this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_1__["TransactionState"].Proceeding) {
+ new UserAgentClient(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], this.core, message);
+ }
+ }, { once: true });
+ }
+ return message;
+ }
+ /**
+ * If a 401 (Unauthorized) or 407 (Proxy Authentication Required)
+ * response is received, the UAC SHOULD follow the authorization
+ * procedures of Section 22.2 and Section 22.3 to retry the request with
+ * credentials.
+ * https://tools.ietf.org/html/rfc3261#section-8.1.3.5
+ * 22 Usage of HTTP Authentication
+ * https://tools.ietf.org/html/rfc3261#section-22
+ * 22.1 Framework
+ * https://tools.ietf.org/html/rfc3261#section-22.1
+ * 22.2 User-to-User Authentication
+ * https://tools.ietf.org/html/rfc3261#section-22.2
+ * 22.3 Proxy-to-User Authentication
+ * https://tools.ietf.org/html/rfc3261#section-22.3
+ *
+ * FIXME: This "guard for and retry the request with credentials"
+ * implementation is not complete and at best minimally passable.
+ * @param response - The incoming response to guard.
+ * @param dialog - If defined, the dialog within which the response was received.
+ * @returns True if the program execution is to continue in the branch in question.
+ * Otherwise the request is retried with credentials and current request processing must stop.
+ */
+ authenticationGuard(message, dialog) {
+ const statusCode = message.statusCode;
+ if (!statusCode) {
+ throw new Error("Response status code undefined.");
+ }
+ // If a 401 (Unauthorized) or 407 (Proxy Authentication Required)
+ // response is received, the UAC SHOULD follow the authorization
+ // procedures of Section 22.2 and Section 22.3 to retry the request with
+ // credentials.
+ // https://tools.ietf.org/html/rfc3261#section-8.1.3.5
+ if (statusCode !== 401 && statusCode !== 407) {
+ return true;
+ }
+ // Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let challenge;
+ let authorizationHeaderName;
+ if (statusCode === 401) {
+ challenge = message.parseHeader("www-authenticate");
+ authorizationHeaderName = "authorization";
+ }
+ else {
+ challenge = message.parseHeader("proxy-authenticate");
+ authorizationHeaderName = "proxy-authorization";
+ }
+ // Verify it seems a valid challenge.
+ if (!challenge) {
+ this.logger.warn(statusCode + " with wrong or missing challenge, cannot authenticate");
+ return true;
+ }
+ // Avoid infinite authentications.
+ if (this.challenged && (this.stale || challenge.stale !== true)) {
+ this.logger.warn(statusCode + " apparently in authentication loop, cannot authenticate");
+ return true;
+ }
+ // Get credentials.
+ if (!this.credentials) {
+ this.credentials = this.core.configuration.authenticationFactory();
+ if (!this.credentials) {
+ this.logger.warn("Unable to obtain credentials, cannot authenticate");
+ return true;
+ }
+ }
+ // Verify that the challenge is really valid.
+ if (!this.credentials.authenticate(this.message, challenge)) {
+ return true;
+ }
+ this.challenged = true;
+ if (challenge.stale) {
+ this.stale = true;
+ }
+ // If response to out of dialog request, assume incrementing the CSeq will suffice.
+ let cseq = (this.message.cseq += 1);
+ // If response to in dialog request, get a valid next CSeq number.
+ if (dialog && dialog.localSequenceNumber) {
+ dialog.incrementLocalSequenceNumber();
+ cseq = this.message.cseq = dialog.localSequenceNumber;
+ }
+ this.message.setHeader("cseq", cseq + " " + this.message.method);
+ this.message.setHeader(authorizationHeaderName, this.credentials.toString());
+ // Calling init (again) will swap out our existing client transaction with a new one.
+ // FIXME: HACK: An assumption is being made here that there is nothing that needs to
+ // be cleaned up beyond the client transaction which is being replaced. For example,
+ // it is assumed that no early dialogs have been created.
+ this.init();
+ return false;
+ }
+ /**
+ * 8.1.3.1 Transaction Layer Errors
+ * In some cases, the response returned by the transaction layer will
+ * not be a SIP message, but rather a transaction layer error. When a
+ * timeout error is received from the transaction layer, it MUST be
+ * treated as if a 408 (Request Timeout) status code has been received.
+ * If a fatal transport error is reported by the transport layer
+ * (generally, due to fatal ICMP errors in UDP or connection failures in
+ * TCP), the condition MUST be treated as a 503 (Service Unavailable)
+ * status code.
+ * https://tools.ietf.org/html/rfc3261#section-8.1.3.1
+ */
+ onRequestTimeout() {
+ this.logger.warn("User agent client request timed out. Generating internal 408 Request Timeout.");
+ const message = new _messages__WEBPACK_IMPORTED_MODULE_0__["IncomingResponseMessage"]();
+ message.statusCode = 408;
+ message.reasonPhrase = "Request Timeout";
+ this.receiveResponse(message);
+ return;
+ }
+ /**
+ * 8.1.3.1 Transaction Layer Errors
+ * In some cases, the response returned by the transaction layer will
+ * not be a SIP message, but rather a transaction layer error. When a
+ * timeout error is received from the transaction layer, it MUST be
+ * treated as if a 408 (Request Timeout) status code has been received.
+ * If a fatal transport error is reported by the transport layer
+ * (generally, due to fatal ICMP errors in UDP or connection failures in
+ * TCP), the condition MUST be treated as a 503 (Service Unavailable)
+ * status code.
+ * https://tools.ietf.org/html/rfc3261#section-8.1.3.1
+ * @param error - Transport error
+ */
+ onTransportError(error) {
+ this.logger.error(error.message);
+ this.logger.error("User agent client request transport error. Generating internal 503 Service Unavailable.");
+ const message = new _messages__WEBPACK_IMPORTED_MODULE_0__["IncomingResponseMessage"]();
+ message.statusCode = 503;
+ message.reasonPhrase = "Service Unavailable";
+ this.receiveResponse(message);
+ }
+ /**
+ * Receive a response from the transaction layer.
+ * @param message - Incoming response message.
+ */
+ receiveResponse(message) {
+ if (!this.authenticationGuard(message)) {
+ return;
+ }
+ const statusCode = message.statusCode ? message.statusCode.toString() : "";
+ if (!statusCode) {
+ throw new Error("Response status code undefined.");
+ }
+ switch (true) {
+ case /^100$/.test(statusCode):
+ if (this.delegate && this.delegate.onTrying) {
+ this.delegate.onTrying({ message });
+ }
+ break;
+ case /^1[0-9]{2}$/.test(statusCode):
+ if (this.delegate && this.delegate.onProgress) {
+ this.delegate.onProgress({ message });
+ }
+ break;
+ case /^2[0-9]{2}$/.test(statusCode):
+ if (this.delegate && this.delegate.onAccept) {
+ this.delegate.onAccept({ message });
+ }
+ break;
+ case /^3[0-9]{2}$/.test(statusCode):
+ if (this.delegate && this.delegate.onRedirect) {
+ this.delegate.onRedirect({ message });
+ }
+ break;
+ case /^[4-6][0-9]{2}$/.test(statusCode):
+ if (this.delegate && this.delegate.onReject) {
+ this.delegate.onReject({ message });
+ }
+ break;
+ default:
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ }
+ init() {
+ // We are the transaction user.
+ const user = {
+ loggerFactory: this.loggerFactory,
+ onRequestTimeout: () => this.onRequestTimeout(),
+ onStateChange: (newState) => {
+ if (newState === _transactions__WEBPACK_IMPORTED_MODULE_1__["TransactionState"].Terminated) {
+ // Remove the terminated transaction from the core.
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ this.core.userAgentClients.delete(userAgentClientId);
+ // FIXME: HACK: Our transaction may have been swapped out with a new one
+ // post authentication (see above), so make sure to only to dispose of
+ // ourselves if this terminating transaction is our current transaction.
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ if (transaction === this._transaction) {
+ this.dispose();
+ }
+ }
+ },
+ onTransportError: (error) => this.onTransportError(error),
+ receiveResponse: (message) => this.receiveResponse(message)
+ };
+ // Create a new transaction with us as the user.
+ const transaction = new this.transactionConstructor(this.message, this.core.transport, user);
+ this._transaction = transaction;
+ // Add the new transaction to the core.
+ const userAgentClientId = transaction.id + transaction.request.method;
+ this.core.userAgentClients.set(userAgentClientId, this);
+ }
+}
+
+
+/***/ }),
+/* 64 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ByeUserAgentServer", function() { return ByeUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * BYE UAS.
+ * @public
+ */
+class ByeUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(dialog, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], dialog.userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 65 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "UserAgentServer", function() { return UserAgentServer; });
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(51);
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8);
+/* harmony import */ var _messages_utils__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(32);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(48);
+
+
+
+
+/**
+ * User Agent Server (UAS).
+ * @remarks
+ * A user agent server is a logical entity
+ * that generates a response to a SIP request. The response
+ * accepts, rejects, or redirects the request. This role lasts
+ * only for the duration of that transaction. In other words, if
+ * a piece of software responds to a request, it acts as a UAS for
+ * the duration of that transaction. If it generates a request
+ * later, it assumes the role of a user agent client for the
+ * processing of that transaction.
+ * https://tools.ietf.org/html/rfc3261#section-6
+ * @public
+ */
+class UserAgentServer {
+ constructor(transactionConstructor, core, message, delegate) {
+ this.transactionConstructor = transactionConstructor;
+ this.core = core;
+ this.message = message;
+ this.delegate = delegate;
+ this.logger = this.loggerFactory.getLogger("sip.user-agent-server");
+ this.toTag = message.toTag ? message.toTag : Object(_messages_utils__WEBPACK_IMPORTED_MODULE_2__["newTag"])();
+ this.init();
+ }
+ dispose() {
+ this.transaction.dispose();
+ }
+ get loggerFactory() {
+ return this.core.loggerFactory;
+ }
+ /** The transaction associated with this request. */
+ get transaction() {
+ if (!this._transaction) {
+ throw new Error("Transaction undefined.");
+ }
+ return this._transaction;
+ }
+ accept(options = { statusCode: 200 }) {
+ if (!this.acceptable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransactionStateError"](`${this.message.method} not acceptable in state ${this.transaction.state}.`);
+ }
+ const statusCode = options.statusCode;
+ if (statusCode < 200 || statusCode > 299) {
+ throw new TypeError(`Invalid statusCode: ${statusCode}`);
+ }
+ const response = this.reply(options);
+ return response;
+ }
+ progress(options = { statusCode: 180 }) {
+ if (!this.progressable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransactionStateError"](`${this.message.method} not progressable in state ${this.transaction.state}.`);
+ }
+ const statusCode = options.statusCode;
+ if (statusCode < 101 || statusCode > 199) {
+ throw new TypeError(`Invalid statusCode: ${statusCode}`);
+ }
+ const response = this.reply(options);
+ return response;
+ }
+ redirect(contacts, options = { statusCode: 302 }) {
+ if (!this.redirectable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransactionStateError"](`${this.message.method} not redirectable in state ${this.transaction.state}.`);
+ }
+ const statusCode = options.statusCode;
+ if (statusCode < 300 || statusCode > 399) {
+ throw new TypeError(`Invalid statusCode: ${statusCode}`);
+ }
+ const contactHeaders = new Array();
+ contacts.forEach((contact) => contactHeaders.push(`Contact: ${contact.toString()}`));
+ options.extraHeaders = (options.extraHeaders || []).concat(contactHeaders);
+ const response = this.reply(options);
+ return response;
+ }
+ reject(options = { statusCode: 480 }) {
+ if (!this.rejectable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransactionStateError"](`${this.message.method} not rejectable in state ${this.transaction.state}.`);
+ }
+ const statusCode = options.statusCode;
+ if (statusCode < 400 || statusCode > 699) {
+ throw new TypeError(`Invalid statusCode: ${statusCode}`);
+ }
+ const response = this.reply(options);
+ return response;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ trying(options) {
+ if (!this.tryingable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_0__["TransactionStateError"](`${this.message.method} not tryingable in state ${this.transaction.state}.`);
+ }
+ const response = this.reply({ statusCode: 100 });
+ return response;
+ }
+ /**
+ * If the UAS did not find a matching transaction for the CANCEL
+ * according to the procedure above, it SHOULD respond to the CANCEL
+ * with a 481 (Call Leg/Transaction Does Not Exist). If the transaction
+ * for the original request still exists, the behavior of the UAS on
+ * receiving a CANCEL request depends on whether it has already sent a
+ * final response for the original request. If it has, the CANCEL
+ * request has no effect on the processing of the original request, no
+ * effect on any session state, and no effect on the responses generated
+ * for the original request. If the UAS has not issued a final response
+ * for the original request, its behavior depends on the method of the
+ * original request. If the original request was an INVITE, the UAS
+ * SHOULD immediately respond to the INVITE with a 487 (Request
+ * Terminated). A CANCEL request has no impact on the processing of
+ * transactions with any other method defined in this specification.
+ * https://tools.ietf.org/html/rfc3261#section-9.2
+ * @param request - Incoming CANCEL request.
+ */
+ receiveCancel(message) {
+ // Note: Currently CANCEL is being handled as a special case.
+ // No UAS is created to handle the CANCEL and the response to
+ // it CANCEL is being handled statelessly by the user agent core.
+ // As such, there is currently no way to externally impact the
+ // response to the a CANCEL request.
+ if (this.delegate && this.delegate.onCancel) {
+ this.delegate.onCancel(message);
+ }
+ }
+ get acceptable() {
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ return (this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding || this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Accepted);
+ }
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["NonInviteServerTransaction"]) {
+ return (this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Trying || this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding);
+ }
+ throw new Error("Unknown transaction type.");
+ }
+ get progressable() {
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ return this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding;
+ }
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["NonInviteServerTransaction"]) {
+ return false; // https://tools.ietf.org/html/rfc4320#section-4.1
+ }
+ throw new Error("Unknown transaction type.");
+ }
+ get redirectable() {
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ return this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding;
+ }
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["NonInviteServerTransaction"]) {
+ return (this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Trying || this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding);
+ }
+ throw new Error("Unknown transaction type.");
+ }
+ get rejectable() {
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ return this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding;
+ }
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["NonInviteServerTransaction"]) {
+ return (this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Trying || this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding);
+ }
+ throw new Error("Unknown transaction type.");
+ }
+ get tryingable() {
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"]) {
+ return this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Proceeding;
+ }
+ if (this.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["NonInviteServerTransaction"]) {
+ return this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Trying;
+ }
+ throw new Error("Unknown transaction type.");
+ }
+ /**
+ * When a UAS wishes to construct a response to a request, it follows
+ * the general procedures detailed in the following subsections.
+ * Additional behaviors specific to the response code in question, which
+ * are not detailed in this section, may also be required.
+ *
+ * Once all procedures associated with the creation of a response have
+ * been completed, the UAS hands the response back to the server
+ * transaction from which it received the request.
+ * https://tools.ietf.org/html/rfc3261#section-8.2.6
+ * @param statusCode - Status code to reply with.
+ * @param options - Reply options bucket.
+ */
+ reply(options) {
+ if (!options.toTag && options.statusCode !== 100) {
+ options.toTag = this.toTag;
+ }
+ options.userAgent = options.userAgent || this.core.configuration.userAgentHeaderFieldValue;
+ options.supported = options.supported || this.core.configuration.supportedOptionTagsResponse;
+ const response = Object(_messages__WEBPACK_IMPORTED_MODULE_1__["constructOutgoingResponse"])(this.message, options);
+ this.transaction.receiveResponse(options.statusCode, response.message);
+ return response;
+ }
+ init() {
+ // We are the transaction user.
+ const user = {
+ loggerFactory: this.loggerFactory,
+ onStateChange: (newState) => {
+ if (newState === _transactions__WEBPACK_IMPORTED_MODULE_3__["TransactionState"].Terminated) {
+ // Remove the terminated transaction from the core.
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ this.core.userAgentServers.delete(userAgentServerId);
+ this.dispose();
+ }
+ },
+ onTransportError: (error) => {
+ this.logger.error(error.message);
+ if (this.delegate && this.delegate.onTransportError) {
+ this.delegate.onTransportError(error);
+ }
+ else {
+ this.logger.error("User agent server response transport error.");
+ }
+ }
+ };
+ // Create a new transaction with us as the user.
+ const transaction = new this.transactionConstructor(this.message, this.core.transport, user);
+ this._transaction = transaction;
+ // Add the new transaction to the core.
+ const userAgentServerId = transaction.id;
+ this.core.userAgentServers.set(transaction.id, this);
+ }
+}
+
+
+/***/ }),
+/* 66 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "InfoUserAgentClient", function() { return InfoUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * INFO UAC.
+ * @public
+ */
+class InfoUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].INFO, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 67 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "InfoUserAgentServer", function() { return InfoUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * INFO UAS.
+ * @public
+ */
+class InfoUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(dialog, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], dialog.userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 68 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MessageUserAgentClient", function() { return MessageUserAgentClient; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(63);
+
+
+/**
+ * MESSAGE UAC.
+ * @public
+ */
+class MessageUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_1__["UserAgentClient"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteClientTransaction"], core, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 69 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "MessageUserAgentServer", function() { return MessageUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * MESSAGE UAS.
+ * @public
+ */
+class MessageUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], core, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 70 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "NotifyUserAgentClient", function() { return NotifyUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * NOTIFY UAS.
+ * @public
+ */
+class NotifyUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].NOTIFY, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 71 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "NotifyUserAgentServer", function() { return NotifyUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function instanceOfDialog(object) {
+ return object.userAgentCore !== undefined;
+}
+/**
+ * NOTIFY UAS.
+ * @public
+ */
+class NotifyUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ /**
+ * NOTIFY UAS constructor.
+ * @param dialogOrCore - Dialog for in dialog NOTIFY, UserAgentCore for out of dialog NOTIFY (deprecated).
+ * @param message - Incoming NOTIFY request message.
+ */
+ constructor(dialogOrCore, message, delegate) {
+ const userAgentCore = instanceOfDialog(dialogOrCore) ? dialogOrCore.userAgentCore : dialogOrCore;
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 72 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PrackUserAgentClient", function() { return PrackUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * PRACK UAC.
+ * @public
+ */
+class PrackUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].PRACK, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ dialog.signalingStateTransition(message);
+ }
+}
+
+
+/***/ }),
+/* 73 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PrackUserAgentServer", function() { return PrackUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * PRACK UAS.
+ * @public
+ */
+class PrackUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(dialog, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], dialog.userAgentCore, message, delegate);
+ // Update dialog signaling state with offer/answer in body
+ dialog.signalingStateTransition(message);
+ this.dialog = dialog;
+ }
+ /**
+ * Update the dialog signaling state on a 2xx response.
+ * @param options - Options bucket.
+ */
+ accept(options = { statusCode: 200 }) {
+ if (options.body) {
+ // Update dialog signaling state with offer/answer in body
+ this.dialog.signalingStateTransition(options.body);
+ }
+ return super.accept(options);
+ }
+}
+
+
+/***/ }),
+/* 74 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReInviteUserAgentClient", function() { return ReInviteUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * Re-INVITE UAC.
+ * @remarks
+ * 14 Modifying an Existing Session
+ * https://tools.ietf.org/html/rfc3261#section-14
+ * 14.1 UAC Behavior
+ * https://tools.ietf.org/html/rfc3261#section-14.1
+ * @public
+ */
+class ReInviteUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["InviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ this.delegate = delegate;
+ dialog.signalingStateTransition(message);
+ // FIXME: TODO: next line obviously needs to be improved...
+ dialog.reinviteUserAgentClient = this; // let the dialog know re-invite request sent
+ this.dialog = dialog;
+ }
+ receiveResponse(message) {
+ if (!this.authenticationGuard(message, this.dialog)) {
+ return;
+ }
+ const statusCode = message.statusCode ? message.statusCode.toString() : "";
+ if (!statusCode) {
+ throw new Error("Response status code undefined.");
+ }
+ switch (true) {
+ case /^100$/.test(statusCode):
+ if (this.delegate && this.delegate.onTrying) {
+ this.delegate.onTrying({ message });
+ }
+ break;
+ case /^1[0-9]{2}$/.test(statusCode):
+ if (this.delegate && this.delegate.onProgress) {
+ this.delegate.onProgress({
+ message,
+ session: this.dialog,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ prack: (options) => {
+ throw new Error("Unimplemented.");
+ }
+ });
+ }
+ break;
+ case /^2[0-9]{2}$/.test(statusCode):
+ // Update dialog signaling state with offer/answer in body
+ this.dialog.signalingStateTransition(message);
+ if (this.delegate && this.delegate.onAccept) {
+ this.delegate.onAccept({
+ message,
+ session: this.dialog,
+ ack: (options) => {
+ const outgoingAckRequest = this.dialog.ack(options);
+ return outgoingAckRequest;
+ }
+ });
+ }
+ break;
+ case /^3[0-9]{2}$/.test(statusCode):
+ this.dialog.signalingStateRollback();
+ this.dialog.reinviteUserAgentClient = undefined; // ACK was handled by transaction
+ if (this.delegate && this.delegate.onRedirect) {
+ this.delegate.onRedirect({ message });
+ }
+ break;
+ case /^[4-6][0-9]{2}$/.test(statusCode):
+ this.dialog.signalingStateRollback();
+ this.dialog.reinviteUserAgentClient = undefined; // ACK was handled by transaction
+ if (this.delegate && this.delegate.onReject) {
+ this.delegate.onReject({ message });
+ }
+ else {
+ // If a UA receives a non-2xx final response to a re-INVITE, the session
+ // parameters MUST remain unchanged, as if no re-INVITE had been issued.
+ // Note that, as stated in Section 12.2.1.2, if the non-2xx final
+ // response is a 481 (Call/Transaction Does Not Exist), or a 408
+ // (Request Timeout), or no response at all is received for the re-
+ // INVITE (that is, a timeout is returned by the INVITE client
+ // transaction), the UAC will terminate the dialog.
+ //
+ // If a UAC receives a 491 response to a re-INVITE, it SHOULD start a
+ // timer with a value T chosen as follows:
+ //
+ // 1. If the UAC is the owner of the Call-ID of the dialog ID
+ // (meaning it generated the value), T has a randomly chosen value
+ // between 2.1 and 4 seconds in units of 10 ms.
+ //
+ // 2. If the UAC is not the owner of the Call-ID of the dialog ID, T
+ // has a randomly chosen value of between 0 and 2 seconds in units
+ // of 10 ms.
+ //
+ // When the timer fires, the UAC SHOULD attempt the re-INVITE once more,
+ // if it still desires for that session modification to take place. For
+ // example, if the call was already hung up with a BYE, the re-INVITE
+ // would not take place.
+ // https://tools.ietf.org/html/rfc3261#section-14.1
+ // FIXME: TODO: The above.
+ }
+ break;
+ default:
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ }
+}
+
+
+/***/ }),
+/* 75 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReInviteUserAgentServer", function() { return ReInviteUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * Re-INVITE UAS.
+ * @remarks
+ * 14 Modifying an Existing Session
+ * https://tools.ietf.org/html/rfc3261#section-14
+ * 14.2 UAS Behavior
+ * https://tools.ietf.org/html/rfc3261#section-14.2
+ * @public
+ */
+class ReInviteUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(dialog, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["InviteServerTransaction"], dialog.userAgentCore, message, delegate);
+ dialog.reinviteUserAgentServer = this;
+ this.dialog = dialog;
+ }
+ /**
+ * Update the dialog signaling state on a 2xx response.
+ * @param options - Options bucket.
+ */
+ accept(options = { statusCode: 200 }) {
+ // FIXME: The next two lines SHOULD go away, but I suppose it's technically harmless...
+ // These are here because some versions of SIP.js prior to 0.13.8 set the route set
+ // of all in dialog ACKs based on the Record-Route headers in the associated 2xx
+ // response. While this worked for dialog forming 2xx responses, it was technically
+ // broken for re-INVITE ACKS as it only worked if the UAS populated the Record-Route
+ // headers in the re-INVITE 2xx response (which is not required and a waste of bandwidth
+ // as the should be ignored if present in re-INVITE ACKS) and the UAS populated
+ // the Record-Route headers with the correct values (would be weird not too, but...).
+ // Anyway, for now the technically useless Record-Route headers are being added
+ // to maintain "backwards compatibility" with the older broken versions of SIP.js.
+ options.extraHeaders = options.extraHeaders || [];
+ options.extraHeaders = options.extraHeaders.concat(this.dialog.routeSet.map((route) => `Record-Route: ${route}`));
+ // Send and return the response
+ const response = super.accept(options);
+ const session = this.dialog;
+ const result = Object.assign(Object.assign({}, response), { session });
+ if (options.body) {
+ // Update dialog signaling state with offer/answer in body
+ this.dialog.signalingStateTransition(options.body);
+ }
+ // Update dialog
+ this.dialog.reConfirm();
+ return result;
+ }
+ /**
+ * Update the dialog signaling state on a 1xx response.
+ * @param options - Progress options bucket.
+ */
+ progress(options = { statusCode: 180 }) {
+ // Send and return the response
+ const response = super.progress(options);
+ const session = this.dialog;
+ const result = Object.assign(Object.assign({}, response), { session });
+ // Update dialog signaling state
+ if (options.body) {
+ this.dialog.signalingStateTransition(options.body);
+ }
+ return result;
+ }
+ /**
+ * TODO: Not Yet Supported
+ * @param contacts - Contacts to redirect to.
+ * @param options - Redirect options bucket.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ redirect(contacts, options = { statusCode: 302 }) {
+ this.dialog.signalingStateRollback();
+ this.dialog.reinviteUserAgentServer = undefined; // ACK will be handled by transaction
+ throw new Error("Unimplemented.");
+ }
+ /**
+ * 3.1 Background on Re-INVITE Handling by UASs
+ * An error response to a re-INVITE has the following semantics. As
+ * specified in Section 12.2.2 of RFC 3261 [RFC3261], if a re-INVITE is
+ * rejected, no state changes are performed.
+ * https://tools.ietf.org/html/rfc6141#section-3.1
+ * @param options - Reject options bucket.
+ */
+ reject(options = { statusCode: 488 }) {
+ this.dialog.signalingStateRollback();
+ this.dialog.reinviteUserAgentServer = undefined; // ACK will be handled by transaction
+ return super.reject(options);
+ }
+}
+
+
+/***/ }),
+/* 76 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReferUserAgentClient", function() { return ReferUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * REFER UAC.
+ * @public
+ */
+class ReferUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].REFER, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 77 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReferUserAgentServer", function() { return ReferUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function instanceOfSessionDialog(object) {
+ return object.userAgentCore !== undefined;
+}
+/**
+ * REFER UAS.
+ * @public
+ */
+class ReferUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ /**
+ * REFER UAS constructor.
+ * @param dialogOrCore - Dialog for in dialog REFER, UserAgentCore for out of dialog REFER.
+ * @param message - Incoming REFER request message.
+ */
+ constructor(dialogOrCore, message, delegate) {
+ const userAgentCore = instanceOfSessionDialog(dialogOrCore) ? dialogOrCore.userAgentCore : dialogOrCore;
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 78 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SubscriptionDialog", function() { return SubscriptionDialog; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _subscription__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(79);
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
+/* harmony import */ var _user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(82);
+/* harmony import */ var _user_agents_notify_user_agent_server__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(71);
+/* harmony import */ var _user_agents_re_subscribe_user_agent_client__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(83);
+/* harmony import */ var _dialog__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(7);
+
+
+
+
+
+
+
+/**
+ * Subscription Dialog.
+ * @remarks
+ * SIP-Specific Event Notification
+ *
+ * Abstract
+ *
+ * This document describes an extension to the Session Initiation
+ * Protocol (SIP) defined by RFC 3261. The purpose of this extension is
+ * to provide an extensible framework by which SIP nodes can request
+ * notification from remote nodes indicating that certain events have
+ * occurred.
+ *
+ * Note that the event notification mechanisms defined herein are NOT
+ * intended to be a general-purpose infrastructure for all classes of
+ * event subscription and notification.
+ *
+ * This document represents a backwards-compatible improvement on the
+ * original mechanism described by RFC 3265, taking into account several
+ * years of implementation experience. Accordingly, this document
+ * obsoletes RFC 3265. This document also updates RFC 4660 slightly to
+ * accommodate some small changes to the mechanism that were discussed
+ * in that document.
+ *
+ * https://tools.ietf.org/html/rfc6665
+ * @public
+ */
+class SubscriptionDialog extends _dialog__WEBPACK_IMPORTED_MODULE_6__["Dialog"] {
+ constructor(subscriptionEvent, subscriptionExpires, subscriptionState, core, state, delegate) {
+ super(core, state);
+ this.delegate = delegate;
+ this._autoRefresh = false;
+ this._subscriptionEvent = subscriptionEvent;
+ this._subscriptionExpires = subscriptionExpires;
+ this._subscriptionExpiresInitial = subscriptionExpires;
+ this._subscriptionExpiresLastSet = Math.floor(Date.now() / 1000);
+ this._subscriptionRefresh = undefined;
+ this._subscriptionRefreshLastSet = undefined;
+ this._subscriptionState = subscriptionState;
+ this.logger = core.loggerFactory.getLogger("sip.subscribe-dialog");
+ this.logger.log(`SUBSCRIBE dialog ${this.id} constructed`);
+ }
+ /**
+ * When a UAC receives a response that establishes a dialog, it
+ * constructs the state of the dialog. This state MUST be maintained
+ * for the duration of the dialog.
+ * https://tools.ietf.org/html/rfc3261#section-12.1.2
+ * @param outgoingRequestMessage - Outgoing request message for dialog.
+ * @param incomingResponseMessage - Incoming response message creating dialog.
+ */
+ static initialDialogStateForSubscription(outgoingSubscribeRequestMessage, incomingNotifyRequestMessage) {
+ // If the request was sent over TLS, and the Request-URI contained a
+ // SIPS URI, the "secure" flag is set to TRUE.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ const secure = false; // FIXME: Currently no support for TLS.
+ // The route set MUST be set to the list of URIs in the Record-Route
+ // header field from the response, taken in reverse order and preserving
+ // all URI parameters. If no Record-Route header field is present in
+ // the response, the route set MUST be set to the empty set. This route
+ // set, even if empty, overrides any pre-existing route set for future
+ // requests in this dialog. The remote target MUST be set to the URI
+ // from the Contact header field of the response.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ const routeSet = incomingNotifyRequestMessage.getHeaders("record-route");
+ const contact = incomingNotifyRequestMessage.parseHeader("contact");
+ if (!contact) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("Contact undefined.");
+ }
+ if (!(contact instanceof _messages__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"])) {
+ throw new Error("Contact not instance of NameAddrHeader.");
+ }
+ const remoteTarget = contact.uri;
+ // The local sequence number MUST be set to the value of the sequence
+ // number in the CSeq header field of the request. The remote sequence
+ // number MUST be empty (it is established when the remote UA sends a
+ // request within the dialog). The call identifier component of the
+ // dialog ID MUST be set to the value of the Call-ID in the request.
+ // The local tag component of the dialog ID MUST be set to the tag in
+ // the From field in the request, and the remote tag component of the
+ // dialog ID MUST be set to the tag in the To field of the response. A
+ // UAC MUST be prepared to receive a response without a tag in the To
+ // field, in which case the tag is considered to have a value of null.
+ //
+ // This is to maintain backwards compatibility with RFC 2543, which
+ // did not mandate To tags.
+ //
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ const localSequenceNumber = outgoingSubscribeRequestMessage.cseq;
+ const remoteSequenceNumber = undefined;
+ const callId = outgoingSubscribeRequestMessage.callId;
+ const localTag = outgoingSubscribeRequestMessage.fromTag;
+ const remoteTag = incomingNotifyRequestMessage.fromTag;
+ if (!callId) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("Call id undefined.");
+ }
+ if (!localTag) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("From tag undefined.");
+ }
+ if (!remoteTag) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("To tag undefined."); // FIXME: No backwards compatibility with RFC 2543
+ }
+ // The remote URI MUST be set to the URI in the To field, and the local
+ // URI MUST be set to the URI in the From field.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.2
+ if (!outgoingSubscribeRequestMessage.from) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("From undefined.");
+ }
+ if (!outgoingSubscribeRequestMessage.to) {
+ // TODO: Review to make sure this will never happen
+ throw new Error("To undefined.");
+ }
+ const localURI = outgoingSubscribeRequestMessage.from.uri;
+ const remoteURI = outgoingSubscribeRequestMessage.to.uri;
+ // A dialog can also be in the "early" state, which occurs when it is
+ // created with a provisional response, and then transition to the
+ // "confirmed" state when a 2xx final response arrives.
+ // https://tools.ietf.org/html/rfc3261#section-12
+ const early = false;
+ const dialogState = {
+ id: callId + localTag + remoteTag,
+ early,
+ callId,
+ localTag,
+ remoteTag,
+ localSequenceNumber,
+ remoteSequenceNumber,
+ localURI,
+ remoteURI,
+ remoteTarget,
+ routeSet,
+ secure
+ };
+ return dialogState;
+ }
+ dispose() {
+ super.dispose();
+ if (this.N) {
+ clearTimeout(this.N);
+ this.N = undefined;
+ }
+ this.refreshTimerClear();
+ this.logger.log(`SUBSCRIBE dialog ${this.id} destroyed`);
+ }
+ get autoRefresh() {
+ return this._autoRefresh;
+ }
+ set autoRefresh(autoRefresh) {
+ this._autoRefresh = true;
+ this.refreshTimerSet();
+ }
+ get subscriptionEvent() {
+ return this._subscriptionEvent;
+ }
+ /** Number of seconds until subscription expires. */
+ get subscriptionExpires() {
+ const secondsSinceLastSet = Math.floor(Date.now() / 1000) - this._subscriptionExpiresLastSet;
+ const secondsUntilExpires = this._subscriptionExpires - secondsSinceLastSet;
+ return Math.max(secondsUntilExpires, 0);
+ }
+ set subscriptionExpires(expires) {
+ if (expires < 0) {
+ throw new Error("Expires must be greater than or equal to zero.");
+ }
+ this._subscriptionExpires = expires;
+ this._subscriptionExpiresLastSet = Math.floor(Date.now() / 1000);
+ if (this.autoRefresh) {
+ const refresh = this.subscriptionRefresh;
+ if (refresh === undefined || refresh >= expires) {
+ this.refreshTimerSet();
+ }
+ }
+ }
+ get subscriptionExpiresInitial() {
+ return this._subscriptionExpiresInitial;
+ }
+ /** Number of seconds until subscription auto refresh. */
+ get subscriptionRefresh() {
+ if (this._subscriptionRefresh === undefined || this._subscriptionRefreshLastSet === undefined) {
+ return undefined;
+ }
+ const secondsSinceLastSet = Math.floor(Date.now() / 1000) - this._subscriptionRefreshLastSet;
+ const secondsUntilExpires = this._subscriptionRefresh - secondsSinceLastSet;
+ return Math.max(secondsUntilExpires, 0);
+ }
+ get subscriptionState() {
+ return this._subscriptionState;
+ }
+ /**
+ * Receive in dialog request message from transport.
+ * @param message - The incoming request message.
+ */
+ receiveRequest(message) {
+ this.logger.log(`SUBSCRIBE dialog ${this.id} received ${message.method} request`);
+ // Request within a dialog out of sequence guard.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ if (!this.sequenceGuard(message)) {
+ this.logger.log(`SUBSCRIBE dialog ${this.id} rejected out of order ${message.method} request.`);
+ return;
+ }
+ // Request within a dialog common processing.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ super.receiveRequest(message);
+ // Switch on method and then delegate.
+ switch (message.method) {
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].NOTIFY:
+ this.onNotify(message);
+ break;
+ default:
+ this.logger.log(`SUBSCRIBE dialog ${this.id} received unimplemented ${message.method} request`);
+ this.core.replyStateless(message, { statusCode: 501 });
+ break;
+ }
+ }
+ /**
+ * 4.1.2.2. Refreshing of Subscriptions
+ * https://tools.ietf.org/html/rfc6665#section-4.1.2.2
+ */
+ refresh() {
+ const allowHeader = "Allow: " + _user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_3__["AllowedMethods"].toString();
+ const options = {};
+ options.extraHeaders = (options.extraHeaders || []).slice();
+ options.extraHeaders.push(allowHeader);
+ options.extraHeaders.push("Event: " + this.subscriptionEvent);
+ options.extraHeaders.push("Expires: " + this.subscriptionExpiresInitial);
+ options.extraHeaders.push("Contact: " + this.core.configuration.contact.toString());
+ return this.subscribe(undefined, options);
+ }
+ /**
+ * 4.1.2.2. Refreshing of Subscriptions
+ * https://tools.ietf.org/html/rfc6665#section-4.1.2.2
+ * @param delegate - Delegate to handle responses.
+ * @param options - Options bucket.
+ */
+ subscribe(delegate, options = {}) {
+ if (this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending && this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active) {
+ // FIXME: This needs to be a proper exception
+ throw new Error(`Invalid state ${this.subscriptionState}. May only re-subscribe while in state "pending" or "active".`);
+ }
+ this.logger.log(`SUBSCRIBE dialog ${this.id} sending SUBSCRIBE request`);
+ const uac = new _user_agents_re_subscribe_user_agent_client__WEBPACK_IMPORTED_MODULE_5__["ReSubscribeUserAgentClient"](this, delegate, options);
+ // Abort any outstanding timer (as it would otherwise become guaranteed to terminate us).
+ if (this.N) {
+ clearTimeout(this.N);
+ this.N = undefined;
+ }
+ // When refreshing a subscription, a subscriber starts Timer N, set to
+ // 64*T1, when it sends the SUBSCRIBE request.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.2
+ this.N = setTimeout(() => this.timerN(), _timers__WEBPACK_IMPORTED_MODULE_2__["Timers"].TIMER_N);
+ return uac;
+ }
+ /**
+ * 4.4.1. Dialog Creation and Termination
+ * A subscription is destroyed after a notifier sends a NOTIFY request
+ * with a "Subscription-State" of "terminated", or in certain error
+ * situations described elsewhere in this document.
+ * https://tools.ietf.org/html/rfc6665#section-4.4.1
+ */
+ terminate() {
+ this.stateTransition(_subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated);
+ this.onTerminated();
+ }
+ /**
+ * 4.1.2.3. Unsubscribing
+ * https://tools.ietf.org/html/rfc6665#section-4.1.2.3
+ */
+ unsubscribe() {
+ const allowHeader = "Allow: " + _user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_3__["AllowedMethods"].toString();
+ const options = {};
+ options.extraHeaders = (options.extraHeaders || []).slice();
+ options.extraHeaders.push(allowHeader);
+ options.extraHeaders.push("Event: " + this.subscriptionEvent);
+ options.extraHeaders.push("Expires: 0");
+ options.extraHeaders.push("Contact: " + this.core.configuration.contact.toString());
+ return this.subscribe(undefined, options);
+ }
+ /**
+ * Handle in dialog NOTIFY requests.
+ * This does not include the first NOTIFY which created the dialog.
+ * @param message - The incoming NOTIFY request message.
+ */
+ onNotify(message) {
+ // If, for some reason, the event package designated in the "Event"
+ // header field of the NOTIFY request is not supported, the subscriber
+ // will respond with a 489 (Bad Event) response.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.3
+ const event = message.parseHeader("Event").event;
+ if (!event || event !== this.subscriptionEvent) {
+ this.core.replyStateless(message, { statusCode: 489 });
+ return;
+ }
+ // In the state diagram, "Re-subscription times out" means that an
+ // attempt to refresh or update the subscription using a new SUBSCRIBE
+ // request does not result in a NOTIFY request before the corresponding
+ // Timer N expires.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2
+ if (this.N) {
+ clearTimeout(this.N);
+ this.N = undefined;
+ }
+ // NOTIFY requests MUST contain "Subscription-State" header fields that
+ // indicate the status of the subscription.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.3
+ const subscriptionState = message.parseHeader("Subscription-State");
+ if (!subscriptionState || !subscriptionState.state) {
+ this.core.replyStateless(message, { statusCode: 489 });
+ return;
+ }
+ const state = subscriptionState.state;
+ const expires = subscriptionState.expires ? Math.max(subscriptionState.expires, 0) : undefined;
+ // Update our state and expiration.
+ switch (state) {
+ case "pending":
+ this.stateTransition(_subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending, expires);
+ break;
+ case "active":
+ this.stateTransition(_subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active, expires);
+ break;
+ case "terminated":
+ this.stateTransition(_subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated, expires);
+ break;
+ default:
+ this.logger.warn("Unrecognized subscription state.");
+ break;
+ }
+ // Delegate remainder of NOTIFY handling.
+ const uas = new _user_agents_notify_user_agent_server__WEBPACK_IMPORTED_MODULE_4__["NotifyUserAgentServer"](this, message);
+ if (this.delegate && this.delegate.onNotify) {
+ this.delegate.onNotify(uas);
+ }
+ else {
+ uas.accept();
+ }
+ }
+ onRefresh(request) {
+ if (this.delegate && this.delegate.onRefresh) {
+ this.delegate.onRefresh(request);
+ }
+ }
+ onTerminated() {
+ if (this.delegate && this.delegate.onTerminated) {
+ this.delegate.onTerminated();
+ }
+ }
+ refreshTimerClear() {
+ if (this.refreshTimer) {
+ clearTimeout(this.refreshTimer);
+ this.refreshTimer = undefined;
+ }
+ }
+ refreshTimerSet() {
+ this.refreshTimerClear();
+ if (this.autoRefresh && this.subscriptionExpires > 0) {
+ const refresh = this.subscriptionExpires * 900;
+ this._subscriptionRefresh = Math.floor(refresh / 1000);
+ this._subscriptionRefreshLastSet = Math.floor(Date.now() / 1000);
+ this.refreshTimer = setTimeout(() => {
+ this.refreshTimer = undefined;
+ this._subscriptionRefresh = undefined;
+ this._subscriptionRefreshLastSet = undefined;
+ this.onRefresh(this.refresh());
+ }, refresh);
+ }
+ }
+ stateTransition(newState, newExpires) {
+ // Assert valid state transitions.
+ const invalidStateTransition = () => {
+ this.logger.warn(`Invalid subscription state transition from ${this.subscriptionState} to ${newState}`);
+ };
+ switch (newState) {
+ case _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Initial:
+ invalidStateTransition();
+ return;
+ case _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait:
+ invalidStateTransition();
+ return;
+ case _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending:
+ if (this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait &&
+ this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending) {
+ invalidStateTransition();
+ return;
+ }
+ break;
+ case _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active:
+ if (this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait &&
+ this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending &&
+ this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active) {
+ invalidStateTransition();
+ return;
+ }
+ break;
+ case _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated:
+ if (this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait &&
+ this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending &&
+ this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active) {
+ invalidStateTransition();
+ return;
+ }
+ break;
+ default:
+ invalidStateTransition();
+ return;
+ }
+ // If the "Subscription-State" value is "pending", the subscription has
+ // been received by the notifier, but there is insufficient policy
+ // information to grant or deny the subscription yet. If the header
+ // field also contains an "expires" parameter, the subscriber SHOULD
+ // take it as the authoritative subscription duration and adjust
+ // accordingly. No further action is necessary on the part of the
+ // subscriber. The "retry-after" and "reason" parameters have no
+ // semantics for "pending".
+ // https://tools.ietf.org/html/rfc6665#section-4.1.3
+ if (newState === _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending) {
+ if (newExpires) {
+ this.subscriptionExpires = newExpires;
+ }
+ }
+ // If the "Subscription-State" header field value is "active", it means
+ // that the subscription has been accepted and (in general) has been
+ // authorized. If the header field also contains an "expires"
+ // parameter, the subscriber SHOULD take it as the authoritative
+ // subscription duration and adjust accordingly. The "retry-after" and
+ // "reason" parameters have no semantics for "active".
+ // https://tools.ietf.org/html/rfc6665#section-4.1.3
+ if (newState === _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active) {
+ if (newExpires) {
+ this.subscriptionExpires = newExpires;
+ }
+ }
+ // If the "Subscription-State" value is "terminated", the subscriber
+ // MUST consider the subscription terminated. The "expires" parameter
+ // has no semantics for "terminated" -- notifiers SHOULD NOT include an
+ // "expires" parameter on a "Subscription-State" header field with a
+ // value of "terminated", and subscribers MUST ignore any such
+ // parameter, if present.
+ if (newState === _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ this.dispose();
+ }
+ this._subscriptionState = newState;
+ }
+ /**
+ * When refreshing a subscription, a subscriber starts Timer N, set to
+ * 64*T1, when it sends the SUBSCRIBE request. If this Timer N expires
+ * prior to the receipt of a NOTIFY request, the subscriber considers
+ * the subscription terminated. If the subscriber receives a success
+ * response to the SUBSCRIBE request that indicates that no NOTIFY
+ * request will be generated -- such as the 204 response defined for use
+ * with the optional extension described in [RFC5839] -- then it MUST
+ * cancel Timer N.
+ * https://tools.ietf.org/html/rfc6665#section-4.1.2.2
+ */
+ timerN() {
+ this.logger.warn(`Timer N expired for SUBSCRIBE dialog. Timed out waiting for NOTIFY.`);
+ if (this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ this.stateTransition(_subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated);
+ this.onTerminated();
+ }
+ }
+}
+
+
+/***/ }),
+/* 79 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _subscription__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(80);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscriptionState", function() { return _subscription__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"]; });
+
+/* harmony import */ var _subscription_delegate__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(81);
+/* harmony import */ var _subscription_delegate__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_subscription_delegate__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _subscription_delegate__WEBPACK_IMPORTED_MODULE_1__) if(["SubscriptionState","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _subscription_delegate__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+
+
+
+
+/***/ }),
+/* 80 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SubscriptionState", function() { return SubscriptionState; });
+/**
+ * Subscription state.
+ * @remarks
+ * https://tools.ietf.org/html/rfc6665#section-4.1.2
+ * @public
+ */
+var SubscriptionState;
+(function (SubscriptionState) {
+ SubscriptionState["Initial"] = "Initial";
+ SubscriptionState["NotifyWait"] = "NotifyWait";
+ SubscriptionState["Pending"] = "Pending";
+ SubscriptionState["Active"] = "Active";
+ SubscriptionState["Terminated"] = "Terminated";
+})(SubscriptionState || (SubscriptionState = {}));
+
+
+/***/ }),
+/* 81 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 82 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "AllowedMethods", function() { return AllowedMethods; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+
+/**
+ * FIXME: TODO: Should be configurable/variable.
+ */
+const AllowedMethods = [
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].BYE,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].CANCEL,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INFO,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].MESSAGE,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].NOTIFY,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].OPTIONS,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].PRACK,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].REFER,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].REGISTER,
+ _messages__WEBPACK_IMPORTED_MODULE_0__["C"].SUBSCRIBE
+];
+
+
+/***/ }),
+/* 83 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReSubscribeUserAgentClient", function() { return ReSubscribeUserAgentClient; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(63);
+
+
+
+/**
+ * Re-SUBSCRIBE UAC.
+ * @public
+ */
+class ReSubscribeUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"] {
+ constructor(dialog, delegate, options) {
+ const message = dialog.createOutgoingRequestMessage(_messages__WEBPACK_IMPORTED_MODULE_0__["C"].SUBSCRIBE, options);
+ super(_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], dialog.userAgentCore, message, delegate);
+ this.dialog = dialog;
+ }
+ waitNotifyStop() {
+ // TODO: Placeholder. Not utilized currently.
+ return;
+ }
+ /**
+ * Receive a response from the transaction layer.
+ * @param message - Incoming response message.
+ */
+ receiveResponse(message) {
+ if (message.statusCode && message.statusCode >= 200 && message.statusCode < 300) {
+ // The "Expires" header field in a 200-class response to SUBSCRIBE
+ // request indicates the actual duration for which the subscription will
+ // remain active (unless refreshed). The received value might be
+ // smaller than the value indicated in the SUBSCRIBE request but cannot
+ // be larger; see Section 4.2.1 for details.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.1
+ const expires = message.getHeader("Expires");
+ if (!expires) {
+ this.logger.warn("Expires header missing in a 200-class response to SUBSCRIBE");
+ }
+ else {
+ const subscriptionExpiresReceived = Number(expires);
+ if (this.dialog.subscriptionExpires > subscriptionExpiresReceived) {
+ this.dialog.subscriptionExpires = subscriptionExpiresReceived;
+ }
+ }
+ }
+ if (message.statusCode && message.statusCode >= 400 && message.statusCode < 700) {
+ // If a SUBSCRIBE request to refresh a subscription receives a 404, 405,
+ // 410, 416, 480-485, 489, 501, or 604 response, the subscriber MUST
+ // consider the subscription terminated. (See [RFC5057] for further
+ // details and notes about the effect of error codes on dialogs and
+ // usages within dialog, such as subscriptions). If the subscriber
+ // wishes to re-subscribe to the state, he does so by composing an
+ // unrelated initial SUBSCRIBE request with a freshly generated Call-ID
+ // and a new, unique "From" tag (see Section 4.1.2.1).
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.2
+ const errorCodes = [404, 405, 410, 416, 480, 481, 482, 483, 484, 485, 489, 501, 604];
+ if (errorCodes.includes(message.statusCode)) {
+ this.dialog.terminate();
+ }
+ // If a SUBSCRIBE request to refresh a subscription fails with any error
+ // code other than those listed above, the original subscription is
+ // still considered valid for the duration of the most recently known
+ // "Expires" value as negotiated by the most recent successful SUBSCRIBE
+ // transaction, or as communicated by a NOTIFY request in its
+ // "Subscription-State" header field "expires" parameter.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.2
+ }
+ super.receiveResponse(message);
+ }
+}
+
+
+/***/ }),
+/* 84 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _levels__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(85);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Levels", function() { return _levels__WEBPACK_IMPORTED_MODULE_0__["Levels"]; });
+
+/* harmony import */ var _logger_factory__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(86);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "LoggerFactory", function() { return _logger_factory__WEBPACK_IMPORTED_MODULE_1__["LoggerFactory"]; });
+
+/* harmony import */ var _logger__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(87);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Logger", function() { return _logger__WEBPACK_IMPORTED_MODULE_2__["Logger"]; });
+
+
+
+
+
+
+/***/ }),
+/* 85 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Levels", function() { return Levels; });
+/**
+ * Log levels.
+ * @public
+ */
+var Levels;
+(function (Levels) {
+ Levels[Levels["error"] = 0] = "error";
+ Levels[Levels["warn"] = 1] = "warn";
+ Levels[Levels["log"] = 2] = "log";
+ Levels[Levels["debug"] = 3] = "debug";
+})(Levels || (Levels = {}));
+
+
+/***/ }),
+/* 86 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "LoggerFactory", function() { return LoggerFactory; });
+/* harmony import */ var _levels__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(85);
+/* harmony import */ var _logger__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(87);
+
+
+/**
+ * Logger.
+ * @public
+ */
+class LoggerFactory {
+ constructor() {
+ this.builtinEnabled = true;
+ this._level = _levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].log;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ this.loggers = {};
+ this.logger = this.getLogger("sip:loggerfactory");
+ }
+ get level() {
+ return this._level;
+ }
+ set level(newLevel) {
+ if (newLevel >= 0 && newLevel <= 3) {
+ this._level = newLevel;
+ }
+ else if (newLevel > 3) {
+ this._level = 3;
+ // eslint-disable-next-line no-prototype-builtins
+ }
+ else if (_levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].hasOwnProperty(newLevel)) {
+ this._level = newLevel;
+ }
+ else {
+ this.logger.error("invalid 'level' parameter value: " + JSON.stringify(newLevel));
+ }
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ get connector() {
+ return this._connector;
+ }
+ set connector(value) {
+ if (!value) {
+ this._connector = undefined;
+ }
+ else if (typeof value === "function") {
+ this._connector = value;
+ }
+ else {
+ this.logger.error("invalid 'connector' parameter value: " + JSON.stringify(value));
+ }
+ }
+ getLogger(category, label) {
+ if (label && this.level === 3) {
+ return new _logger__WEBPACK_IMPORTED_MODULE_1__["Logger"](this, category, label);
+ }
+ else if (this.loggers[category]) {
+ return this.loggers[category];
+ }
+ else {
+ const logger = new _logger__WEBPACK_IMPORTED_MODULE_1__["Logger"](this, category);
+ this.loggers[category] = logger;
+ return logger;
+ }
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ genericLog(levelToLog, category, label, content) {
+ if (this.level >= levelToLog) {
+ if (this.builtinEnabled) {
+ this.print(levelToLog, category, label, content);
+ }
+ }
+ if (this.connector) {
+ this.connector(_levels__WEBPACK_IMPORTED_MODULE_0__["Levels"][levelToLog], category, label, content);
+ }
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ print(levelToLog, category, label, content) {
+ if (typeof content === "string") {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const prefix = [new Date(), category];
+ if (label) {
+ prefix.push(label);
+ }
+ content = prefix.concat(content).join(" | ");
+ }
+ switch (levelToLog) {
+ case _levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].error:
+ // eslint-disable-next-line no-console
+ console.error(content);
+ break;
+ case _levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].warn:
+ // eslint-disable-next-line no-console
+ console.warn(content);
+ break;
+ case _levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].log:
+ // eslint-disable-next-line no-console
+ console.log(content);
+ break;
+ case _levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].debug:
+ // eslint-disable-next-line no-console
+ console.debug(content);
+ break;
+ default:
+ break;
+ }
+ }
+}
+
+
+/***/ }),
+/* 87 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Logger", function() { return Logger; });
+/* harmony import */ var _levels__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(85);
+
+/**
+ * Logger.
+ * @public
+ */
+class Logger {
+ constructor(logger, category, label) {
+ this.logger = logger;
+ this.category = category;
+ this.label = label;
+ }
+ error(content) {
+ this.genericLog(_levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].error, content);
+ }
+ warn(content) {
+ this.genericLog(_levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].warn, content);
+ }
+ log(content) {
+ this.genericLog(_levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].log, content);
+ }
+ debug(content) {
+ this.genericLog(_levels__WEBPACK_IMPORTED_MODULE_0__["Levels"].debug, content);
+ }
+ genericLog(level, content) {
+ this.logger.genericLog(level, this.category, this.label, content);
+ }
+}
+
+
+/***/ }),
+/* 88 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _user_agent_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(89);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentCore", function() { return _user_agent_core__WEBPACK_IMPORTED_MODULE_0__["UserAgentCore"]; });
+
+/* harmony import */ var _user_agent_core_configuration__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(100);
+/* harmony import */ var _user_agent_core_configuration__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_user_agent_core_configuration__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _user_agent_core_configuration__WEBPACK_IMPORTED_MODULE_1__) if(["UserAgentCore","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _user_agent_core_configuration__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _user_agent_core_delegate__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(101);
+/* harmony import */ var _user_agent_core_delegate__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_user_agent_core_delegate__WEBPACK_IMPORTED_MODULE_2__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _user_agent_core_delegate__WEBPACK_IMPORTED_MODULE_2__) if(["UserAgentCore","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _user_agent_core_delegate__WEBPACK_IMPORTED_MODULE_2__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+
+
+
+
+
+/***/ }),
+/* 89 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "UserAgentCore", function() { return UserAgentCore; });
+/* harmony import */ var _messages__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(8);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(48);
+/* harmony import */ var _user_agents__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(90);
+/* harmony import */ var _allowed_methods__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(82);
+
+
+
+
+/**
+ * This is ported from UA.C.ACCEPTED_BODY_TYPES.
+ * FIXME: TODO: Should be configurable/variable.
+ */
+const acceptedBodyTypes = ["application/sdp", "application/dtmf-relay"];
+/**
+ * User Agent Core.
+ * @remarks
+ * Core designates the functions specific to a particular type
+ * of SIP entity, i.e., specific to either a stateful or stateless
+ * proxy, a user agent or registrar. All cores, except those for
+ * the stateless proxy, are transaction users.
+ * https://tools.ietf.org/html/rfc3261#section-6
+ *
+ * UAC Core: The set of processing functions required of a UAC that
+ * reside above the transaction and transport layers.
+ * https://tools.ietf.org/html/rfc3261#section-6
+ *
+ * UAS Core: The set of processing functions required at a UAS that
+ * resides above the transaction and transport layers.
+ * https://tools.ietf.org/html/rfc3261#section-6
+ * @public
+ */
+class UserAgentCore {
+ /**
+ * Constructor.
+ * @param configuration - Configuration.
+ * @param delegate - Delegate.
+ */
+ constructor(configuration, delegate = {}) {
+ /** UACs. */
+ this.userAgentClients = new Map();
+ /** UASs. */
+ this.userAgentServers = new Map();
+ this.configuration = configuration;
+ this.delegate = delegate;
+ this.dialogs = new Map();
+ this.subscribers = new Map();
+ this.logger = configuration.loggerFactory.getLogger("sip.user-agent-core");
+ }
+ /** Destructor. */
+ dispose() {
+ this.reset();
+ }
+ /** Reset. */
+ reset() {
+ this.dialogs.forEach((dialog) => dialog.dispose());
+ this.dialogs.clear();
+ this.subscribers.forEach((subscriber) => subscriber.dispose());
+ this.subscribers.clear();
+ this.userAgentClients.forEach((uac) => uac.dispose());
+ this.userAgentClients.clear();
+ this.userAgentServers.forEach((uac) => uac.dispose());
+ this.userAgentServers.clear();
+ }
+ /** Logger factory. */
+ get loggerFactory() {
+ return this.configuration.loggerFactory;
+ }
+ /** Transport. */
+ get transport() {
+ const transport = this.configuration.transportAccessor();
+ if (!transport) {
+ throw new Error("Transport undefined.");
+ }
+ return transport;
+ }
+ /**
+ * Send INVITE.
+ * @param request - Outgoing request.
+ * @param delegate - Request delegate.
+ */
+ invite(request, delegate) {
+ return new _user_agents__WEBPACK_IMPORTED_MODULE_2__["InviteUserAgentClient"](this, request, delegate);
+ }
+ /**
+ * Send MESSAGE.
+ * @param request - Outgoing request.
+ * @param delegate - Request delegate.
+ */
+ message(request, delegate) {
+ return new _user_agents__WEBPACK_IMPORTED_MODULE_2__["MessageUserAgentClient"](this, request, delegate);
+ }
+ /**
+ * Send PUBLISH.
+ * @param request - Outgoing request.
+ * @param delegate - Request delegate.
+ */
+ publish(request, delegate) {
+ return new _user_agents__WEBPACK_IMPORTED_MODULE_2__["PublishUserAgentClient"](this, request, delegate);
+ }
+ /**
+ * Send REGISTER.
+ * @param request - Outgoing request.
+ * @param delegate - Request delegate.
+ */
+ register(request, delegate) {
+ return new _user_agents__WEBPACK_IMPORTED_MODULE_2__["RegisterUserAgentClient"](this, request, delegate);
+ }
+ /**
+ * Send SUBSCRIBE.
+ * @param request - Outgoing request.
+ * @param delegate - Request delegate.
+ */
+ subscribe(request, delegate) {
+ return new _user_agents__WEBPACK_IMPORTED_MODULE_2__["SubscribeUserAgentClient"](this, request, delegate);
+ }
+ /**
+ * Send a request.
+ * @param request - Outgoing request.
+ * @param delegate - Request delegate.
+ */
+ request(request, delegate) {
+ return new _user_agents__WEBPACK_IMPORTED_MODULE_2__["UserAgentClient"](_transactions__WEBPACK_IMPORTED_MODULE_1__["NonInviteClientTransaction"], this, request, delegate);
+ }
+ /**
+ * Outgoing request message factory function.
+ * @param method - Method.
+ * @param requestURI - Request-URI.
+ * @param fromURI - From URI.
+ * @param toURI - To URI.
+ * @param options - Request options.
+ * @param extraHeaders - Extra headers to add.
+ * @param body - Message body.
+ */
+ makeOutgoingRequestMessage(method, requestURI, fromURI, toURI, options, extraHeaders, body) {
+ // default values from user agent configuration
+ const callIdPrefix = this.configuration.sipjsId;
+ const fromDisplayName = this.configuration.displayName;
+ const forceRport = this.configuration.viaForceRport;
+ const hackViaTcp = this.configuration.hackViaTcp;
+ const hackViaWs = this.configuration.hackViaWs;
+ const optionTags = this.configuration.supportedOptionTags.slice();
+ if (method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].REGISTER) {
+ optionTags.push("path", "gruu");
+ }
+ if (method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE && (this.configuration.contact.pubGruu || this.configuration.contact.tempGruu)) {
+ optionTags.push("gruu");
+ }
+ const routeSet = this.configuration.routeSet;
+ const userAgentString = this.configuration.userAgentHeaderFieldValue;
+ const viaHost = this.configuration.viaHost;
+ const defaultOptions = {
+ callIdPrefix,
+ forceRport,
+ fromDisplayName,
+ hackViaTcp,
+ hackViaWs,
+ optionTags,
+ routeSet,
+ userAgentString,
+ viaHost
+ };
+ // merge provided options with default options
+ const requestOptions = Object.assign(Object.assign({}, defaultOptions), options);
+ return new _messages__WEBPACK_IMPORTED_MODULE_0__["OutgoingRequestMessage"](method, requestURI, fromURI, toURI, requestOptions, extraHeaders, body);
+ }
+ /**
+ * Handle an incoming request message from the transport.
+ * @param message - Incoming request message from transport layer.
+ */
+ receiveIncomingRequestFromTransport(message) {
+ this.receiveRequestFromTransport(message);
+ }
+ /**
+ * Handle an incoming response message from the transport.
+ * @param message - Incoming response message from transport layer.
+ */
+ receiveIncomingResponseFromTransport(message) {
+ this.receiveResponseFromTransport(message);
+ }
+ /**
+ * A stateless UAS is a UAS that does not maintain transaction state.
+ * It replies to requests normally, but discards any state that would
+ * ordinarily be retained by a UAS after a response has been sent. If a
+ * stateless UAS receives a retransmission of a request, it regenerates
+ * the response and re-sends it, just as if it were replying to the first
+ * instance of the request. A UAS cannot be stateless unless the request
+ * processing for that method would always result in the same response
+ * if the requests are identical. This rules out stateless registrars,
+ * for example. Stateless UASs do not use a transaction layer; they
+ * receive requests directly from the transport layer and send responses
+ * directly to the transport layer.
+ * https://tools.ietf.org/html/rfc3261#section-8.2.7
+ * @param message - Incoming request message to reply to.
+ * @param statusCode - Status code to reply with.
+ */
+ replyStateless(message, options) {
+ const userAgent = this.configuration.userAgentHeaderFieldValue;
+ const supported = this.configuration.supportedOptionTagsResponse;
+ options = Object.assign(Object.assign({}, options), { userAgent, supported });
+ const response = Object(_messages__WEBPACK_IMPORTED_MODULE_0__["constructOutgoingResponse"])(message, options);
+ this.transport.send(response.message).catch((error) => {
+ // If the transport rejects, it SHOULD reject with a TransportError.
+ // But the transport may be external code, so we are careful...
+ if (error instanceof Error) {
+ this.logger.error(error.message);
+ }
+ this.logger.error(`Transport error occurred sending stateless reply to ${message.method} request.`);
+ // TODO: Currently there is no hook to provide notification that a transport error occurred
+ // and throwing would result in an uncaught error (in promise), so we silently eat the error.
+ // Furthermore, silently eating stateless reply transport errors is arguably what we want to do here.
+ });
+ return response;
+ }
+ /**
+ * In Section 18.2.1, replace the last paragraph with:
+ *
+ * Next, the server transport attempts to match the request to a
+ * server transaction. It does so using the matching rules described
+ * in Section 17.2.3. If a matching server transaction is found, the
+ * request is passed to that transaction for processing. If no match
+ * is found, the request is passed to the core, which may decide to
+ * construct a new server transaction for that request.
+ * https://tools.ietf.org/html/rfc6026#section-8.10
+ * @param message - Incoming request message from transport layer.
+ */
+ receiveRequestFromTransport(message) {
+ // When a request is received from the network by the server, it has to
+ // be matched to an existing transaction. This is accomplished in the
+ // following manner.
+ //
+ // The branch parameter in the topmost Via header field of the request
+ // is examined. If it is present and begins with the magic cookie
+ // "z9hG4bK", the request was generated by a client transaction
+ // compliant to this specification. Therefore, the branch parameter
+ // will be unique across all transactions sent by that client. The
+ // request matches a transaction if:
+ //
+ // 1. the branch parameter in the request is equal to the one in the
+ // top Via header field of the request that created the
+ // transaction, and
+ //
+ // 2. the sent-by value in the top Via of the request is equal to the
+ // one in the request that created the transaction, and
+ //
+ // 3. the method of the request matches the one that created the
+ // transaction, except for ACK, where the method of the request
+ // that created the transaction is INVITE.
+ //
+ // This matching rule applies to both INVITE and non-INVITE transactions
+ // alike.
+ //
+ // The sent-by value is used as part of the matching process because
+ // there could be accidental or malicious duplication of branch
+ // parameters from different clients.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.3
+ const transactionId = message.viaBranch; // FIXME: Currently only using rule 1...
+ const uas = this.userAgentServers.get(transactionId);
+ // When receiving an ACK that matches an existing INVITE server
+ // transaction and that does not contain a branch parameter containing
+ // the magic cookie defined in RFC 3261, the matching transaction MUST
+ // be checked to see if it is in the "Accepted" state. If it is, then
+ // the ACK must be passed directly to the transaction user instead of
+ // being absorbed by the transaction state machine. This is necessary
+ // as requests from RFC 2543 clients will not include a unique branch
+ // parameter, and the mechanisms for calculating the transaction ID from
+ // such a request will be the same for both INVITE and ACKs.
+ // https://tools.ietf.org/html/rfc6026#section-6
+ // Any ACKs received from the network while in the "Accepted" state MUST be
+ // passed directly to the TU and not absorbed.
+ // https://tools.ietf.org/html/rfc6026#section-7.1
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ if (uas && uas.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_1__["TransactionState"].Accepted) {
+ if (uas instanceof _user_agents__WEBPACK_IMPORTED_MODULE_2__["InviteUserAgentServer"]) {
+ // These are ACKs matching an INVITE server transaction.
+ // These should never happen with RFC 3261 compliant user agents
+ // (would be a broken ACK to negative final response or something)
+ // but is apparently how RFC 2543 user agents do things.
+ // We are not currently supporting this case.
+ // NOTE: Not backwards compatible with RFC 2543 (no support for strict-routing).
+ this.logger.warn(`Discarding out of dialog ACK after 2xx response sent on transaction ${transactionId}.`);
+ return;
+ }
+ }
+ }
+ // The CANCEL method requests that the TU at the server side cancel a
+ // pending transaction. The TU determines the transaction to be
+ // cancelled by taking the CANCEL request, and then assuming that the
+ // request method is anything but CANCEL or ACK and applying the
+ // transaction matching procedures of Section 17.2.3. The matching
+ // transaction is the one to be cancelled.
+ // https://tools.ietf.org/html/rfc3261#section-9.2
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].CANCEL) {
+ if (uas) {
+ // Regardless of the method of the original request, as long as the
+ // CANCEL matched an existing transaction, the UAS answers the CANCEL
+ // request itself with a 200 (OK) response.
+ // https://tools.ietf.org/html/rfc3261#section-9.2
+ this.replyStateless(message, { statusCode: 200 });
+ // If the transaction for the original request still exists, the behavior
+ // of the UAS on receiving a CANCEL request depends on whether it has already
+ // sent a final response for the original request. If it has, the CANCEL
+ // request has no effect on the processing of the original request, no
+ // effect on any session state, and no effect on the responses generated
+ // for the original request. If the UAS has not issued a final response
+ // for the original request, its behavior depends on the method of the
+ // original request. If the original request was an INVITE, the UAS
+ // SHOULD immediately respond to the INVITE with a 487 (Request
+ // Terminated).
+ // https://tools.ietf.org/html/rfc3261#section-9.2
+ if (uas.transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_1__["InviteServerTransaction"] &&
+ uas.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_1__["TransactionState"].Proceeding) {
+ if (uas instanceof _user_agents__WEBPACK_IMPORTED_MODULE_2__["InviteUserAgentServer"]) {
+ uas.receiveCancel(message);
+ }
+ // A CANCEL request has no impact on the processing of
+ // transactions with any other method defined in this specification.
+ // https://tools.ietf.org/html/rfc3261#section-9.2
+ }
+ }
+ else {
+ // If the UAS did not find a matching transaction for the CANCEL
+ // according to the procedure above, it SHOULD respond to the CANCEL
+ // with a 481 (Call Leg/Transaction Does Not Exist).
+ // https://tools.ietf.org/html/rfc3261#section-9.2
+ this.replyStateless(message, { statusCode: 481 });
+ }
+ return;
+ }
+ // If a matching server transaction is found, the request is passed to that
+ // transaction for processing.
+ // https://tools.ietf.org/html/rfc6026#section-8.10
+ if (uas) {
+ uas.transaction.receiveRequest(message);
+ return;
+ }
+ // If no match is found, the request is passed to the core, which may decide to
+ // construct a new server transaction for that request.
+ // https://tools.ietf.org/html/rfc6026#section-8.10
+ this.receiveRequest(message);
+ return;
+ }
+ /**
+ * UAC and UAS procedures depend strongly on two factors. First, based
+ * on whether the request or response is inside or outside of a dialog,
+ * and second, based on the method of a request. Dialogs are discussed
+ * thoroughly in Section 12; they represent a peer-to-peer relationship
+ * between user agents and are established by specific SIP methods, such
+ * as INVITE.
+ * @param message - Incoming request message.
+ */
+ receiveRequest(message) {
+ // 8.2 UAS Behavior
+ // UASs SHOULD process the requests in the order of the steps that
+ // follow in this section (that is, starting with authentication, then
+ // inspecting the method, the header fields, and so on throughout the
+ // remainder of this section).
+ // https://tools.ietf.org/html/rfc3261#section-8.2
+ // 8.2.1 Method Inspection
+ // Once a request is authenticated (or authentication is skipped), the
+ // UAS MUST inspect the method of the request. If the UAS recognizes
+ // but does not support the method of a request, it MUST generate a 405
+ // (Method Not Allowed) response. Procedures for generating responses
+ // are described in Section 8.2.6. The UAS MUST also add an Allow
+ // header field to the 405 (Method Not Allowed) response. The Allow
+ // header field MUST list the set of methods supported by the UAS
+ // generating the message.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.1
+ if (!_allowed_methods__WEBPACK_IMPORTED_MODULE_3__["AllowedMethods"].includes(message.method)) {
+ const allowHeader = "Allow: " + _allowed_methods__WEBPACK_IMPORTED_MODULE_3__["AllowedMethods"].toString();
+ this.replyStateless(message, {
+ statusCode: 405,
+ extraHeaders: [allowHeader]
+ });
+ return;
+ }
+ // 8.2.2 Header Inspection
+ // https://tools.ietf.org/html/rfc3261#section-8.2.2
+ if (!message.ruri) {
+ // FIXME: A request message should always have an ruri
+ throw new Error("Request-URI undefined.");
+ }
+ // 8.2.2.1 To and Request-URI
+ // If the Request-URI uses a scheme not supported by the UAS, it SHOULD
+ // reject the request with a 416 (Unsupported URI Scheme) response.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.2.1
+ if (message.ruri.scheme !== "sip") {
+ this.replyStateless(message, { statusCode: 416 });
+ return;
+ }
+ // 8.2.2.1 To and Request-URI
+ // If the Request-URI does not identify an address that the
+ // UAS is willing to accept requests for, it SHOULD reject
+ // the request with a 404 (Not Found) response.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.2.1
+ const ruri = message.ruri;
+ const ruriMatches = (uri) => {
+ return !!uri && uri.user === ruri.user;
+ };
+ if (!ruriMatches(this.configuration.aor) &&
+ !(ruriMatches(this.configuration.contact.uri) ||
+ ruriMatches(this.configuration.contact.pubGruu) ||
+ ruriMatches(this.configuration.contact.tempGruu))) {
+ this.logger.warn("Request-URI does not point to us.");
+ if (message.method !== _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ this.replyStateless(message, { statusCode: 404 });
+ }
+ return;
+ }
+ // 8.2.2.1 To and Request-URI
+ // Other potential sources of received Request-URIs include
+ // the Contact header fields of requests and responses sent by the UA
+ // that establish or refresh dialogs.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.2.1
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE) {
+ if (!message.hasHeader("Contact")) {
+ this.replyStateless(message, {
+ statusCode: 400,
+ reasonPhrase: "Missing Contact Header"
+ });
+ return;
+ }
+ }
+ // 8.2.2.2 Merged Requests
+ // If the request has no tag in the To header field, the UAS core MUST
+ // check the request against ongoing transactions. If the From tag,
+ // Call-ID, and CSeq exactly match those associated with an ongoing
+ // transaction, but the request does not match that transaction (based
+ // on the matching rules in Section 17.2.3), the UAS core SHOULD
+ // generate a 482 (Loop Detected) response and pass it to the server
+ // transaction.
+ //
+ // The same request has arrived at the UAS more than once, following
+ // different paths, most likely due to forking. The UAS processes
+ // the first such request received and responds with a 482 (Loop
+ // Detected) to the rest of them.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.2.2
+ if (!message.toTag) {
+ const transactionId = message.viaBranch;
+ if (!this.userAgentServers.has(transactionId)) {
+ const mergedRequest = Array.from(this.userAgentServers.values()).some((uas) => uas.transaction.request.fromTag === message.fromTag &&
+ uas.transaction.request.callId === message.callId &&
+ uas.transaction.request.cseq === message.cseq);
+ if (mergedRequest) {
+ this.replyStateless(message, { statusCode: 482 });
+ return;
+ }
+ }
+ }
+ // 8.2.2.3 Require
+ // https://tools.ietf.org/html/rfc3261#section-8.2.2.3
+ // TODO
+ // 8.2.3 Content Processing
+ // https://tools.ietf.org/html/rfc3261#section-8.2.3
+ // TODO
+ // 8.2.4 Applying Extensions
+ // https://tools.ietf.org/html/rfc3261#section-8.2.4
+ // TODO
+ // 8.2.5 Processing the Request
+ // Assuming all of the checks in the previous subsections are passed,
+ // the UAS processing becomes method-specific.
+ // https://tools.ietf.org/html/rfc3261#section-8.2.5
+ // The UAS will receive the request from the transaction layer. If the
+ // request has a tag in the To header field, the UAS core computes the
+ // dialog identifier corresponding to the request and compares it with
+ // existing dialogs. If there is a match, this is a mid-dialog request.
+ // In that case, the UAS first applies the same processing rules for
+ // requests outside of a dialog, discussed in Section 8.2.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ if (message.toTag) {
+ this.receiveInsideDialogRequest(message);
+ }
+ else {
+ this.receiveOutsideDialogRequest(message);
+ }
+ return;
+ }
+ /**
+ * Once a dialog has been established between two UAs, either of them
+ * MAY initiate new transactions as needed within the dialog. The UA
+ * sending the request will take the UAC role for the transaction. The
+ * UA receiving the request will take the UAS role. Note that these may
+ * be different roles than the UAs held during the transaction that
+ * established the dialog.
+ * https://tools.ietf.org/html/rfc3261#section-12.2
+ * @param message - Incoming request message.
+ */
+ receiveInsideDialogRequest(message) {
+ // NOTIFY requests are matched to such SUBSCRIBE requests if they
+ // contain the same "Call-ID", a "To" header field "tag" parameter that
+ // matches the "From" header field "tag" parameter of the SUBSCRIBE
+ // request, and the same "Event" header field. Rules for comparisons of
+ // the "Event" header fields are described in Section 8.2.1.
+ // https://tools.ietf.org/html/rfc6665#section-4.4.1
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].NOTIFY) {
+ const event = message.parseHeader("Event");
+ if (!event || !event.event) {
+ this.replyStateless(message, { statusCode: 489 });
+ return;
+ }
+ // FIXME: Subscriber id should also matching on event id.
+ const subscriberId = message.callId + message.toTag + event.event;
+ const subscriber = this.subscribers.get(subscriberId);
+ if (subscriber) {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["NotifyUserAgentServer"](this, message);
+ subscriber.onNotify(uas);
+ return;
+ }
+ }
+ // Requests sent within a dialog, as any other requests, are atomic. If
+ // a particular request is accepted by the UAS, all the state changes
+ // associated with it are performed. If the request is rejected, none
+ // of the state changes are performed.
+ //
+ // Note that some requests, such as INVITEs, affect several pieces of
+ // state.
+ //
+ // The UAS will receive the request from the transaction layer. If the
+ // request has a tag in the To header field, the UAS core computes the
+ // dialog identifier corresponding to the request and compares it with
+ // existing dialogs. If there is a match, this is a mid-dialog request.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ const dialogId = message.callId + message.toTag + message.fromTag;
+ const dialog = this.dialogs.get(dialogId);
+ if (dialog) {
+ // [Sip-implementors] Reg. SIP reinvite, UPDATE and OPTIONS
+ // You got the question right.
+ //
+ // And you got the right answer too. :-)
+ //
+ // Thanks,
+ // Paul
+ //
+ // Robert Sparks wrote:
+ // > So I've lost track of the question during the musing.
+ // >
+ // > I _think_ the fundamental question being asked is this:
+ // >
+ // > Is an endpoint required to reject (with a 481) an OPTIONS request that
+ // > arrives with at to-tag but does not match any existing dialog state.
+ // > (Assuming some earlier requirement hasn't forced another error code). Or
+ // > is it OK if it just sends
+ // > a 200 OK anyhow.
+ // >
+ // > My take on the collection of specs is that its _not_ ok for it to send
+ // > the 200 OK anyhow and that it is required to send
+ // > the 481. I base this primarily on these sentences from 11.2 in 3261:
+ // >
+ // > The response to an OPTIONS is constructed using the standard rules
+ // > for a SIP response as discussed in Section 8.2.6. The response code
+ // > chosen MUST be the same that would have been chosen had the request
+ // > been an INVITE.
+ // >
+ // > Did I miss the point of the question?
+ // >
+ // > On May 15, 2008, at 12:48 PM, Paul Kyzivat wrote:
+ // >
+ // >> [Including Robert in hopes of getting his insight on this.]
+ // https://lists.cs.columbia.edu/pipermail/sip-implementors/2008-May/019178.html
+ //
+ // Requests that do not change in any way the state of a dialog may be
+ // received within a dialog (for example, an OPTIONS request). They are
+ // processed as if they had been received outside the dialog.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].OPTIONS) {
+ const allowHeader = "Allow: " + _allowed_methods__WEBPACK_IMPORTED_MODULE_3__["AllowedMethods"].toString();
+ const acceptHeader = "Accept: " + acceptedBodyTypes.toString();
+ this.replyStateless(message, {
+ statusCode: 200,
+ extraHeaders: [allowHeader, acceptHeader]
+ });
+ return;
+ }
+ // Pass the incoming request to the dialog for further handling.
+ dialog.receiveRequest(message);
+ return;
+ }
+ // The most important behaviors of a stateless UAS are the following:
+ // ...
+ // o A stateless UAS MUST ignore ACK requests.
+ // ...
+ // https://tools.ietf.org/html/rfc3261#section-8.2.7
+ if (message.method === _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK) {
+ // If a final response to an INVITE was sent statelessly,
+ // the corresponding ACK:
+ // - will not match an existing transaction
+ // - may have tag in the To header field
+ // - not not match any existing dialogs
+ // Absorb unmatched ACKs.
+ return;
+ }
+ // If the request has a tag in the To header field, but the dialog
+ // identifier does not match any existing dialogs, the UAS may have
+ // crashed and restarted, or it may have received a request for a
+ // different (possibly failed) UAS (the UASs can construct the To tags
+ // so that a UAS can identify that the tag was for a UAS for which it is
+ // providing recovery). Another possibility is that the incoming
+ // request has been simply mis-routed. Based on the To tag, the UAS MAY
+ // either accept or reject the request. Accepting the request for
+ // acceptable To tags provides robustness, so that dialogs can persist
+ // even through crashes. UAs wishing to support this capability must
+ // take into consideration some issues such as choosing monotonically
+ // increasing CSeq sequence numbers even across reboots, reconstructing
+ // the route set, and accepting out-of-range RTP timestamps and sequence
+ // numbers.
+ //
+ // If the UAS wishes to reject the request because it does not wish to
+ // recreate the dialog, it MUST respond to the request with a 481
+ // (Call/Transaction Does Not Exist) status code and pass that to the
+ // server transaction.
+ // https://tools.ietf.org/html/rfc3261#section-12.2.2
+ this.replyStateless(message, { statusCode: 481 });
+ return;
+ }
+ /**
+ * Assuming all of the checks in the previous subsections are passed,
+ * the UAS processing becomes method-specific.
+ * https://tools.ietf.org/html/rfc3261#section-8.2.5
+ * @param message - Incoming request message.
+ */
+ receiveOutsideDialogRequest(message) {
+ switch (message.method) {
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].ACK:
+ // Absorb stray out of dialog ACKs
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].BYE:
+ // If the BYE does not match an existing dialog, the UAS core SHOULD
+ // generate a 481 (Call/Transaction Does Not Exist) response and pass
+ // that to the server transaction. This rule means that a BYE sent
+ // without tags by a UAC will be rejected.
+ // https://tools.ietf.org/html/rfc3261#section-15.1.2
+ this.replyStateless(message, { statusCode: 481 });
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].CANCEL:
+ throw new Error(`Unexpected out of dialog request method ${message.method}.`);
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INFO:
+ // Use of the INFO method does not constitute a separate dialog usage.
+ // INFO messages are always part of, and share the fate of, an invite
+ // dialog usage [RFC5057]. INFO messages cannot be sent as part of
+ // other dialog usages, or outside an existing dialog.
+ // https://tools.ietf.org/html/rfc6086#section-1
+ this.replyStateless(message, { statusCode: 405 }); // Should never happen
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE:
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1
+ {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["InviteUserAgentServer"](this, message);
+ this.delegate.onInvite ? this.delegate.onInvite(uas) : uas.reject();
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].MESSAGE:
+ // MESSAGE requests are discouraged inside a dialog. Implementations
+ // are restricted from creating a usage for the purpose of carrying a
+ // sequence of MESSAGE requests (though some implementations use it that
+ // way, against the standard recommendation).
+ // https://tools.ietf.org/html/rfc5057#section-5.3
+ {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["MessageUserAgentServer"](this, message);
+ this.delegate.onMessage ? this.delegate.onMessage(uas) : uas.accept();
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].NOTIFY:
+ // Obsoleted by: RFC 6665
+ // If any non-SUBSCRIBE mechanisms are defined to create subscriptions,
+ // it is the responsibility of the parties defining those mechanisms to
+ // ensure that correlation of a NOTIFY message to the corresponding
+ // subscription is possible. Designers of such mechanisms are also
+ // warned to make a distinction between sending a NOTIFY message to a
+ // subscriber who is aware of the subscription, and sending a NOTIFY
+ // message to an unsuspecting node. The latter behavior is invalid, and
+ // MUST receive a "481 Subscription does not exist" response (unless
+ // some other 400- or 500-class error code is more applicable), as
+ // described in section 3.2.4. In other words, knowledge of a
+ // subscription must exist in both the subscriber and the notifier to be
+ // valid, even if installed via a non-SUBSCRIBE mechanism.
+ // https://tools.ietf.org/html/rfc3265#section-3.2
+ //
+ // NOTIFY requests are sent to inform subscribers of changes in state to
+ // which the subscriber has a subscription. Subscriptions are created
+ // using the SUBSCRIBE method. In legacy implementations, it is
+ // possible that other means of subscription creation have been used.
+ // However, this specification does not allow the creation of
+ // subscriptions except through SUBSCRIBE requests and (for backwards-
+ // compatibility) REFER requests [RFC3515].
+ // https://tools.ietf.org/html/rfc6665#section-3.2
+ {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["NotifyUserAgentServer"](this, message);
+ this.delegate.onNotify ? this.delegate.onNotify(uas) : uas.reject({ statusCode: 405 });
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].OPTIONS:
+ // https://tools.ietf.org/html/rfc3261#section-11.2
+ {
+ const allowHeader = "Allow: " + _allowed_methods__WEBPACK_IMPORTED_MODULE_3__["AllowedMethods"].toString();
+ const acceptHeader = "Accept: " + acceptedBodyTypes.toString();
+ this.replyStateless(message, {
+ statusCode: 200,
+ extraHeaders: [allowHeader, acceptHeader]
+ });
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].REFER:
+ // https://tools.ietf.org/html/rfc3515#section-2.4.2
+ {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["ReferUserAgentServer"](this, message);
+ this.delegate.onRefer ? this.delegate.onRefer(uas) : uas.reject({ statusCode: 405 });
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].REGISTER:
+ // https://tools.ietf.org/html/rfc3261#section-10.3
+ {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["RegisterUserAgentServer"](this, message);
+ this.delegate.onRegister ? this.delegate.onRegister(uas) : uas.reject({ statusCode: 405 });
+ }
+ break;
+ case _messages__WEBPACK_IMPORTED_MODULE_0__["C"].SUBSCRIBE:
+ // https://tools.ietf.org/html/rfc6665#section-4.2
+ {
+ const uas = new _user_agents__WEBPACK_IMPORTED_MODULE_2__["SubscribeUserAgentServer"](this, message);
+ this.delegate.onSubscribe ? this.delegate.onSubscribe(uas) : uas.reject({ statusCode: 480 });
+ }
+ break;
+ default:
+ throw new Error(`Unexpected out of dialog request method ${message.method}.`);
+ }
+ return;
+ }
+ /**
+ * Responses are first processed by the transport layer and then passed
+ * up to the transaction layer. The transaction layer performs its
+ * processing and then passes the response up to the TU. The majority
+ * of response processing in the TU is method specific. However, there
+ * are some general behaviors independent of the method.
+ * https://tools.ietf.org/html/rfc3261#section-8.1.3
+ * @param message - Incoming response message from transport layer.
+ */
+ receiveResponseFromTransport(message) {
+ // 8.1.3.1 Transaction Layer Errors
+ // https://tools.ietf.org/html/rfc3261#section-8.1.3.1
+ // Handled by transaction layer callbacks.
+ // 8.1.3.2 Unrecognized Responses
+ // https://tools.ietf.org/html/rfc3261#section-8.1.3.1
+ // TODO
+ // 8.1.3.3 Vias
+ // https://tools.ietf.org/html/rfc3261#section-8.1.3.3
+ if (message.getHeaders("via").length > 1) {
+ this.logger.warn("More than one Via header field present in the response, dropping");
+ return;
+ }
+ // 8.1.3.4 Processing 3xx Responses
+ // https://tools.ietf.org/html/rfc3261#section-8.1.3.4
+ // TODO
+ // 8.1.3.5 Processing 4xx Responses
+ // https://tools.ietf.org/html/rfc3261#section-8.1.3.5
+ // TODO
+ // When the transport layer in the client receives a response, it has to
+ // determine which client transaction will handle the response, so that
+ // the processing of Sections 17.1.1 and 17.1.2 can take place. The
+ // branch parameter in the top Via header field is used for this
+ // purpose. A response matches a client transaction under two
+ // conditions:
+ //
+ // 1. If the response has the same value of the branch parameter in
+ // the top Via header field as the branch parameter in the top
+ // Via header field of the request that created the transaction.
+ //
+ // 2. If the method parameter in the CSeq header field matches the
+ // method of the request that created the transaction. The
+ // method is needed since a CANCEL request constitutes a
+ // different transaction, but shares the same value of the branch
+ // parameter.
+ // https://tools.ietf.org/html/rfc3261#section-17.1.3
+ const userAgentClientId = message.viaBranch + message.method;
+ const userAgentClient = this.userAgentClients.get(userAgentClientId);
+ // The client transport uses the matching procedures of Section
+ // 17.1.3 to attempt to match the response to an existing
+ // transaction. If there is a match, the response MUST be passed to
+ // that transaction. Otherwise, any element other than a stateless
+ // proxy MUST silently discard the response.
+ // https://tools.ietf.org/html/rfc6026#section-8.9
+ if (userAgentClient) {
+ userAgentClient.transaction.receiveResponse(message);
+ }
+ else {
+ this.logger.warn(`Discarding unmatched ${message.statusCode} response to ${message.method} ${userAgentClientId}.`);
+ }
+ }
+}
+
+
+/***/ }),
+/* 90 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _bye_user_agent_client__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(62);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ByeUserAgentClient", function() { return _bye_user_agent_client__WEBPACK_IMPORTED_MODULE_0__["ByeUserAgentClient"]; });
+
+/* harmony import */ var _bye_user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(64);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ByeUserAgentServer", function() { return _bye_user_agent_server__WEBPACK_IMPORTED_MODULE_1__["ByeUserAgentServer"]; });
+
+/* harmony import */ var _cancel_user_agent_client__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(91);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "CancelUserAgentClient", function() { return _cancel_user_agent_client__WEBPACK_IMPORTED_MODULE_2__["CancelUserAgentClient"]; });
+
+/* harmony import */ var _info_user_agent_client__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(66);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InfoUserAgentClient", function() { return _info_user_agent_client__WEBPACK_IMPORTED_MODULE_3__["InfoUserAgentClient"]; });
+
+/* harmony import */ var _info_user_agent_server__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(67);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InfoUserAgentServer", function() { return _info_user_agent_server__WEBPACK_IMPORTED_MODULE_4__["InfoUserAgentServer"]; });
+
+/* harmony import */ var _invite_user_agent_client__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(92);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InviteUserAgentClient", function() { return _invite_user_agent_client__WEBPACK_IMPORTED_MODULE_5__["InviteUserAgentClient"]; });
+
+/* harmony import */ var _invite_user_agent_server__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(93);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "InviteUserAgentServer", function() { return _invite_user_agent_server__WEBPACK_IMPORTED_MODULE_6__["InviteUserAgentServer"]; });
+
+/* harmony import */ var _message_user_agent_client__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(68);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "MessageUserAgentClient", function() { return _message_user_agent_client__WEBPACK_IMPORTED_MODULE_7__["MessageUserAgentClient"]; });
+
+/* harmony import */ var _message_user_agent_server__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(69);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "MessageUserAgentServer", function() { return _message_user_agent_server__WEBPACK_IMPORTED_MODULE_8__["MessageUserAgentServer"]; });
+
+/* harmony import */ var _notify_user_agent_client__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(70);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NotifyUserAgentClient", function() { return _notify_user_agent_client__WEBPACK_IMPORTED_MODULE_9__["NotifyUserAgentClient"]; });
+
+/* harmony import */ var _notify_user_agent_server__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(71);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "NotifyUserAgentServer", function() { return _notify_user_agent_server__WEBPACK_IMPORTED_MODULE_10__["NotifyUserAgentServer"]; });
+
+/* harmony import */ var _publish_user_agent_client__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(94);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PublishUserAgentClient", function() { return _publish_user_agent_client__WEBPACK_IMPORTED_MODULE_11__["PublishUserAgentClient"]; });
+
+/* harmony import */ var _prack_user_agent_client__WEBPACK_IMPORTED_MODULE_12__ = __webpack_require__(72);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PrackUserAgentClient", function() { return _prack_user_agent_client__WEBPACK_IMPORTED_MODULE_12__["PrackUserAgentClient"]; });
+
+/* harmony import */ var _prack_user_agent_server__WEBPACK_IMPORTED_MODULE_13__ = __webpack_require__(73);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "PrackUserAgentServer", function() { return _prack_user_agent_server__WEBPACK_IMPORTED_MODULE_13__["PrackUserAgentServer"]; });
+
+/* harmony import */ var _re_invite_user_agent_client__WEBPACK_IMPORTED_MODULE_14__ = __webpack_require__(74);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReInviteUserAgentClient", function() { return _re_invite_user_agent_client__WEBPACK_IMPORTED_MODULE_14__["ReInviteUserAgentClient"]; });
+
+/* harmony import */ var _re_invite_user_agent_server__WEBPACK_IMPORTED_MODULE_15__ = __webpack_require__(75);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReInviteUserAgentServer", function() { return _re_invite_user_agent_server__WEBPACK_IMPORTED_MODULE_15__["ReInviteUserAgentServer"]; });
+
+/* harmony import */ var _re_subscribe_user_agent_client__WEBPACK_IMPORTED_MODULE_16__ = __webpack_require__(83);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReSubscribeUserAgentClient", function() { return _re_subscribe_user_agent_client__WEBPACK_IMPORTED_MODULE_16__["ReSubscribeUserAgentClient"]; });
+
+/* harmony import */ var _re_subscribe_user_agent_server__WEBPACK_IMPORTED_MODULE_17__ = __webpack_require__(95);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReSubscribeUserAgentServer", function() { return _re_subscribe_user_agent_server__WEBPACK_IMPORTED_MODULE_17__["ReSubscribeUserAgentServer"]; });
+
+/* harmony import */ var _refer_user_agent_client__WEBPACK_IMPORTED_MODULE_18__ = __webpack_require__(76);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReferUserAgentClient", function() { return _refer_user_agent_client__WEBPACK_IMPORTED_MODULE_18__["ReferUserAgentClient"]; });
+
+/* harmony import */ var _refer_user_agent_server__WEBPACK_IMPORTED_MODULE_19__ = __webpack_require__(77);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "ReferUserAgentServer", function() { return _refer_user_agent_server__WEBPACK_IMPORTED_MODULE_19__["ReferUserAgentServer"]; });
+
+/* harmony import */ var _register_user_agent_client__WEBPACK_IMPORTED_MODULE_20__ = __webpack_require__(96);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RegisterUserAgentClient", function() { return _register_user_agent_client__WEBPACK_IMPORTED_MODULE_20__["RegisterUserAgentClient"]; });
+
+/* harmony import */ var _register_user_agent_server__WEBPACK_IMPORTED_MODULE_21__ = __webpack_require__(97);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "RegisterUserAgentServer", function() { return _register_user_agent_server__WEBPACK_IMPORTED_MODULE_21__["RegisterUserAgentServer"]; });
+
+/* harmony import */ var _subscribe_user_agent_client__WEBPACK_IMPORTED_MODULE_22__ = __webpack_require__(98);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscribeUserAgentClient", function() { return _subscribe_user_agent_client__WEBPACK_IMPORTED_MODULE_22__["SubscribeUserAgentClient"]; });
+
+/* harmony import */ var _subscribe_user_agent_server__WEBPACK_IMPORTED_MODULE_23__ = __webpack_require__(99);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SubscribeUserAgentServer", function() { return _subscribe_user_agent_server__WEBPACK_IMPORTED_MODULE_23__["SubscribeUserAgentServer"]; });
+
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_24__ = __webpack_require__(63);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentClient", function() { return _user_agent_client__WEBPACK_IMPORTED_MODULE_24__["UserAgentClient"]; });
+
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_25__ = __webpack_require__(65);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "UserAgentServer", function() { return _user_agent_server__WEBPACK_IMPORTED_MODULE_25__["UserAgentServer"]; });
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+/***/ }),
+/* 91 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "CancelUserAgentClient", function() { return CancelUserAgentClient; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(63);
+
+
+/**
+ * CANCEL UAC.
+ * @public
+ */
+class CancelUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_1__["UserAgentClient"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteClientTransaction"], core, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 92 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "InviteUserAgentClient", function() { return InviteUserAgentClient; });
+/* harmony import */ var _dialogs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(6);
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(44);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(63);
+
+
+
+
+/**
+ * INVITE UAC.
+ * @remarks
+ * 13 Initiating a Session
+ * https://tools.ietf.org/html/rfc3261#section-13
+ * 13.1 Overview
+ * https://tools.ietf.org/html/rfc3261#section-13.1
+ * 13.2 UAC Processing
+ * https://tools.ietf.org/html/rfc3261#section-13.2
+ * @public
+ */
+class InviteUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_3__["UserAgentClient"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_2__["InviteClientTransaction"], core, message, delegate);
+ this.confirmedDialogAcks = new Map();
+ this.confirmedDialogs = new Map();
+ this.earlyDialogs = new Map();
+ this.delegate = delegate;
+ }
+ dispose() {
+ // The UAC core considers the INVITE transaction completed 64*T1 seconds
+ // after the reception of the first 2xx response. At this point all the
+ // early dialogs that have not transitioned to established dialogs are
+ // terminated. Once the INVITE transaction is considered completed by
+ // the UAC core, no more new 2xx responses are expected to arrive.
+ //
+ // If, after acknowledging any 2xx response to an INVITE, the UAC does
+ // not want to continue with that dialog, then the UAC MUST terminate
+ // the dialog by sending a BYE request as described in Section 15.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ this.earlyDialogs.forEach((earlyDialog) => earlyDialog.dispose());
+ this.earlyDialogs.clear();
+ super.dispose();
+ }
+ /**
+ * Special case for transport error while sending ACK.
+ * @param error - Transport error
+ */
+ onTransportError(error) {
+ if (this.transaction.state === _transactions__WEBPACK_IMPORTED_MODULE_2__["TransactionState"].Calling) {
+ return super.onTransportError(error);
+ }
+ // If not in 'calling' state, the transport error occurred while sending an ACK.
+ this.logger.error(error.message);
+ this.logger.error("User agent client request transport error while sending ACK.");
+ }
+ /**
+ * Once the INVITE has been passed to the INVITE client transaction, the
+ * UAC waits for responses for the INVITE.
+ * https://tools.ietf.org/html/rfc3261#section-13.2.2
+ * @param incomingResponse - Incoming response to INVITE request.
+ */
+ receiveResponse(message) {
+ if (!this.authenticationGuard(message)) {
+ return;
+ }
+ const statusCode = message.statusCode ? message.statusCode.toString() : "";
+ if (!statusCode) {
+ throw new Error("Response status code undefined.");
+ }
+ switch (true) {
+ case /^100$/.test(statusCode):
+ if (this.delegate && this.delegate.onTrying) {
+ this.delegate.onTrying({ message });
+ }
+ return;
+ case /^1[0-9]{2}$/.test(statusCode):
+ // Zero, one or multiple provisional responses may arrive before one or
+ // more final responses are received. Provisional responses for an
+ // INVITE request can create "early dialogs". If a provisional response
+ // has a tag in the To field, and if the dialog ID of the response does
+ // not match an existing dialog, one is constructed using the procedures
+ // defined in Section 12.1.2.
+ //
+ // The early dialog will only be needed if the UAC needs to send a
+ // request to its peer within the dialog before the initial INVITE
+ // transaction completes. Header fields present in a provisional
+ // response are applicable as long as the dialog is in the early state
+ // (for example, an Allow header field in a provisional response
+ // contains the methods that can be used in the dialog while this is in
+ // the early state).
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.1
+ {
+ // Dialogs are created through the generation of non-failure responses
+ // to requests with specific methods. Within this specification, only
+ // 2xx and 101-199 responses with a To tag, where the request was
+ // INVITE, will establish a dialog. A dialog established by a non-final
+ // response to a request is in the "early" state and it is called an
+ // early dialog.
+ // https://tools.ietf.org/html/rfc3261#section-12.1
+ // Provisional without to tag, no dialog to create.
+ if (!message.toTag) {
+ this.logger.warn("Non-100 1xx INVITE response received without a to tag, dropping.");
+ return;
+ }
+ // When a UAS responds to a request with a response that establishes a
+ // dialog (such as a 2xx to INVITE), the UAS MUST copy all Record-Route
+ // header field values from the request into the response (including the
+ // URIs, URI parameters, and any Record-Route header field parameters,
+ // whether they are known or unknown to the UAS) and MUST maintain the
+ // order of those values. The UAS MUST add a Contact header field to
+ // the response.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ // Provisional without Contact header field, malformed response.
+ const contact = message.parseHeader("contact");
+ if (!contact) {
+ this.logger.error("Non-100 1xx INVITE response received without a Contact header field, dropping.");
+ return;
+ }
+ // Compute dialog state.
+ const dialogState = _dialogs__WEBPACK_IMPORTED_MODULE_0__["Dialog"].initialDialogStateForUserAgentClient(this.message, message);
+ // Have existing early dialog or create a new one.
+ let earlyDialog = this.earlyDialogs.get(dialogState.id);
+ if (!earlyDialog) {
+ const transaction = this.transaction;
+ if (!(transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_2__["InviteClientTransaction"])) {
+ throw new Error("Transaction not instance of InviteClientTransaction.");
+ }
+ earlyDialog = new _dialogs__WEBPACK_IMPORTED_MODULE_0__["SessionDialog"](transaction, this.core, dialogState);
+ this.earlyDialogs.set(earlyDialog.id, earlyDialog);
+ }
+ // Guard against out of order reliable provisional responses.
+ // Note that this is where the rseq tracking is done.
+ if (!earlyDialog.reliableSequenceGuard(message)) {
+ this.logger.warn("1xx INVITE reliable response received out of order or is a retransmission, dropping.");
+ return;
+ }
+ // If the initial offer is in an INVITE, the answer MUST be in a
+ // reliable non-failure message from UAS back to UAC which is
+ // correlated to that INVITE. For this specification, that is
+ // only the final 2xx response to that INVITE. That same exact
+ // answer MAY also be placed in any provisional responses sent
+ // prior to the answer. The UAC MUST treat the first session
+ // description it receives as the answer, and MUST ignore any
+ // session descriptions in subsequent responses to the initial
+ // INVITE.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ if (earlyDialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial ||
+ earlyDialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer) {
+ earlyDialog.signalingStateTransition(message);
+ }
+ // Pass response to delegate.
+ const session = earlyDialog;
+ if (this.delegate && this.delegate.onProgress) {
+ this.delegate.onProgress({
+ message,
+ session,
+ prack: (options) => {
+ const outgoingPrackRequest = session.prack(undefined, options);
+ return outgoingPrackRequest;
+ }
+ });
+ }
+ }
+ return;
+ case /^2[0-9]{2}$/.test(statusCode):
+ // Multiple 2xx responses may arrive at the UAC for a single INVITE
+ // request due to a forking proxy. Each response is distinguished by
+ // the tag parameter in the To header field, and each represents a
+ // distinct dialog, with a distinct dialog identifier.
+ //
+ // If the dialog identifier in the 2xx response matches the dialog
+ // identifier of an existing dialog, the dialog MUST be transitioned to
+ // the "confirmed" state, and the route set for the dialog MUST be
+ // recomputed based on the 2xx response using the procedures of Section
+ // 12.2.1.2. Otherwise, a new dialog in the "confirmed" state MUST be
+ // constructed using the procedures of Section 12.1.2.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ {
+ // Dialogs are created through the generation of non-failure responses
+ // to requests with specific methods. Within this specification, only
+ // 2xx and 101-199 responses with a To tag, where the request was
+ // INVITE, will establish a dialog. A dialog established by a non-final
+ // response to a request is in the "early" state and it is called an
+ // early dialog.
+ // https://tools.ietf.org/html/rfc3261#section-12.1
+ // Final without to tag, malformed response.
+ if (!message.toTag) {
+ this.logger.error("2xx INVITE response received without a to tag, dropping.");
+ return;
+ }
+ // When a UAS responds to a request with a response that establishes a
+ // dialog (such as a 2xx to INVITE), the UAS MUST copy all Record-Route
+ // header field values from the request into the response (including the
+ // URIs, URI parameters, and any Record-Route header field parameters,
+ // whether they are known or unknown to the UAS) and MUST maintain the
+ // order of those values. The UAS MUST add a Contact header field to
+ // the response.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ // Final without Contact header field, malformed response.
+ const contact = message.parseHeader("contact");
+ if (!contact) {
+ this.logger.error("2xx INVITE response received without a Contact header field, dropping.");
+ return;
+ }
+ // Compute dialog state.
+ const dialogState = _dialogs__WEBPACK_IMPORTED_MODULE_0__["Dialog"].initialDialogStateForUserAgentClient(this.message, message);
+ // NOTE: Currently our transaction layer is caching the 2xx ACKs and
+ // handling retransmissions of the ACK which is an approach which is
+ // not to spec. In any event, this block is intended to provide a to
+ // spec implementation of ACK retransmissions, but it should not be
+ // hit currently.
+ let dialog = this.confirmedDialogs.get(dialogState.id);
+ if (dialog) {
+ // Once the ACK has been constructed, the procedures of [4] are used to
+ // determine the destination address, port and transport. However, the
+ // request is passed to the transport layer directly for transmission,
+ // rather than a client transaction. This is because the UAC core
+ // handles retransmissions of the ACK, not the transaction layer. The
+ // ACK MUST be passed to the client transport every time a
+ // retransmission of the 2xx final response that triggered the ACK
+ // arrives.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ const outgoingAckRequest = this.confirmedDialogAcks.get(dialogState.id);
+ if (outgoingAckRequest) {
+ const transaction = this.transaction;
+ if (!(transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_2__["InviteClientTransaction"])) {
+ throw new Error("Client transaction not instance of InviteClientTransaction.");
+ }
+ transaction.ackResponse(outgoingAckRequest.message);
+ }
+ else {
+ // If still waiting for an ACK, drop the retransmission of the 2xx final response.
+ }
+ return;
+ }
+ // If the dialog identifier in the 2xx response matches the dialog
+ // identifier of an existing dialog, the dialog MUST be transitioned to
+ // the "confirmed" state, and the route set for the dialog MUST be
+ // recomputed based on the 2xx response using the procedures of Section
+ // 12.2.1.2. Otherwise, a new dialog in the "confirmed" state MUST be
+ // constructed using the procedures of Section 12.1.2.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ dialog = this.earlyDialogs.get(dialogState.id);
+ if (dialog) {
+ dialog.confirm();
+ dialog.recomputeRouteSet(message);
+ this.earlyDialogs.delete(dialog.id);
+ this.confirmedDialogs.set(dialog.id, dialog);
+ }
+ else {
+ const transaction = this.transaction;
+ if (!(transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_2__["InviteClientTransaction"])) {
+ throw new Error("Transaction not instance of InviteClientTransaction.");
+ }
+ dialog = new _dialogs__WEBPACK_IMPORTED_MODULE_0__["SessionDialog"](transaction, this.core, dialogState);
+ this.confirmedDialogs.set(dialog.id, dialog);
+ }
+ // If the initial offer is in an INVITE, the answer MUST be in a
+ // reliable non-failure message from UAS back to UAC which is
+ // correlated to that INVITE. For this specification, that is
+ // only the final 2xx response to that INVITE. That same exact
+ // answer MAY also be placed in any provisional responses sent
+ // prior to the answer. The UAC MUST treat the first session
+ // description it receives as the answer, and MUST ignore any
+ // session descriptions in subsequent responses to the initial
+ // INVITE.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ if (dialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].Initial ||
+ dialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_1__["SignalingState"].HaveLocalOffer) {
+ dialog.signalingStateTransition(message);
+ }
+ // Session Initiated! :)
+ const session = dialog;
+ // The UAC core MUST generate an ACK request for each 2xx received from
+ // the transaction layer. The header fields of the ACK are constructed
+ // in the same way as for any request sent within a dialog (see Section
+ // 12) with the exception of the CSeq and the header fields related to
+ // authentication. The sequence number of the CSeq header field MUST be
+ // the same as the INVITE being acknowledged, but the CSeq method MUST
+ // be ACK. The ACK MUST contain the same credentials as the INVITE. If
+ // the 2xx contains an offer (based on the rules above), the ACK MUST
+ // carry an answer in its body. If the offer in the 2xx response is not
+ // acceptable, the UAC core MUST generate a valid answer in the ACK and
+ // then send a BYE immediately.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.4
+ if (this.delegate && this.delegate.onAccept) {
+ this.delegate.onAccept({
+ message,
+ session,
+ ack: (options) => {
+ const outgoingAckRequest = session.ack(options);
+ this.confirmedDialogAcks.set(session.id, outgoingAckRequest);
+ return outgoingAckRequest;
+ }
+ });
+ }
+ else {
+ const outgoingAckRequest = session.ack();
+ this.confirmedDialogAcks.set(session.id, outgoingAckRequest);
+ }
+ }
+ return;
+ case /^3[0-9]{2}$/.test(statusCode):
+ // 12.3 Termination of a Dialog
+ //
+ // Independent of the method, if a request outside of a dialog generates
+ // a non-2xx final response, any early dialogs created through
+ // provisional responses to that request are terminated. The mechanism
+ // for terminating confirmed dialogs is method specific. In this
+ // specification, the BYE method terminates a session and the dialog
+ // associated with it. See Section 15 for details.
+ // https://tools.ietf.org/html/rfc3261#section-12.3
+ // All early dialogs are considered terminated upon reception of the
+ // non-2xx final response.
+ //
+ // After having received the non-2xx final response the UAC core
+ // considers the INVITE transaction completed. The INVITE client
+ // transaction handles the generation of ACKs for the response (see
+ // Section 17).
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.3
+ this.earlyDialogs.forEach((earlyDialog) => earlyDialog.dispose());
+ this.earlyDialogs.clear();
+ // A 3xx response may contain one or more Contact header field values
+ // providing new addresses where the callee might be reachable.
+ // Depending on the status code of the 3xx response (see Section 21.3),
+ // the UAC MAY choose to try those new addresses.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.2
+ if (this.delegate && this.delegate.onRedirect) {
+ this.delegate.onRedirect({ message });
+ }
+ return;
+ case /^[4-6][0-9]{2}$/.test(statusCode):
+ // 12.3 Termination of a Dialog
+ //
+ // Independent of the method, if a request outside of a dialog generates
+ // a non-2xx final response, any early dialogs created through
+ // provisional responses to that request are terminated. The mechanism
+ // for terminating confirmed dialogs is method specific. In this
+ // specification, the BYE method terminates a session and the dialog
+ // associated with it. See Section 15 for details.
+ // https://tools.ietf.org/html/rfc3261#section-12.3
+ // All early dialogs are considered terminated upon reception of the
+ // non-2xx final response.
+ //
+ // After having received the non-2xx final response the UAC core
+ // considers the INVITE transaction completed. The INVITE client
+ // transaction handles the generation of ACKs for the response (see
+ // Section 17).
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.3
+ this.earlyDialogs.forEach((earlyDialog) => earlyDialog.dispose());
+ this.earlyDialogs.clear();
+ // A single non-2xx final response may be received for the INVITE. 4xx,
+ // 5xx and 6xx responses may contain a Contact header field value
+ // indicating the location where additional information about the error
+ // can be found. Subsequent final responses (which would only arrive
+ // under error conditions) MUST be ignored.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.2.3
+ if (this.delegate && this.delegate.onReject) {
+ this.delegate.onReject({ message });
+ }
+ return;
+ default:
+ throw new Error(`Invalid status code ${statusCode}`);
+ }
+ throw new Error(`Executing what should be an unreachable code path receiving ${statusCode} response.`);
+ }
+}
+
+
+/***/ }),
+/* 93 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "InviteUserAgentServer", function() { return InviteUserAgentServer; });
+/* harmony import */ var _dialogs__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(6);
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(51);
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(44);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(82);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(65);
+
+
+
+
+
+
+/**
+ * INVITE UAS.
+ * @remarks
+ * 13 Initiating a Session
+ * https://tools.ietf.org/html/rfc3261#section-13
+ * 13.1 Overview
+ * https://tools.ietf.org/html/rfc3261#section-13.1
+ * 13.3 UAS Processing
+ * https://tools.ietf.org/html/rfc3261#section-13.3
+ * @public
+ */
+class InviteUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_5__["UserAgentServer"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"], core, message, delegate);
+ this.core = core;
+ }
+ dispose() {
+ if (this.earlyDialog) {
+ this.earlyDialog.dispose();
+ }
+ super.dispose();
+ }
+ /**
+ * 13.3.1.4 The INVITE is Accepted
+ * The UAS core generates a 2xx response. This response establishes a
+ * dialog, and therefore follows the procedures of Section 12.1.1 in
+ * addition to those of Section 8.2.6.
+ * https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ * @param options - Accept options bucket.
+ */
+ accept(options = { statusCode: 200 }) {
+ if (!this.acceptable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_1__["TransactionStateError"](`${this.message.method} not acceptable in state ${this.transaction.state}.`);
+ }
+ // This response establishes a dialog...
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ if (!this.confirmedDialog) {
+ if (this.earlyDialog) {
+ this.earlyDialog.confirm();
+ this.confirmedDialog = this.earlyDialog;
+ this.earlyDialog = undefined;
+ }
+ else {
+ const transaction = this.transaction;
+ if (!(transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"])) {
+ throw new Error("Transaction not instance of InviteClientTransaction.");
+ }
+ const state = _dialogs__WEBPACK_IMPORTED_MODULE_0__["Dialog"].initialDialogStateForUserAgentServer(this.message, this.toTag);
+ this.confirmedDialog = new _dialogs__WEBPACK_IMPORTED_MODULE_0__["SessionDialog"](transaction, this.core, state);
+ }
+ }
+ // When a UAS responds to a request with a response that establishes a
+ // dialog (such as a 2xx to INVITE), the UAS MUST copy all Record-Route
+ // header field values from the request into the response (including the
+ // URIs, URI parameters, and any Record-Route header field parameters,
+ // whether they are known or unknown to the UAS) and MUST maintain the
+ // order of those values. The UAS MUST add a Contact header field to
+ // the response. The Contact header field contains an address where the
+ // UAS would like to be contacted for subsequent requests in the dialog
+ // (which includes the ACK for a 2xx response in the case of an INVITE).
+ // Generally, the host portion of this URI is the IP address or FQDN of
+ // the host. The URI provided in the Contact header field MUST be a SIP
+ // or SIPS URI. If the request that initiated the dialog contained a
+ // SIPS URI in the Request-URI or in the top Record-Route header field
+ // value, if there was any, or the Contact header field if there was no
+ // Record-Route header field, the Contact header field in the response
+ // MUST be a SIPS URI. The URI SHOULD have global scope (that is, the
+ // same URI can be used in messages outside this dialog). The same way,
+ // the scope of the URI in the Contact header field of the INVITE is not
+ // limited to this dialog either. It can therefore be used in messages
+ // to the UAC even outside this dialog.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const recordRouteHeader = this.message.getHeaders("record-route").map((header) => `Record-Route: ${header}`);
+ const contactHeader = `Contact: ${this.core.configuration.contact.toString()}`;
+ // A 2xx response to an INVITE SHOULD contain the Allow header field and
+ // the Supported header field, and MAY contain the Accept header field.
+ // Including these header fields allows the UAC to determine the
+ // features and extensions supported by the UAS for the duration of the
+ // call, without probing.
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ // FIXME: TODO: This should not be hard coded.
+ const allowHeader = "Allow: " + _user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_4__["AllowedMethods"].toString();
+ // FIXME: TODO: Supported header (see reply())
+ // FIXME: TODO: Accept header
+ // If the INVITE request contained an offer, and the UAS had not yet
+ // sent an answer, the 2xx MUST contain an answer. If the INVITE did
+ // not contain an offer, the 2xx MUST contain an offer if the UAS had
+ // not yet sent an offer.
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ if (!options.body) {
+ if (this.confirmedDialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_2__["SignalingState"].Stable) {
+ options.body = this.confirmedDialog.answer; // resend the answer sent in provisional response
+ }
+ else if (this.confirmedDialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_2__["SignalingState"].Initial ||
+ this.confirmedDialog.signalingState === _session__WEBPACK_IMPORTED_MODULE_2__["SignalingState"].HaveRemoteOffer) {
+ throw new Error("Response must have a body.");
+ }
+ }
+ options.statusCode = options.statusCode || 200;
+ options.extraHeaders = options.extraHeaders || [];
+ options.extraHeaders = options.extraHeaders.concat(recordRouteHeader);
+ options.extraHeaders.push(allowHeader);
+ options.extraHeaders.push(contactHeader);
+ const response = super.accept(options);
+ const session = this.confirmedDialog;
+ const result = Object.assign(Object.assign({}, response), { session });
+ // Update dialog signaling state
+ if (options.body) {
+ // Once the UAS has sent or received an answer to the initial
+ // offer, it MUST NOT generate subsequent offers in any responses
+ // to the initial INVITE. This means that a UAS based on this
+ // specification alone can never generate subsequent offers until
+ // completion of the initial transaction.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ if (this.confirmedDialog.signalingState !== _session__WEBPACK_IMPORTED_MODULE_2__["SignalingState"].Stable) {
+ this.confirmedDialog.signalingStateTransition(options.body);
+ }
+ }
+ return result;
+ }
+ /**
+ * 13.3.1.1 Progress
+ * If the UAS is not able to answer the invitation immediately, it can
+ * choose to indicate some kind of progress to the UAC (for example, an
+ * indication that a phone is ringing). This is accomplished with a
+ * provisional response between 101 and 199. These provisional
+ * responses establish early dialogs and therefore follow the procedures
+ * of Section 12.1.1 in addition to those of Section 8.2.6. A UAS MAY
+ * send as many provisional responses as it likes. Each of these MUST
+ * indicate the same dialog ID. However, these will not be delivered
+ * reliably.
+ *
+ * If the UAS desires an extended period of time to answer the INVITE,
+ * it will need to ask for an "extension" in order to prevent proxies
+ * from canceling the transaction. A proxy has the option of canceling
+ * a transaction when there is a gap of 3 minutes between responses in a
+ * transaction. To prevent cancellation, the UAS MUST send a non-100
+ * provisional response at every minute, to handle the possibility of
+ * lost provisional responses.
+ * https://tools.ietf.org/html/rfc3261#section-13.3.1.1
+ * @param options - Progress options bucket.
+ */
+ progress(options = { statusCode: 180 }) {
+ if (!this.progressable) {
+ throw new _exceptions__WEBPACK_IMPORTED_MODULE_1__["TransactionStateError"](`${this.message.method} not progressable in state ${this.transaction.state}.`);
+ }
+ // This response establishes a dialog...
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.4
+ if (!this.earlyDialog) {
+ const transaction = this.transaction;
+ if (!(transaction instanceof _transactions__WEBPACK_IMPORTED_MODULE_3__["InviteServerTransaction"])) {
+ throw new Error("Transaction not instance of InviteClientTransaction.");
+ }
+ const state = _dialogs__WEBPACK_IMPORTED_MODULE_0__["Dialog"].initialDialogStateForUserAgentServer(this.message, this.toTag, true);
+ this.earlyDialog = new _dialogs__WEBPACK_IMPORTED_MODULE_0__["SessionDialog"](transaction, this.core, state);
+ }
+ // When a UAS responds to a request with a response that establishes a
+ // dialog (such as a 2xx to INVITE), the UAS MUST copy all Record-Route
+ // header field values from the request into the response (including the
+ // URIs, URI parameters, and any Record-Route header field parameters,
+ // whether they are known or unknown to the UAS) and MUST maintain the
+ // order of those values. The UAS MUST add a Contact header field to
+ // the response. The Contact header field contains an address where the
+ // UAS would like to be contacted for subsequent requests in the dialog
+ // (which includes the ACK for a 2xx response in the case of an INVITE).
+ // Generally, the host portion of this URI is the IP address or FQDN of
+ // the host. The URI provided in the Contact header field MUST be a SIP
+ // or SIPS URI. If the request that initiated the dialog contained a
+ // SIPS URI in the Request-URI or in the top Record-Route header field
+ // value, if there was any, or the Contact header field if there was no
+ // Record-Route header field, the Contact header field in the response
+ // MUST be a SIPS URI. The URI SHOULD have global scope (that is, the
+ // same URI can be used in messages outside this dialog). The same way,
+ // the scope of the URI in the Contact header field of the INVITE is not
+ // limited to this dialog either. It can therefore be used in messages
+ // to the UAC even outside this dialog.
+ // https://tools.ietf.org/html/rfc3261#section-12.1.1
+ const recordRouteHeader = this.message.getHeaders("record-route").map((header) => `Record-Route: ${header}`);
+ const contactHeader = `Contact: ${this.core.configuration.contact}`;
+ options.extraHeaders = options.extraHeaders || [];
+ options.extraHeaders = options.extraHeaders.concat(recordRouteHeader);
+ options.extraHeaders.push(contactHeader);
+ const response = super.progress(options);
+ const session = this.earlyDialog;
+ const result = Object.assign(Object.assign({}, response), { session });
+ // Update dialog signaling state
+ if (options.body) {
+ // Once the UAS has sent or received an answer to the initial
+ // offer, it MUST NOT generate subsequent offers in any responses
+ // to the initial INVITE. This means that a UAS based on this
+ // specification alone can never generate subsequent offers until
+ // completion of the initial transaction.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ if (this.earlyDialog.signalingState !== _session__WEBPACK_IMPORTED_MODULE_2__["SignalingState"].Stable) {
+ this.earlyDialog.signalingStateTransition(options.body);
+ }
+ }
+ return result;
+ }
+ /**
+ * 13.3.1.2 The INVITE is Redirected
+ * If the UAS decides to redirect the call, a 3xx response is sent. A
+ * 300 (Multiple Choices), 301 (Moved Permanently) or 302 (Moved
+ * Temporarily) response SHOULD contain a Contact header field
+ * containing one or more URIs of new addresses to be tried. The
+ * response is passed to the INVITE server transaction, which will deal
+ * with its retransmissions.
+ * https://tools.ietf.org/html/rfc3261#section-13.3.1.2
+ * @param contacts - Contacts to redirect to.
+ * @param options - Redirect options bucket.
+ */
+ redirect(contacts, options = { statusCode: 302 }) {
+ return super.redirect(contacts, options);
+ }
+ /**
+ * 13.3.1.3 The INVITE is Rejected
+ * A common scenario occurs when the callee is currently not willing or
+ * able to take additional calls at this end system. A 486 (Busy Here)
+ * SHOULD be returned in such a scenario.
+ * https://tools.ietf.org/html/rfc3261#section-13.3.1.3
+ * @param options - Reject options bucket.
+ */
+ reject(options = { statusCode: 486 }) {
+ return super.reject(options);
+ }
+}
+
+
+/***/ }),
+/* 94 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PublishUserAgentClient", function() { return PublishUserAgentClient; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(63);
+
+
+/**
+ * PUBLISH UAC.
+ * @public
+ */
+class PublishUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_1__["UserAgentClient"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteClientTransaction"], core, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 95 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "ReSubscribeUserAgentServer", function() { return ReSubscribeUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * Re-SUBSCRIBE UAS.
+ * @public
+ */
+class ReSubscribeUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(dialog, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], dialog.userAgentCore, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 96 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RegisterUserAgentClient", function() { return RegisterUserAgentClient; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(63);
+
+
+/**
+ * REGISTER UAC.
+ * @public
+ */
+class RegisterUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_1__["UserAgentClient"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteClientTransaction"], core, message, delegate);
+ }
+}
+
+
+/***/ }),
+/* 97 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RegisterUserAgentServer", function() { return RegisterUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * REGISTER UAS.
+ * @public
+ */
+class RegisterUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], core, message, delegate);
+ this.core = core;
+ }
+}
+
+
+/***/ }),
+/* 98 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SubscribeUserAgentClient", function() { return SubscribeUserAgentClient; });
+/* harmony import */ var _dialogs_subscription_dialog__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(78);
+/* harmony import */ var _subscription__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(79);
+/* harmony import */ var _timers__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(47);
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_client__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(63);
+
+
+
+
+
+/**
+ * SUBSCRIBE UAC.
+ * @remarks
+ * 4.1. Subscriber Behavior
+ * https://tools.ietf.org/html/rfc6665#section-4.1
+ *
+ * User agent client for installation of a single subscription per SUBSCRIBE request.
+ * TODO: Support for installation of multiple subscriptions on forked SUBSCRIBE requests.
+ * @public
+ */
+class SubscribeUserAgentClient extends _user_agent_client__WEBPACK_IMPORTED_MODULE_4__["UserAgentClient"] {
+ constructor(core, message, delegate) {
+ // Get event from request message.
+ const event = message.getHeader("Event");
+ if (!event) {
+ throw new Error("Event undefined");
+ }
+ // Get expires from request message.
+ const expires = message.getHeader("Expires");
+ if (!expires) {
+ throw new Error("Expires undefined");
+ }
+ super(_transactions__WEBPACK_IMPORTED_MODULE_3__["NonInviteClientTransaction"], core, message, delegate);
+ this.delegate = delegate;
+ // FIXME: Subscriber id should also be matching on event id.
+ this.subscriberId = message.callId + message.fromTag + event;
+ this.subscriptionExpiresRequested = this.subscriptionExpires = Number(expires);
+ this.subscriptionEvent = event;
+ this.subscriptionState = _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait;
+ // Start waiting for a NOTIFY we can use to create a subscription.
+ this.waitNotifyStart();
+ }
+ /**
+ * Destructor.
+ * Note that Timer N may live on waiting for an initial NOTIFY and
+ * the delegate may still receive that NOTIFY. If you don't want
+ * that behavior then either clear the delegate so the delegate
+ * doesn't get called (a 200 will be sent in response to the NOTIFY)
+ * or call `waitNotifyStop` which will clear Timer N and remove this
+ * UAC from the core (a 481 will be sent in response to the NOTIFY).
+ */
+ dispose() {
+ super.dispose();
+ }
+ /**
+ * Handle out of dialog NOTIFY associated with SUBSCRIBE request.
+ * This is the first NOTIFY received after the SUBSCRIBE request.
+ * @param uas - User agent server handling the subscription creating NOTIFY.
+ */
+ onNotify(uas) {
+ // NOTIFY requests are matched to such SUBSCRIBE requests if they
+ // contain the same "Call-ID", a "To" header field "tag" parameter that
+ // matches the "From" header field "tag" parameter of the SUBSCRIBE
+ // request, and the same "Event" header field. Rules for comparisons of
+ // the "Event" header fields are described in Section 8.2.1.
+ // https://tools.ietf.org/html/rfc6665#section-4.4.1
+ const event = uas.message.parseHeader("Event").event;
+ if (!event || event !== this.subscriptionEvent) {
+ this.logger.warn(`Failed to parse event.`);
+ uas.reject({ statusCode: 489 });
+ return;
+ }
+ // NOTIFY requests MUST contain "Subscription-State" header fields that
+ // indicate the status of the subscription.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.3
+ const subscriptionState = uas.message.parseHeader("Subscription-State");
+ if (!subscriptionState || !subscriptionState.state) {
+ this.logger.warn("Failed to parse subscription state.");
+ uas.reject({ statusCode: 489 });
+ return;
+ }
+ // Validate subscription state.
+ const state = subscriptionState.state;
+ switch (state) {
+ case "pending":
+ break;
+ case "active":
+ break;
+ case "terminated":
+ break;
+ default:
+ this.logger.warn(`Invalid subscription state ${state}`);
+ uas.reject({ statusCode: 489 });
+ return;
+ }
+ // Dialogs usages are created upon completion of a NOTIFY transaction
+ // for a new subscription, unless the NOTIFY request contains a
+ // "Subscription-State" of "terminated."
+ // https://tools.ietf.org/html/rfc6665#section-4.4.1
+ if (state !== "terminated") {
+ // The Contact header field MUST be present and contain exactly one SIP
+ // or SIPS URI in any request that can result in the establishment of a
+ // dialog.
+ // https://tools.ietf.org/html/rfc3261#section-8.1.1.8
+ const contact = uas.message.parseHeader("contact");
+ if (!contact) {
+ this.logger.warn("Failed to parse contact.");
+ uas.reject({ statusCode: 489 });
+ return;
+ }
+ }
+ // In accordance with the rules for proxying non-INVITE requests as
+ // defined in [RFC3261], successful SUBSCRIBE requests will receive only
+ // one 200-class response; however, due to forking, the subscription may
+ // have been accepted by multiple nodes. The subscriber MUST therefore
+ // be prepared to receive NOTIFY requests with "From:" tags that differ
+ // from the "To:" tag received in the SUBSCRIBE 200-class response.
+ //
+ // If multiple NOTIFY requests are received in different dialogs in
+ // response to a single SUBSCRIBE request, each dialog represents a
+ // different destination to which the SUBSCRIBE request was forked.
+ // Subscriber handling in such situations varies by event package; see
+ // Section 5.4.9 for details.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.4
+ // Each event package MUST specify whether forked SUBSCRIBE requests are
+ // allowed to install multiple subscriptions.
+ //
+ // If such behavior is not allowed, the first potential dialog-
+ // establishing message will create a dialog. All subsequent NOTIFY
+ // requests that correspond to the SUBSCRIBE request (i.e., have
+ // matching "To", "From", "Call-ID", and "Event" header fields, as well
+ // as "From" header field "tag" parameter and "Event" header field "id"
+ // parameter) but that do not match the dialog would be rejected with a
+ // 481 response. Note that the 200-class response to the SUBSCRIBE
+ // request can arrive after a matching NOTIFY request has been received;
+ // such responses might not correlate to the same dialog established by
+ // the NOTIFY request. Except as required to complete the SUBSCRIBE
+ // transaction, such non-matching 200-class responses are ignored.
+ //
+ // If installing of multiple subscriptions by way of a single forked
+ // SUBSCRIBE request is allowed, the subscriber establishes a new dialog
+ // towards each notifier by returning a 200-class response to each
+ // NOTIFY request. Each dialog is then handled as its own entity and is
+ // refreshed independently of the other dialogs.
+ //
+ // In the case that multiple subscriptions are allowed, the event
+ // package MUST specify whether merging of the notifications to form a
+ // single state is required, and how such merging is to be performed.
+ // Note that it is possible that some event packages may be defined in
+ // such a way that each dialog is tied to a mutually exclusive state
+ // that is unaffected by the other dialogs; this MUST be clearly stated
+ // if it is the case.
+ // https://tools.ietf.org/html/rfc6665#section-5.4.9
+ // *** NOTE: This implementation is only for event packages which
+ // do not allow forked requests to install multiple subscriptions.
+ // As such and in accordance with the specification, we stop waiting
+ // and any future NOTIFY requests will be rejected with a 481.
+ if (this.dialog) {
+ throw new Error("Dialog already created. This implementation only supports install of single subscriptions.");
+ }
+ this.waitNotifyStop();
+ // Update expires.
+ this.subscriptionExpires = subscriptionState.expires
+ ? Math.min(this.subscriptionExpires, Math.max(subscriptionState.expires, 0))
+ : this.subscriptionExpires;
+ // Update subscription state.
+ switch (state) {
+ case "pending":
+ this.subscriptionState = _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Pending;
+ break;
+ case "active":
+ this.subscriptionState = _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Active;
+ break;
+ case "terminated":
+ this.subscriptionState = _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated;
+ break;
+ default:
+ throw new Error(`Unrecognized state ${state}.`);
+ }
+ // Dialogs usages are created upon completion of a NOTIFY transaction
+ // for a new subscription, unless the NOTIFY request contains a
+ // "Subscription-State" of "terminated."
+ // https://tools.ietf.org/html/rfc6665#section-4.4.1
+ if (this.subscriptionState !== _subscription__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ // Because the dialog usage is established by the NOTIFY request, the
+ // route set at the subscriber is taken from the NOTIFY request itself,
+ // as opposed to the route set present in the 200-class response to the
+ // SUBSCRIBE request.
+ // https://tools.ietf.org/html/rfc6665#section-4.4.1
+ const dialogState = _dialogs_subscription_dialog__WEBPACK_IMPORTED_MODULE_0__["SubscriptionDialog"].initialDialogStateForSubscription(this.message, uas.message);
+ // Subscription Initiated! :)
+ this.dialog = new _dialogs_subscription_dialog__WEBPACK_IMPORTED_MODULE_0__["SubscriptionDialog"](this.subscriptionEvent, this.subscriptionExpires, this.subscriptionState, this.core, dialogState);
+ }
+ // Delegate.
+ if (this.delegate && this.delegate.onNotify) {
+ const request = uas;
+ const subscription = this.dialog;
+ this.delegate.onNotify({ request, subscription });
+ }
+ else {
+ uas.accept();
+ }
+ }
+ waitNotifyStart() {
+ if (!this.N) {
+ // Add ourselves to the core's subscriber map.
+ // This allows the core to route out of dialog NOTIFY messages to us.
+ this.core.subscribers.set(this.subscriberId, this);
+ this.N = setTimeout(() => this.timerN(), _timers__WEBPACK_IMPORTED_MODULE_2__["Timers"].TIMER_N);
+ }
+ }
+ waitNotifyStop() {
+ if (this.N) {
+ // Remove ourselves to the core's subscriber map.
+ // Any future out of dialog NOTIFY messages will be rejected with a 481.
+ this.core.subscribers.delete(this.subscriberId);
+ clearTimeout(this.N);
+ this.N = undefined;
+ }
+ }
+ /**
+ * Receive a response from the transaction layer.
+ * @param message - Incoming response message.
+ */
+ receiveResponse(message) {
+ if (!this.authenticationGuard(message)) {
+ return;
+ }
+ if (message.statusCode && message.statusCode >= 200 && message.statusCode < 300) {
+ // The "Expires" header field in a 200-class response to SUBSCRIBE
+ // request indicates the actual duration for which the subscription will
+ // remain active (unless refreshed). The received value might be
+ // smaller than the value indicated in the SUBSCRIBE request but cannot
+ // be larger; see Section 4.2.1 for details.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.1
+ // The "Expires" values present in SUBSCRIBE 200-class responses behave
+ // in the same way as they do in REGISTER responses: the server MAY
+ // shorten the interval but MUST NOT lengthen it.
+ //
+ // If the duration specified in a SUBSCRIBE request is unacceptably
+ // short, the notifier may be able to send a 423 response, as
+ // described earlier in this section.
+ //
+ // 200-class responses to SUBSCRIBE requests will not generally contain
+ // any useful information beyond subscription duration; their primary
+ // purpose is to serve as a reliability mechanism. State information
+ // will be communicated via a subsequent NOTIFY request from the
+ // notifier.
+ // https://tools.ietf.org/html/rfc6665#section-4.2.1.1
+ const expires = message.getHeader("Expires");
+ if (!expires) {
+ this.logger.warn("Expires header missing in a 200-class response to SUBSCRIBE");
+ }
+ else {
+ const subscriptionExpiresReceived = Number(expires);
+ if (subscriptionExpiresReceived > this.subscriptionExpiresRequested) {
+ this.logger.warn("Expires header in a 200-class response to SUBSCRIBE with a higher value than the one in the request");
+ }
+ if (subscriptionExpiresReceived < this.subscriptionExpires) {
+ this.subscriptionExpires = subscriptionExpiresReceived;
+ }
+ }
+ // If a NOTIFY arrived before 200-class response a dialog may have been created.
+ // Updated the dialogs expiration only if this indicates earlier expiration.
+ if (this.dialog) {
+ if (this.dialog.subscriptionExpires > this.subscriptionExpires) {
+ this.dialog.subscriptionExpires = this.subscriptionExpires;
+ }
+ }
+ }
+ if (message.statusCode && message.statusCode >= 300 && message.statusCode < 700) {
+ this.waitNotifyStop(); // No NOTIFY will be sent after a negative final response.
+ }
+ super.receiveResponse(message);
+ }
+ /**
+ * To ensure that subscribers do not wait indefinitely for a
+ * subscription to be established, a subscriber starts a Timer N, set to
+ * 64*T1, when it sends a SUBSCRIBE request. If this Timer N expires
+ * prior to the receipt of a NOTIFY request, the subscriber considers
+ * the subscription failed, and cleans up any state associated with the
+ * subscription attempt.
+ * https://tools.ietf.org/html/rfc6665#section-4.1.2.4
+ */
+ timerN() {
+ this.logger.warn(`Timer N expired for SUBSCRIBE user agent client. Timed out waiting for NOTIFY.`);
+ this.waitNotifyStop();
+ if (this.delegate && this.delegate.onNotifyTimeout) {
+ this.delegate.onNotifyTimeout();
+ }
+ }
+}
+
+
+/***/ }),
+/* 99 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SubscribeUserAgentServer", function() { return SubscribeUserAgentServer; });
+/* harmony import */ var _transactions__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(48);
+/* harmony import */ var _user_agent_server__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(65);
+
+
+/**
+ * SUBSCRIBE UAS.
+ * @public
+ */
+class SubscribeUserAgentServer extends _user_agent_server__WEBPACK_IMPORTED_MODULE_1__["UserAgentServer"] {
+ constructor(core, message, delegate) {
+ super(_transactions__WEBPACK_IMPORTED_MODULE_0__["NonInviteServerTransaction"], core, message, delegate);
+ this.core = core;
+ }
+}
+
+
+/***/ }),
+/* 100 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 101 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 102 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 103 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RequestPendingError", function() { return RequestPendingError; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * An exception indicating an outstanding prior request prevented execution.
+ * @public
+ */
+class RequestPendingError extends _core__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ /** @internal */
+ constructor(message) {
+ super(message ? message : "Request pending.");
+ }
+}
+
+
+/***/ }),
+/* 104 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SessionDescriptionHandlerError", function() { return SessionDescriptionHandlerError; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * An exception indicating a session description handler error occured.
+ * @public
+ */
+class SessionDescriptionHandlerError extends _core__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ constructor(message) {
+ super(message ? message : "Unspecified session description handler error.");
+ }
+}
+
+
+/***/ }),
+/* 105 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SessionTerminatedError", function() { return SessionTerminatedError; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * An exception indicating the session terminated before the action completed.
+ * @public
+ */
+class SessionTerminatedError extends _core__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ constructor() {
+ super("The session has terminated.");
+ }
+}
+
+
+/***/ }),
+/* 106 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "StateTransitionError", function() { return StateTransitionError; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * An exception indicating an invalid state transition error occured.
+ * @public
+ */
+class StateTransitionError extends _core__WEBPACK_IMPORTED_MODULE_0__["Exception"] {
+ constructor(message) {
+ super(message ? message : "An error occurred during state transition.");
+ }
+}
+
+
+/***/ }),
+/* 107 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Bye", function() { return Bye; });
+/**
+ * A request to end a {@link Session} (incoming BYE).
+ * @public
+ */
+class Bye {
+ /** @internal */
+ constructor(incomingByeRequest) {
+ this.incomingByeRequest = incomingByeRequest;
+ }
+ /** Incoming BYE request message. */
+ get request() {
+ return this.incomingByeRequest.message;
+ }
+ /** Accept the request. */
+ accept(options) {
+ this.incomingByeRequest.accept(options);
+ return Promise.resolve();
+ }
+ /** Reject the request. */
+ reject(options) {
+ this.incomingByeRequest.reject(options);
+ return Promise.resolve();
+ }
+}
+
+
+/***/ }),
+/* 108 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "EmitterImpl", function() { return EmitterImpl; });
+/**
+ * An {@link Emitter} implementation.
+ * @internal
+ */
+class EmitterImpl {
+ constructor() {
+ this.listeners = new Array();
+ }
+ /**
+ * Sets up a function that will be called whenever the target changes.
+ * @param listener - Callback function.
+ * @param options - An options object that specifies characteristics about the listener.
+ * If once true, indicates that the listener should be invoked at most once after being added.
+ * If once true, the listener would be automatically removed when invoked.
+ */
+ addListener(listener, options) {
+ const onceWrapper = (data) => {
+ this.removeListener(onceWrapper);
+ listener(data);
+ };
+ (options === null || options === void 0 ? void 0 : options.once) === true ? this.listeners.push(onceWrapper) : this.listeners.push(listener);
+ }
+ /**
+ * Emit change.
+ * @param data - Data to emit.
+ */
+ emit(data) {
+ this.listeners.slice().forEach((listener) => listener(data));
+ }
+ /**
+ * Removes all listeners previously registered with addListener.
+ */
+ removeAllListeners() {
+ this.listeners = [];
+ }
+ /**
+ * Removes a listener previously registered with addListener.
+ * @param listener - Callback function.
+ */
+ removeListener(listener) {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+ /**
+ * Registers a listener.
+ * @param listener - Callback function.
+ * @deprecated Use addListener.
+ */
+ on(listener) {
+ return this.addListener(listener);
+ }
+ /**
+ * Unregisters a listener.
+ * @param listener - Callback function.
+ * @deprecated Use removeListener.
+ */
+ off(listener) {
+ return this.removeListener(listener);
+ }
+ /**
+ * Registers a listener then unregisters the listener after one event emission.
+ * @param listener - Callback function.
+ * @deprecated Use addListener.
+ */
+ once(listener) {
+ return this.addListener(listener, { once: true });
+ }
+}
+
+
+/***/ }),
+/* 109 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Info", function() { return Info; });
+/**
+ * An exchange of information (incoming INFO).
+ * @public
+ */
+class Info {
+ /** @internal */
+ constructor(incomingInfoRequest) {
+ this.incomingInfoRequest = incomingInfoRequest;
+ }
+ /** Incoming MESSAGE request message. */
+ get request() {
+ return this.incomingInfoRequest.message;
+ }
+ /** Accept the request. */
+ accept(options) {
+ this.incomingInfoRequest.accept(options);
+ return Promise.resolve();
+ }
+ /** Reject the request. */
+ reject(options) {
+ this.incomingInfoRequest.reject(options);
+ return Promise.resolve();
+ }
+}
+
+
+/***/ }),
+/* 110 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 111 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 112 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 113 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Invitation", function() { return Invitation; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _core_messages_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3);
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(114);
+/* harmony import */ var _session_state__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(118);
+/* harmony import */ var _user_agent_options__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(119);
+
+
+
+
+
+
+/**
+ * An invitation is an offer to establish a {@link Session} (incoming INVITE).
+ * @public
+ */
+class Invitation extends _session__WEBPACK_IMPORTED_MODULE_3__["Session"] {
+ /** @internal */
+ constructor(userAgent, incomingInviteRequest) {
+ super(userAgent);
+ this.incomingInviteRequest = incomingInviteRequest;
+ /** True if dispose() has been called. */
+ this.disposed = false;
+ /** INVITE will be rejected if not accepted within a certain period time. */
+ this.expiresTimer = undefined;
+ /** True if this Session has been Terminated due to a CANCEL request. */
+ this.isCanceled = false;
+ /** Are reliable provisional responses required or supported. */
+ this.rel100 = "none";
+ /** The current RSeq header value. */
+ this.rseq = Math.floor(Math.random() * 10000);
+ /** INVITE will be rejected if final response not sent in a certain period time. */
+ this.userNoAnswerTimer = undefined;
+ /** True if waiting for a PRACK before sending a 200 Ok. */
+ this.waitingForPrack = false;
+ this.logger = userAgent.getLogger("sip.Invitation");
+ const incomingRequestMessage = this.incomingInviteRequest.message;
+ // Set 100rel if necessary
+ const requireHeader = incomingRequestMessage.getHeader("require");
+ if (requireHeader && requireHeader.toLowerCase().includes("100rel")) {
+ this.rel100 = "required";
+ }
+ const supportedHeader = incomingRequestMessage.getHeader("supported");
+ if (supportedHeader && supportedHeader.toLowerCase().includes("100rel")) {
+ this.rel100 = "supported";
+ }
+ // FIXME: HACK: This is a hack to port an existing behavior.
+ // Set the toTag on the incoming request message to the toTag which
+ // will be used in the response to the incoming request!!!
+ // The behavior being ported appears to be a hack itself,
+ // so this is a hack to port a hack. At least one test spec
+ // relies on it (which is yet another hack).
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ incomingRequestMessage.toTag = incomingInviteRequest.toTag;
+ if (typeof incomingRequestMessage.toTag !== "string") {
+ throw new TypeError("toTag should have been a string.");
+ }
+ // The following mapping values are RECOMMENDED:
+ // ...
+ // 19 no answer from the user 480 Temporarily unavailable
+ // https://tools.ietf.org/html/rfc3398#section-7.2.4.1
+ this.userNoAnswerTimer = setTimeout(() => {
+ incomingInviteRequest.reject({ statusCode: 480 });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated);
+ }, this.userAgent.configuration.noAnswerTimeout ? this.userAgent.configuration.noAnswerTimeout * 1000 : 60000);
+ // 1. If the request is an INVITE that contains an Expires header
+ // field, the UAS core sets a timer for the number of seconds
+ // indicated in the header field value. When the timer fires, the
+ // invitation is considered to be expired. If the invitation
+ // expires before the UAS has generated a final response, a 487
+ // (Request Terminated) response SHOULD be generated.
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1
+ if (incomingRequestMessage.hasHeader("expires")) {
+ const expires = Number(incomingRequestMessage.getHeader("expires") || 0) * 1000;
+ this.expiresTimer = setTimeout(() => {
+ if (this.state === _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial) {
+ incomingInviteRequest.reject({ statusCode: 487 });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated);
+ }
+ }, expires);
+ }
+ // Session parent properties
+ const assertedIdentity = this.request.getHeader("P-Asserted-Identity");
+ if (assertedIdentity) {
+ this._assertedIdentity = _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].nameAddrHeaderParse(assertedIdentity);
+ }
+ this._contact = this.userAgent.contact.toString();
+ const contentDisposition = incomingRequestMessage.parseHeader("Content-Disposition");
+ if (contentDisposition && contentDisposition.type === "render") {
+ this._renderbody = incomingRequestMessage.body;
+ this._rendertype = incomingRequestMessage.getHeader("Content-Type");
+ }
+ // Identifier
+ this._id = incomingRequestMessage.callId + incomingRequestMessage.fromTag;
+ // Add to the user agent's session collection.
+ this.userAgent._sessions[this._id] = this;
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ // Only run through this once. It can and does get called multiple times
+ // depending on the what the sessions state is when first called.
+ // For example, if called when "establishing" it will be called again
+ // at least once when the session transitions to "terminated".
+ // Regardless, running through this more than once is pointless.
+ if (this.disposed) {
+ return Promise.resolve();
+ }
+ this.disposed = true;
+ // Clear timers
+ if (this.expiresTimer) {
+ clearTimeout(this.expiresTimer);
+ this.expiresTimer = undefined;
+ }
+ if (this.userNoAnswerTimer) {
+ clearTimeout(this.userNoAnswerTimer);
+ this.userNoAnswerTimer = undefined;
+ }
+ // If accept() is still waiting for a PRACK, make sure it rejects
+ this.prackNeverArrived();
+ // If the final response for the initial INVITE not yet been sent, reject it
+ switch (this.state) {
+ case _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial:
+ return this.reject().then(() => super.dispose());
+ case _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Establishing:
+ return this.reject().then(() => super.dispose());
+ case _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Established:
+ return super.dispose();
+ case _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminating:
+ return super.dispose();
+ case _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated:
+ return super.dispose();
+ default:
+ throw new Error("Unknown state.");
+ }
+ }
+ /**
+ * If true, a first provisional response after the 100 Trying
+ * will be sent automatically. This is false it the UAC required
+ * reliable provisional responses (100rel in Require header),
+ * otherwise it is true. The provisional is sent by calling
+ * `progress()` without any options.
+ *
+ * FIXME: TODO: It seems reasonable that the ISC user should
+ * be able to optionally disable this behavior. As the provisional
+ * is sent prior to the "invite" event being emitted, it's a known
+ * issue that the ISC user cannot register listeners or do any other
+ * setup prior to the call to `progress()`. As an example why this is
+ * an issue, setting `ua.configuration.rel100` to REQUIRED will result
+ * in an attempt by `progress()` to send a 183 with SDP produced by
+ * calling `getDescription()` on a session description handler, but
+ * the ISC user cannot perform any potentially required session description
+ * handler initialization (thus preventing the utilization of setting
+ * `ua.configuration.rel100` to REQUIRED). That begs the question of
+ * why this behavior is disabled when the UAC requires 100rel but not
+ * when the UAS requires 100rel? But ignoring that, it's just one example
+ * of a class of cases where the ISC user needs to do something prior
+ * to the first call to `progress()` and is unable to do so.
+ * @internal
+ */
+ get autoSendAnInitialProvisionalResponse() {
+ return this.rel100 === "required" ? false : true;
+ }
+ /**
+ * Initial incoming INVITE request message body.
+ */
+ get body() {
+ return this.incomingInviteRequest.message.body;
+ }
+ /**
+ * The identity of the local user.
+ */
+ get localIdentity() {
+ return this.request.to;
+ }
+ /**
+ * The identity of the remote user.
+ */
+ get remoteIdentity() {
+ return this.request.from;
+ }
+ /**
+ * Initial incoming INVITE request message.
+ */
+ get request() {
+ return this.incomingInviteRequest.message;
+ }
+ /**
+ * Accept the invitation.
+ *
+ * @remarks
+ * Accept the incoming INVITE request to start a Session.
+ * Replies to the INVITE request with a 200 Ok response.
+ * Resolves once the response sent, otherwise rejects.
+ *
+ * This method may reject for a variety of reasons including
+ * the receipt of a CANCEL request before `accept` is able
+ * to construct a response.
+ * @param options - Options bucket.
+ */
+ accept(options = {}) {
+ this.logger.log("Invitation.accept");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial) {
+ const error = new Error(`Invalid session state ${this.state}`);
+ this.logger.error(error.message);
+ return Promise.reject(error);
+ }
+ // Modifiers and options for initial INVITE transaction
+ if (options.sessionDescriptionHandlerModifiers) {
+ this.sessionDescriptionHandlerModifiers = options.sessionDescriptionHandlerModifiers;
+ }
+ if (options.sessionDescriptionHandlerOptions) {
+ this.sessionDescriptionHandlerOptions = options.sessionDescriptionHandlerOptions;
+ }
+ // transition state
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Establishing);
+ return (this.sendAccept()
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ .then(({ message, session }) => {
+ session.delegate = {
+ onAck: (ackRequest) => this.onAckRequest(ackRequest),
+ onAckTimeout: () => this.onAckTimeout(),
+ onBye: (byeRequest) => this.onByeRequest(byeRequest),
+ onInfo: (infoRequest) => this.onInfoRequest(infoRequest),
+ onInvite: (inviteRequest) => this.onInviteRequest(inviteRequest),
+ onMessage: (messageRequest) => this.onMessageRequest(messageRequest),
+ onNotify: (notifyRequest) => this.onNotifyRequest(notifyRequest),
+ onPrack: (prackRequest) => this.onPrackRequest(prackRequest),
+ onRefer: (referRequest) => this.onReferRequest(referRequest)
+ };
+ this._dialog = session;
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Established);
+ // TODO: Reconsider this "automagic" send of a BYE to replacee behavior.
+ // This behavior has been ported forward from legacy versions.
+ if (this._replacee) {
+ this._replacee._bye();
+ }
+ })
+ .catch((error) => this.handleResponseError(error)));
+ }
+ /**
+ * Indicate progress processing the invitation.
+ *
+ * @remarks
+ * Report progress to the the caller.
+ * Replies to the INVITE request with a 1xx provisional response.
+ * Resolves once the response sent, otherwise rejects.
+ * @param options - Options bucket.
+ */
+ progress(options = {}) {
+ this.logger.log("Invitation.progress");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial) {
+ const error = new Error(`Invalid session state ${this.state}`);
+ this.logger.error(error.message);
+ return Promise.reject(error);
+ }
+ // Ported
+ const statusCode = options.statusCode || 180;
+ if (statusCode < 100 || statusCode > 199) {
+ throw new TypeError("Invalid statusCode: " + statusCode);
+ }
+ // Modifiers and options for initial INVITE transaction
+ if (options.sessionDescriptionHandlerModifiers) {
+ this.sessionDescriptionHandlerModifiers = options.sessionDescriptionHandlerModifiers;
+ }
+ if (options.sessionDescriptionHandlerOptions) {
+ this.sessionDescriptionHandlerOptions = options.sessionDescriptionHandlerOptions;
+ }
+ // After the first reliable provisional response for a request has been
+ // acknowledged, the UAS MAY send additional reliable provisional
+ // responses. The UAS MUST NOT send a second reliable provisional
+ // response until the first is acknowledged. After the first, it is
+ // RECOMMENDED that the UAS not send an additional reliable provisional
+ // response until the previous is acknowledged. The first reliable
+ // provisional response receives special treatment because it conveys
+ // the initial sequence number. If additional reliable provisional
+ // responses were sent before the first was acknowledged, the UAS could
+ // not be certain these were received in order.
+ // https://tools.ietf.org/html/rfc3262#section-3
+ if (this.waitingForPrack) {
+ this.logger.warn("Unexpected call for progress while waiting for prack, ignoring");
+ return Promise.resolve();
+ }
+ // Trying provisional response
+ if (options.statusCode === 100) {
+ return this.sendProgressTrying()
+ .then(() => {
+ return;
+ })
+ .catch((error) => this.handleResponseError(error));
+ }
+ // Standard provisional response
+ if (!(this.rel100 === "required") &&
+ !(this.rel100 === "supported" && options.rel100) &&
+ !(this.rel100 === "supported" && this.userAgent.configuration.sipExtension100rel === _user_agent_options__WEBPACK_IMPORTED_MODULE_5__["SIPExtension"].Required)) {
+ return this.sendProgress(options)
+ .then(() => {
+ return;
+ })
+ .catch((error) => this.handleResponseError(error));
+ }
+ // Reliable provisional response
+ return this.sendProgressReliableWaitForPrack(options)
+ .then(() => {
+ return;
+ })
+ .catch((error) => this.handleResponseError(error));
+ }
+ /**
+ * Reject the invitation.
+ *
+ * @remarks
+ * Replies to the INVITE request with a 4xx, 5xx, or 6xx final response.
+ * Resolves once the response sent, otherwise rejects.
+ *
+ * The expectation is that this method is used to reject an INVITE request.
+ * That is indeed the case - a call to `progress` followed by `reject` is
+ * a typical way to "decline" an incoming INVITE request. However it may
+ * also be called after calling `accept` (but only before it completes)
+ * which will reject the call and cause `accept` to reject.
+ * @param options - Options bucket.
+ */
+ reject(options = {}) {
+ this.logger.log("Invitation.reject");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial && this.state !== _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Establishing) {
+ const error = new Error(`Invalid session state ${this.state}`);
+ this.logger.error(error.message);
+ return Promise.reject(error);
+ }
+ const statusCode = options.statusCode || 480;
+ const reasonPhrase = options.reasonPhrase ? options.reasonPhrase : Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["getReasonPhrase"])(statusCode);
+ const extraHeaders = options.extraHeaders || [];
+ if (statusCode < 300 || statusCode > 699) {
+ throw new TypeError("Invalid statusCode: " + statusCode);
+ }
+ const body = options.body ? Object(_core__WEBPACK_IMPORTED_MODULE_0__["fromBodyLegacy"])(options.body) : undefined;
+ // FIXME: Need to redirect to someplace
+ statusCode < 400
+ ? this.incomingInviteRequest.redirect([], { statusCode, reasonPhrase, extraHeaders, body })
+ : this.incomingInviteRequest.reject({ statusCode, reasonPhrase, extraHeaders, body });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated);
+ return Promise.resolve();
+ }
+ /**
+ * Handle CANCEL request.
+ *
+ * @param message - CANCEL message.
+ * @internal
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _onCancel(message) {
+ this.logger.log("Invitation._onCancel");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial && this.state !== _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Establishing) {
+ this.logger.error(`CANCEL received while in state ${this.state}, dropping request`);
+ return;
+ }
+ // flag canceled
+ this.isCanceled = true;
+ // reject INVITE with 487 status code
+ this.incomingInviteRequest.reject({ statusCode: 487 });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated);
+ }
+ /**
+ * Helper function to handle offer/answer in a PRACK.
+ */
+ handlePrackOfferAnswer(request) {
+ if (!this.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ // If the PRACK doesn't have an offer/answer, nothing to be done.
+ const body = Object(_core__WEBPACK_IMPORTED_MODULE_0__["getBody"])(request.message);
+ if (!body || body.contentDisposition !== "session") {
+ return Promise.resolve(undefined);
+ }
+ const options = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers
+ };
+ // If the UAC receives a reliable provisional response with an offer
+ // (this would occur if the UAC sent an INVITE without an offer, in
+ // which case the first reliable provisional response will contain the
+ // offer), it MUST generate an answer in the PRACK. If the UAC receives
+ // a reliable provisional response with an answer, it MAY generate an
+ // additional offer in the PRACK. If the UAS receives a PRACK with an
+ // offer, it MUST place the answer in the 2xx to the PRACK.
+ // https://tools.ietf.org/html/rfc3262#section-5
+ switch (this.dialog.signalingState) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Initial:
+ // State should never be reached as first reliable provisional response must have answer/offer.
+ throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Stable:
+ // Receved answer.
+ return this.setAnswer(body, options).then(() => undefined);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveLocalOffer:
+ // State should never be reached as local offer would be answered by this PRACK
+ throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveRemoteOffer:
+ // Received offer, generate answer.
+ return this.setOfferAndGetAnswer(body, options);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Closed:
+ throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
+ default:
+ throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
+ }
+ }
+ /**
+ * A handler for errors which occur while attempting to send 1xx and 2xx responses.
+ * In all cases, an attempt is made to reject the request if it is still outstanding.
+ * And while there are a variety of things which can go wrong and we log something here
+ * for all errors, there are a handful of common exceptions we pay some extra attention to.
+ * @param error - The error which occurred.
+ */
+ handleResponseError(error) {
+ let statusCode = 480; // "Temporarily Unavailable"
+ // Log Error message
+ if (error instanceof Error) {
+ this.logger.error(error.message);
+ }
+ else {
+ // We don't actually know what a session description handler implementation might throw our way,
+ // and more generally as a last resort catch all, just assume we are getting an "unknown" and log it.
+ this.logger.error(error);
+ }
+ // Log Exception message
+ if (error instanceof _exceptions__WEBPACK_IMPORTED_MODULE_2__["ContentTypeUnsupportedError"]) {
+ this.logger.error("A session description handler occurred while sending response (content type unsupported");
+ statusCode = 415; // "Unsupported Media Type"
+ }
+ else if (error instanceof _exceptions__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandlerError"]) {
+ this.logger.error("A session description handler occurred while sending response");
+ }
+ else if (error instanceof _exceptions__WEBPACK_IMPORTED_MODULE_2__["SessionTerminatedError"]) {
+ this.logger.error("Session ended before response could be formulated and sent (while waiting for PRACK)");
+ }
+ else if (error instanceof _core__WEBPACK_IMPORTED_MODULE_0__["TransactionStateError"]) {
+ this.logger.error("Session changed state before response could be formulated and sent");
+ }
+ // Reject if still in "initial" or "establishing" state.
+ if (this.state === _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Initial || this.state === _session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Establishing) {
+ try {
+ this.incomingInviteRequest.reject({ statusCode });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated);
+ }
+ catch (e) {
+ this.logger.error("An error occurred attempting to reject the request while handling another error");
+ throw e; // This is not a good place to be...
+ }
+ }
+ // FIXME: TODO:
+ // Here we are squelching the throwing of errors due to an race condition.
+ // We have an internal race between calling `accept()` and handling an incoming
+ // CANCEL request. As there is no good way currently to delegate the handling of
+ // these race errors to the caller of `accept()`, we are squelching the throwing
+ // of ALL errors when/if they occur after receiving a CANCEL to catch the ONE we know
+ // is a "normal" exceptional condition. While this is a completely reasonable approach,
+ // the decision should be left up to the library user. Furthermore, as we are eating
+ // ALL errors in this case, we are potentially (likely) hiding "real" errors which occur.
+ //
+ // Only rethrow error if the session has not been canceled.
+ if (this.isCanceled) {
+ this.logger.warn("An error occurred while attempting to formulate and send a response to an incoming INVITE." +
+ " However a CANCEL was received and processed while doing so which can (and often does) result" +
+ " in errors occurring as the session terminates in the meantime. Said error is being ignored.");
+ return;
+ }
+ throw error;
+ }
+ /**
+ * Callback for when ACK for a 2xx response is never received.
+ * @param session - Session the ACK never arrived for.
+ */
+ onAckTimeout() {
+ this.logger.log("Invitation.onAckTimeout");
+ if (!this.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ this.logger.log("No ACK received for an extended period of time, terminating session");
+ this.dialog.bye();
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_4__["SessionState"].Terminated);
+ }
+ /**
+ * A version of `accept` which resolves a session when the 200 Ok response is sent.
+ * @param options - Options bucket.
+ */
+ sendAccept() {
+ const responseOptions = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers
+ };
+ // The UAS MAY send a final response to the initial request before
+ // having received PRACKs for all unacknowledged reliable provisional
+ // responses, unless the final response is 2xx and any of the
+ // unacknowledged reliable provisional responses contained a session
+ // description. In that case, it MUST NOT send a final response until
+ // those provisional responses are acknowledged. If the UAS does send a
+ // final response when reliable responses are still unacknowledged, it
+ // SHOULD NOT continue to retransmit the unacknowledged reliable
+ // provisional responses, but it MUST be prepared to process PRACK
+ // requests for those outstanding responses. A UAS MUST NOT send new
+ // reliable provisional responses (as opposed to retransmissions of
+ // unacknowledged ones) after sending a final response to a request.
+ // https://tools.ietf.org/html/rfc3262#section-3
+ if (this.waitingForPrack) {
+ return this.waitForArrivalOfPrack()
+ .then(() => clearTimeout(this.userNoAnswerTimer)) // Ported
+ .then(() => this.generateResponseOfferAnswer(this.incomingInviteRequest, responseOptions))
+ .then((body) => this.incomingInviteRequest.accept({ statusCode: 200, body }));
+ }
+ clearTimeout(this.userNoAnswerTimer); // Ported
+ return this.generateResponseOfferAnswer(this.incomingInviteRequest, responseOptions).then((body) => this.incomingInviteRequest.accept({ statusCode: 200, body }));
+ }
+ /**
+ * A version of `progress` which resolves when the provisional response is sent.
+ * @param options - Options bucket.
+ */
+ sendProgress(options = {}) {
+ const statusCode = options.statusCode || 180;
+ const reasonPhrase = options.reasonPhrase;
+ const extraHeaders = (options.extraHeaders || []).slice();
+ const body = options.body ? Object(_core__WEBPACK_IMPORTED_MODULE_0__["fromBodyLegacy"])(options.body) : undefined;
+ // The 183 (Session Progress) response is used to convey information
+ // about the progress of the call that is not otherwise classified. The
+ // Reason-Phrase, header fields, or message body MAY be used to convey
+ // more details about the call progress.
+ // https://tools.ietf.org/html/rfc3261#section-21.1.5
+ // It is the de facto industry standard to utilize 183 with SDP to provide "early media".
+ // While it is unlikely someone would want to send a 183 without SDP, so it should be an option.
+ if (statusCode === 183 && !body) {
+ return this.sendProgressWithSDP(options);
+ }
+ try {
+ const progressResponse = this.incomingInviteRequest.progress({ statusCode, reasonPhrase, extraHeaders, body });
+ this._dialog = progressResponse.session;
+ return Promise.resolve(progressResponse);
+ }
+ catch (error) {
+ return Promise.reject(error);
+ }
+ }
+ /**
+ * A version of `progress` which resolves when the provisional response with sdp is sent.
+ * @param options - Options bucket.
+ */
+ sendProgressWithSDP(options = {}) {
+ const responseOptions = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers
+ };
+ const statusCode = options.statusCode || 183;
+ const reasonPhrase = options.reasonPhrase;
+ const extraHeaders = (options.extraHeaders || []).slice();
+ // Get an offer/answer and send a reply.
+ return this.generateResponseOfferAnswer(this.incomingInviteRequest, responseOptions)
+ .then((body) => this.incomingInviteRequest.progress({ statusCode, reasonPhrase, extraHeaders, body }))
+ .then((progressResponse) => {
+ this._dialog = progressResponse.session;
+ return progressResponse;
+ });
+ }
+ /**
+ * A version of `progress` which resolves when the reliable provisional response is sent.
+ * @param options - Options bucket.
+ */
+ sendProgressReliable(options = {}) {
+ options.extraHeaders = (options.extraHeaders || []).slice();
+ options.extraHeaders.push("Require: 100rel");
+ options.extraHeaders.push("RSeq: " + Math.floor(Math.random() * 10000));
+ return this.sendProgressWithSDP(options);
+ }
+ /**
+ * A version of `progress` which resolves when the reliable provisional response is acknowledged.
+ * @param options - Options bucket.
+ */
+ sendProgressReliableWaitForPrack(options = {}) {
+ const responseOptions = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers
+ };
+ const statusCode = options.statusCode || 183;
+ const reasonPhrase = options.reasonPhrase;
+ const extraHeaders = (options.extraHeaders || []).slice();
+ extraHeaders.push("Require: 100rel");
+ extraHeaders.push("RSeq: " + this.rseq++);
+ let body;
+ return new Promise((resolve, reject) => {
+ this.waitingForPrack = true;
+ this.generateResponseOfferAnswer(this.incomingInviteRequest, responseOptions)
+ .then((offerAnswer) => {
+ body = offerAnswer;
+ return this.incomingInviteRequest.progress({ statusCode, reasonPhrase, extraHeaders, body });
+ })
+ .then((progressResponse) => {
+ this._dialog = progressResponse.session;
+ let prackRequest;
+ let prackResponse;
+ progressResponse.session.delegate = {
+ onPrack: (request) => {
+ prackRequest = request;
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ clearTimeout(prackWaitTimeoutTimer);
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ clearTimeout(rel1xxRetransmissionTimer);
+ if (!this.waitingForPrack) {
+ return;
+ }
+ this.waitingForPrack = false;
+ this.handlePrackOfferAnswer(prackRequest)
+ .then((prackResponseBody) => {
+ try {
+ prackResponse = prackRequest.accept({ statusCode: 200, body: prackResponseBody });
+ this.prackArrived();
+ resolve({ prackRequest, prackResponse, progressResponse });
+ }
+ catch (error) {
+ reject(error);
+ }
+ })
+ .catch((error) => reject(error));
+ }
+ };
+ // https://tools.ietf.org/html/rfc3262#section-3
+ const prackWaitTimeout = () => {
+ if (!this.waitingForPrack) {
+ return;
+ }
+ this.waitingForPrack = false;
+ this.logger.warn("No PRACK received, rejecting INVITE.");
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ clearTimeout(rel1xxRetransmissionTimer);
+ this.reject({ statusCode: 504 })
+ .then(() => reject(new _exceptions__WEBPACK_IMPORTED_MODULE_2__["SessionTerminatedError"]()))
+ .catch((error) => reject(error));
+ };
+ const prackWaitTimeoutTimer = setTimeout(prackWaitTimeout, _core__WEBPACK_IMPORTED_MODULE_0__["Timers"].T1 * 64);
+ // https://tools.ietf.org/html/rfc3262#section-3
+ const rel1xxRetransmission = () => {
+ try {
+ this.incomingInviteRequest.progress({ statusCode, reasonPhrase, extraHeaders, body });
+ }
+ catch (error) {
+ this.waitingForPrack = false;
+ reject(error);
+ return;
+ }
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ rel1xxRetransmissionTimer = setTimeout(rel1xxRetransmission, (timeout *= 2));
+ };
+ let timeout = _core__WEBPACK_IMPORTED_MODULE_0__["Timers"].T1;
+ let rel1xxRetransmissionTimer = setTimeout(rel1xxRetransmission, timeout);
+ })
+ .catch((error) => {
+ this.waitingForPrack = false;
+ reject(error);
+ });
+ });
+ }
+ /**
+ * A version of `progress` which resolves when a 100 Trying provisional response is sent.
+ */
+ sendProgressTrying() {
+ try {
+ const progressResponse = this.incomingInviteRequest.trying();
+ return Promise.resolve(progressResponse);
+ }
+ catch (error) {
+ return Promise.reject(error);
+ }
+ }
+ /**
+ * When attempting to accept the INVITE, an invitation waits
+ * for any outstanding PRACK to arrive before sending the 200 Ok.
+ * It will be waiting on this Promise to resolve which lets it know
+ * the PRACK has arrived and it may proceed to send the 200 Ok.
+ */
+ waitForArrivalOfPrack() {
+ if (this.waitingForPrackPromise) {
+ throw new Error("Already waiting for PRACK");
+ }
+ this.waitingForPrackPromise = new Promise((resolve, reject) => {
+ this.waitingForPrackResolve = resolve;
+ this.waitingForPrackReject = reject;
+ });
+ return this.waitingForPrackPromise;
+ }
+ /**
+ * Here we are resolving the promise which in turn will cause
+ * the accept to proceed (it may still fail for other reasons, but...).
+ */
+ prackArrived() {
+ if (this.waitingForPrackResolve) {
+ this.waitingForPrackResolve();
+ }
+ this.waitingForPrackPromise = undefined;
+ this.waitingForPrackResolve = undefined;
+ this.waitingForPrackReject = undefined;
+ }
+ /**
+ * Here we are rejecting the promise which in turn will cause
+ * the accept to fail and the session to transition to "terminated".
+ */
+ prackNeverArrived() {
+ if (this.waitingForPrackReject) {
+ this.waitingForPrackReject(new _exceptions__WEBPACK_IMPORTED_MODULE_2__["SessionTerminatedError"]());
+ }
+ this.waitingForPrackPromise = undefined;
+ this.waitingForPrackResolve = undefined;
+ this.waitingForPrackReject = undefined;
+ }
+}
+
+
+/***/ }),
+/* 114 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Session", function() { return Session; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _core_messages_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+/* harmony import */ var _core_user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(82);
+/* harmony import */ var _bye__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(107);
+/* harmony import */ var _emitter__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(108);
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(3);
+/* harmony import */ var _info__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(109);
+/* harmony import */ var _message__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(115);
+/* harmony import */ var _notification__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(116);
+/* harmony import */ var _referral__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(117);
+/* harmony import */ var _session_state__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(118);
+
+
+
+
+
+
+
+
+
+
+
+/**
+ * A session provides real time communication between one or more participants.
+ *
+ * @remarks
+ * The transport behaves in a deterministic manner according to the
+ * the state defined in {@link SessionState}.
+ * @public
+ */
+class Session {
+ /**
+ * Constructor.
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @internal
+ */
+ constructor(userAgent, options = {}) {
+ /** True if there is an outgoing re-INVITE request outstanding. */
+ this.pendingReinvite = false;
+ /** True if there is an incoming re-INVITE ACK request outstanding. */
+ this.pendingReinviteAck = false;
+ /** Session state. */
+ this._state = _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Initial;
+ this.delegate = options.delegate;
+ this._stateEventEmitter = new _emitter__WEBPACK_IMPORTED_MODULE_4__["EmitterImpl"]();
+ this._userAgent = userAgent;
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ this.logger.log(`Session ${this.id} in state ${this._state} is being disposed`);
+ // Remove from the user agent's session collection
+ delete this.userAgent._sessions[this.id];
+ // Dispose of dialog media
+ if (this._sessionDescriptionHandler) {
+ this._sessionDescriptionHandler.close();
+ // TODO: The SDH needs to remain defined as it will be called after it is closed in cases
+ // where an answer/offer arrives while the session is being torn down. There are a variety
+ // of circumstances where this can happen - sending a BYE during a re-INVITE for example.
+ // The code is currently written such that it lazily makes a new SDH when it needs one
+ // and one is not yet defined. Thus if we undefined it here, it will currently make a
+ // new one which is out of sync and then never gets cleaned up.
+ //
+ // The downside of leaving it defined are that calls this closed SDH will continue to be
+ // made (think setDescription) and those should/will fail. These failures are handled, but
+ // it would be nice to have it all coded up in a way where having an undefined SDH where
+ // one is expected throws an error.
+ //
+ // this._sessionDescriptionHandler = undefined;
+ }
+ switch (this.state) {
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Initial:
+ break; // the Inviter/Invitation sub class dispose method handles this case
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Establishing:
+ break; // the Inviter/Invitation sub class dispose method handles this case
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established:
+ return new Promise((resolve) => {
+ this._bye({
+ // wait for the response to the BYE before resolving
+ onAccept: () => resolve(),
+ onRedirect: () => resolve(),
+ onReject: () => resolve()
+ });
+ });
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating:
+ break; // nothing to be done
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated:
+ break; // nothing to be done
+ default:
+ throw new Error("Unknown state.");
+ }
+ return Promise.resolve();
+ }
+ /**
+ * The asserted identity of the remote user.
+ */
+ get assertedIdentity() {
+ return this._assertedIdentity;
+ }
+ /**
+ * The confirmed session dialog.
+ */
+ get dialog() {
+ return this._dialog;
+ }
+ /**
+ * A unique identifier for this session.
+ */
+ get id() {
+ return this._id;
+ }
+ /**
+ * The session being replace by this one.
+ */
+ get replacee() {
+ return this._replacee;
+ }
+ /**
+ * Session description handler.
+ * @remarks
+ * If `this` is an instance of `Invitation`,
+ * `sessionDescriptionHandler` will be defined when the session state changes to "established".
+ * If `this` is an instance of `Inviter` and an offer was sent in the INVITE,
+ * `sessionDescriptionHandler` will be defined when the session state changes to "establishing".
+ * If `this` is an instance of `Inviter` and an offer was not sent in the INVITE,
+ * `sessionDescriptionHandler` will be defined when the session state changes to "established".
+ * Otherwise `undefined`.
+ */
+ get sessionDescriptionHandler() {
+ return this._sessionDescriptionHandler;
+ }
+ /**
+ * Session description handler factory.
+ */
+ get sessionDescriptionHandlerFactory() {
+ return this.userAgent.configuration.sessionDescriptionHandlerFactory;
+ }
+ /**
+ * SDH modifiers for the initial INVITE transaction.
+ * @remarks
+ * Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
+ * May be set directly at anytime.
+ * May optionally be set via constructor option.
+ * May optionally be set via options passed to Inviter.invite() or Invitation.accept().
+ */
+ get sessionDescriptionHandlerModifiers() {
+ return this._sessionDescriptionHandlerModifiers || [];
+ }
+ set sessionDescriptionHandlerModifiers(modifiers) {
+ this._sessionDescriptionHandlerModifiers = modifiers.slice();
+ }
+ /**
+ * SDH options for the initial INVITE transaction.
+ * @remarks
+ * Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
+ * May be set directly at anytime.
+ * May optionally be set via constructor option.
+ * May optionally be set via options passed to Inviter.invite() or Invitation.accept().
+ */
+ get sessionDescriptionHandlerOptions() {
+ return this._sessionDescriptionHandlerOptions || {};
+ }
+ set sessionDescriptionHandlerOptions(options) {
+ this._sessionDescriptionHandlerOptions = Object.assign({}, options);
+ }
+ /**
+ * SDH modifiers for re-INVITE transactions.
+ * @remarks
+ * Used in all cases when handling a re-INVITE transaction as either UAC or UAS.
+ * May be set directly at anytime.
+ * May optionally be set via constructor option.
+ * May optionally be set via options passed to Session.invite().
+ */
+ get sessionDescriptionHandlerModifiersReInvite() {
+ return this._sessionDescriptionHandlerModifiersReInvite || [];
+ }
+ set sessionDescriptionHandlerModifiersReInvite(modifiers) {
+ this._sessionDescriptionHandlerModifiersReInvite = modifiers.slice();
+ }
+ /**
+ * SDH options for re-INVITE transactions.
+ * @remarks
+ * Used in all cases when handling a re-INVITE transaction as either UAC or UAS.
+ * May be set directly at anytime.
+ * May optionally be set via constructor option.
+ * May optionally be set via options passed to Session.invite().
+ */
+ get sessionDescriptionHandlerOptionsReInvite() {
+ return this._sessionDescriptionHandlerOptionsReInvite || {};
+ }
+ set sessionDescriptionHandlerOptionsReInvite(options) {
+ this._sessionDescriptionHandlerOptionsReInvite = Object.assign({}, options);
+ }
+ /**
+ * SDH modifiers for the initial INVITE transaction, after ice gathering
+ * is complete.
+ * @remarks
+ * Used in all cases when handling the initial INVITE transaction as either UAC or UAS.
+ * May be set directly at anytime.
+ * May optionally be set via constructor option.
+ * May optionally be set via options passed to Inviter.invite() or Invitation.accept().
+ */
+ get sessionDescriptionHandlerModifiersPostICEGathering() {
+ return this._sessionDescriptionHandlerModifiersPostICEGathering || [];
+ }
+ set sessionDescriptionHandlerModifiersPostICEGathering(modifiers) {
+ this._sessionDescriptionHandlerModifiersPostICEGathering = modifiers.slice();
+ }
+ /**
+ * Session state.
+ */
+ get state() {
+ return this._state;
+ }
+ /**
+ * Session state change emitter.
+ */
+ get stateChange() {
+ return this._stateEventEmitter;
+ }
+ /**
+ * The user agent.
+ */
+ get userAgent() {
+ return this._userAgent;
+ }
+ /**
+ * End the {@link Session}. Sends a BYE.
+ * @param options - Options bucket. See {@link SessionByeOptions} for details.
+ */
+ bye(options = {}) {
+ let message = "Session.bye() may only be called if established session.";
+ switch (this.state) {
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Initial:
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (typeof this.cancel === "function") {
+ message += " However Inviter.invite() has not yet been called.";
+ message += " Perhaps you should have called Inviter.cancel()?";
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }
+ else if (typeof this.reject === "function") {
+ message += " However Invitation.accept() has not yet been called.";
+ message += " Perhaps you should have called Invitation.reject()?";
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Establishing:
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (typeof this.cancel === "function") {
+ message += " However a dialog does not yet exist.";
+ message += " Perhaps you should have called Inviter.cancel()?";
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }
+ else if (typeof this.reject === "function") {
+ message += " However Invitation.accept() has not yet been called (or not yet resolved).";
+ message += " Perhaps you should have called Invitation.reject()?";
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established: {
+ const requestDelegate = options.requestDelegate;
+ const requestOptions = this.copyRequestOptions(options.requestOptions);
+ return this._bye(requestDelegate, requestOptions);
+ }
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating:
+ message += " However this session is already terminating.";
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (typeof this.cancel === "function") {
+ message += " Perhaps you have already called Inviter.cancel()?";
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }
+ else if (typeof this.reject === "function") {
+ message += " Perhaps you have already called Session.bye()?";
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated:
+ message += " However this session is already terminated.";
+ break;
+ default:
+ throw new Error("Unknown state");
+ }
+ this.logger.error(message);
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ /**
+ * Share {@link Info} with peer. Sends an INFO.
+ * @param options - Options bucket. See {@link SessionInfoOptions} for details.
+ */
+ info(options = {}) {
+ // guard session state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ const message = "Session.info() may only be called if established session.";
+ this.logger.error(message);
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ const requestDelegate = options.requestDelegate;
+ const requestOptions = this.copyRequestOptions(options.requestOptions);
+ return this._info(requestDelegate, requestOptions);
+ }
+ /**
+ * Renegotiate the session. Sends a re-INVITE.
+ * @param options - Options bucket. See {@link SessionInviteOptions} for details.
+ */
+ invite(options = {}) {
+ this.logger.log("Session.invite");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ if (this.pendingReinvite) {
+ return Promise.reject(new _exceptions__WEBPACK_IMPORTED_MODULE_5__["RequestPendingError"]("Reinvite in progress. Please wait until complete, then try again."));
+ }
+ this.pendingReinvite = true;
+ // Modifiers and options for initial INVITE transaction
+ if (options.sessionDescriptionHandlerModifiers) {
+ this.sessionDescriptionHandlerModifiersReInvite = options.sessionDescriptionHandlerModifiers;
+ }
+ if (options.sessionDescriptionHandlerOptions) {
+ this.sessionDescriptionHandlerOptionsReInvite = options.sessionDescriptionHandlerOptions;
+ }
+ const delegate = {
+ onAccept: (response) => {
+ // A re-INVITE transaction has an offer/answer [RFC3264] exchange
+ // associated with it. The UAC (User Agent Client) generating a given
+ // re-INVITE can act as the offerer or as the answerer. A UAC willing
+ // to act as the offerer includes an offer in the re-INVITE. The UAS
+ // (User Agent Server) then provides an answer in a response to the
+ // re-INVITE. A UAC willing to act as answerer does not include an
+ // offer in the re-INVITE. The UAS then provides an offer in a response
+ // to the re-INVITE becoming, thus, the offerer.
+ // https://tools.ietf.org/html/rfc6141#section-1
+ const body = Object(_core__WEBPACK_IMPORTED_MODULE_0__["getBody"])(response.message);
+ if (!body) {
+ // No way to recover, so terminate session and mark as failed.
+ this.logger.error("Received 2xx response to re-INVITE without a session description");
+ this.ackAndBye(response, 400, "Missing session description");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ this.pendingReinvite = false;
+ return;
+ }
+ if (options.withoutSdp) {
+ // INVITE without SDP - set remote offer and send an answer in the ACK
+ const answerOptions = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
+ };
+ this.setOfferAndGetAnswer(body, answerOptions)
+ .then((answerBody) => {
+ response.ack({ body: answerBody });
+ })
+ .catch((error) => {
+ // No way to recover, so terminate session and mark as failed.
+ this.logger.error("Failed to handle offer in 2xx response to re-INVITE");
+ this.logger.error(error.message);
+ if (this.state === _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ // A BYE should not be sent if already terminated.
+ // For example, a BYE may be sent/received while re-INVITE is outstanding.
+ response.ack();
+ }
+ else {
+ this.ackAndBye(response, 488, "Bad Media Description");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ }
+ })
+ .then(() => {
+ this.pendingReinvite = false;
+ if (options.requestDelegate && options.requestDelegate.onAccept) {
+ options.requestDelegate.onAccept(response);
+ }
+ });
+ }
+ else {
+ // INVITE with SDP - set remote answer and send an ACK
+ const answerOptions = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
+ };
+ this.setAnswer(body, answerOptions)
+ .then(() => {
+ response.ack();
+ })
+ .catch((error) => {
+ // No way to recover, so terminate session and mark as failed.
+ this.logger.error("Failed to handle answer in 2xx response to re-INVITE");
+ this.logger.error(error.message);
+ // A BYE should only be sent if session is not already terminated.
+ // For example, a BYE may be sent/received while re-INVITE is outstanding.
+ // The ACK needs to be sent regardless as it was not handled by the transaction.
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ this.ackAndBye(response, 488, "Bad Media Description");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ }
+ else {
+ response.ack();
+ }
+ })
+ .then(() => {
+ this.pendingReinvite = false;
+ if (options.requestDelegate && options.requestDelegate.onAccept) {
+ options.requestDelegate.onAccept(response);
+ }
+ });
+ }
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onProgress: (response) => {
+ return;
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onRedirect: (response) => {
+ return;
+ },
+ onReject: (response) => {
+ this.logger.warn("Received a non-2xx response to re-INVITE");
+ this.pendingReinvite = false;
+ if (options.withoutSdp) {
+ if (options.requestDelegate && options.requestDelegate.onReject) {
+ options.requestDelegate.onReject(response);
+ }
+ }
+ else {
+ this.rollbackOffer()
+ .catch((error) => {
+ // No way to recover, so terminate session and mark as failed.
+ this.logger.error("Failed to rollback offer on non-2xx response to re-INVITE");
+ this.logger.error(error.message);
+ // A BYE should only be sent if session is not already terminated.
+ // For example, a BYE may be sent/received while re-INVITE is outstanding.
+ // Note that the ACK was already sent by the transaction, so just need to send BYE.
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ if (!this.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ const extraHeaders = [];
+ extraHeaders.push("Reason: " + this.getReasonHeaderValue(500, "Internal Server Error"));
+ this.dialog.bye(undefined, { extraHeaders });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ }
+ })
+ .then(() => {
+ if (options.requestDelegate && options.requestDelegate.onReject) {
+ options.requestDelegate.onReject(response);
+ }
+ });
+ }
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onTrying: (response) => {
+ return;
+ }
+ };
+ const requestOptions = options.requestOptions || {};
+ requestOptions.extraHeaders = (requestOptions.extraHeaders || []).slice();
+ requestOptions.extraHeaders.push("Allow: " + _core_user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_2__["AllowedMethods"].toString());
+ requestOptions.extraHeaders.push("Contact: " + this._contact);
+ // Just send an INVITE with no sdp...
+ if (options.withoutSdp) {
+ if (!this.dialog) {
+ this.pendingReinvite = false;
+ throw new Error("Dialog undefined.");
+ }
+ return Promise.resolve(this.dialog.invite(delegate, requestOptions));
+ }
+ // Get an offer and send it in an INVITE
+ const offerOptions = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
+ };
+ return this.getOffer(offerOptions)
+ .then((offerBody) => {
+ if (!this.dialog) {
+ this.pendingReinvite = false;
+ throw new Error("Dialog undefined.");
+ }
+ requestOptions.body = offerBody;
+ return this.dialog.invite(delegate, requestOptions);
+ })
+ .catch((error) => {
+ this.logger.error(error.message);
+ this.logger.error("Failed to send re-INVITE");
+ this.pendingReinvite = false;
+ throw error;
+ });
+ }
+ /**
+ * Deliver a {@link Message}. Sends a MESSAGE.
+ * @param options - Options bucket. See {@link SessionMessageOptions} for details.
+ */
+ message(options = {}) {
+ // guard session state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ const message = "Session.message() may only be called if established session.";
+ this.logger.error(message);
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ const requestDelegate = options.requestDelegate;
+ const requestOptions = this.copyRequestOptions(options.requestOptions);
+ return this._message(requestDelegate, requestOptions);
+ }
+ /**
+ * Proffer a {@link Referral}. Send a REFER.
+ * @param referTo - The referral target. If a `Session`, a REFER w/Replaces is sent.
+ * @param options - Options bucket. See {@link SessionReferOptions} for details.
+ */
+ refer(referTo, options = {}) {
+ // guard session state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ const message = "Session.refer() may only be called if established session.";
+ this.logger.error(message);
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ const requestDelegate = options.requestDelegate;
+ const requestOptions = this.copyRequestOptions(options.requestOptions);
+ requestOptions.extraHeaders = requestOptions.extraHeaders
+ ? requestOptions.extraHeaders.concat(this.referExtraHeaders(this.referToString(referTo)))
+ : this.referExtraHeaders(this.referToString(referTo));
+ return this._refer(options.onNotify, requestDelegate, requestOptions);
+ }
+ /**
+ * Send BYE.
+ * @param delegate - Request delegate.
+ * @param options - Request options bucket.
+ * @internal
+ */
+ _bye(delegate, options) {
+ // Using core session dialog
+ if (!this.dialog) {
+ return Promise.reject(new Error("Session dialog undefined."));
+ }
+ const dialog = this.dialog;
+ // The caller's UA MAY send a BYE for either confirmed or early dialogs,
+ // and the callee's UA MAY send a BYE on confirmed dialogs, but MUST NOT
+ // send a BYE on early dialogs. However, the callee's UA MUST NOT send a
+ // BYE on a confirmed dialog until it has received an ACK for its 2xx
+ // response or until the server transaction times out.
+ // https://tools.ietf.org/html/rfc3261#section-15
+ switch (dialog.sessionState) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Initial:
+ throw new Error(`Invalid dialog state ${dialog.sessionState}`);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Early: // Implementation choice - not sending BYE for early dialogs.
+ throw new Error(`Invalid dialog state ${dialog.sessionState}`);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SessionState"].AckWait: {
+ // This state only occurs if we are the callee.
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating); // We're terminating
+ return new Promise((resolve) => {
+ dialog.delegate = {
+ // When ACK shows up, say BYE.
+ onAck: () => {
+ const request = dialog.bye(delegate, options);
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ resolve(request);
+ return Promise.resolve();
+ },
+ // Or the server transaction times out before the ACK arrives.
+ onAckTimeout: () => {
+ const request = dialog.bye(delegate, options);
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ resolve(request);
+ }
+ };
+ });
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Confirmed: {
+ const request = dialog.bye(delegate, options);
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ return Promise.resolve(request);
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Terminated:
+ throw new Error(`Invalid dialog state ${dialog.sessionState}`);
+ default:
+ throw new Error("Unrecognized state.");
+ }
+ }
+ /**
+ * Send INFO.
+ * @param delegate - Request delegate.
+ * @param options - Request options bucket.
+ * @internal
+ */
+ _info(delegate, options) {
+ // Using core session dialog
+ if (!this.dialog) {
+ return Promise.reject(new Error("Session dialog undefined."));
+ }
+ return Promise.resolve(this.dialog.info(delegate, options));
+ }
+ /**
+ * Send MESSAGE.
+ * @param delegate - Request delegate.
+ * @param options - Request options bucket.
+ * @internal
+ */
+ _message(delegate, options) {
+ // Using core session dialog
+ if (!this.dialog) {
+ return Promise.reject(new Error("Session dialog undefined."));
+ }
+ return Promise.resolve(this.dialog.message(delegate, options));
+ }
+ /**
+ * Send REFER.
+ * @param onNotify - Notification callback.
+ * @param delegate - Request delegate.
+ * @param options - Request options bucket.
+ * @internal
+ */
+ _refer(onNotify, delegate, options) {
+ // Using core session dialog
+ if (!this.dialog) {
+ return Promise.reject(new Error("Session dialog undefined."));
+ }
+ // If set, deliver any in-dialog NOTIFY requests here...
+ this.onNotify = onNotify;
+ return Promise.resolve(this.dialog.refer(delegate, options));
+ }
+ /**
+ * Send ACK and then BYE. There are unrecoverable errors which can occur
+ * while handling dialog forming and in-dialog INVITE responses and when
+ * they occur we ACK the response and send a BYE.
+ * Note that the BYE is sent in the dialog associated with the response
+ * which is not necessarily `this.dialog`. And, accordingly, the
+ * session state is not transitioned to terminated and session is not closed.
+ * @param inviteResponse - The response causing the error.
+ * @param statusCode - Status code for he reason phrase.
+ * @param reasonPhrase - Reason phrase for the BYE.
+ * @internal
+ */
+ ackAndBye(response, statusCode, reasonPhrase) {
+ response.ack();
+ const extraHeaders = [];
+ if (statusCode) {
+ extraHeaders.push("Reason: " + this.getReasonHeaderValue(statusCode, reasonPhrase));
+ }
+ // Using the dialog session associate with the response (which might not be this.dialog)
+ response.session.bye(undefined, { extraHeaders });
+ }
+ /**
+ * Handle in dialog ACK request.
+ * @internal
+ */
+ onAckRequest(request) {
+ this.logger.log("Session.onAckRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established && this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating) {
+ this.logger.error(`ACK received while in state ${this.state}, dropping request`);
+ return Promise.resolve();
+ }
+ const dialog = this.dialog;
+ if (!dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ // if received answer in ACK.
+ const answerOptions = {
+ sessionDescriptionHandlerOptions: this.pendingReinviteAck
+ ? this.sessionDescriptionHandlerOptionsReInvite
+ : this.sessionDescriptionHandlerOptions,
+ sessionDescriptionHandlerModifiers: this.pendingReinviteAck
+ ? this._sessionDescriptionHandlerModifiersReInvite
+ : this._sessionDescriptionHandlerModifiers
+ };
+ // reset pending ACK flag
+ this.pendingReinviteAck = false;
+ switch (dialog.signalingState) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Initial: {
+ // State should never be reached as first reliable response must have answer/offer.
+ // So we must have never has sent an offer.
+ this.logger.error(`Invalid signaling state ${dialog.signalingState}.`);
+ const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
+ dialog.bye(undefined, { extraHeaders });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ return Promise.resolve();
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Stable: {
+ // State we should be in.
+ // Either the ACK has the answer that got us here, or we were in this state prior to the ACK.
+ const body = Object(_core__WEBPACK_IMPORTED_MODULE_0__["getBody"])(request.message);
+ // If the ACK doesn't have an answer, nothing to be done.
+ if (!body) {
+ return Promise.resolve();
+ }
+ if (body.contentDisposition === "render") {
+ this._renderbody = body.content;
+ this._rendertype = body.contentType;
+ return Promise.resolve();
+ }
+ if (body.contentDisposition !== "session") {
+ return Promise.resolve();
+ }
+ return this.setAnswer(body, answerOptions).catch((error) => {
+ this.logger.error(error.message);
+ const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
+ dialog.bye(undefined, { extraHeaders });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ });
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveLocalOffer: {
+ // State should never be reached as local offer would be answered by this ACK.
+ // So we must have received an ACK without an answer.
+ this.logger.error(`Invalid signaling state ${dialog.signalingState}.`);
+ const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
+ dialog.bye(undefined, { extraHeaders });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ return Promise.resolve();
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveRemoteOffer: {
+ // State should never be reached as remote offer would be answered in first reliable response.
+ // So we must have never has sent an answer.
+ this.logger.error(`Invalid signaling state ${dialog.signalingState}.`);
+ const extraHeaders = ["Reason: " + this.getReasonHeaderValue(488, "Bad Media Description")];
+ dialog.bye(undefined, { extraHeaders });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ return Promise.resolve();
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Closed:
+ throw new Error(`Invalid signaling state ${dialog.signalingState}.`);
+ default:
+ throw new Error(`Invalid signaling state ${dialog.signalingState}.`);
+ }
+ }
+ /**
+ * Handle in dialog BYE request.
+ * @internal
+ */
+ onByeRequest(request) {
+ this.logger.log("Session.onByeRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`BYE received while in state ${this.state}, dropping request`);
+ return;
+ }
+ if (this.delegate && this.delegate.onBye) {
+ const bye = new _bye__WEBPACK_IMPORTED_MODULE_3__["Bye"](request);
+ this.delegate.onBye(bye);
+ }
+ else {
+ request.accept();
+ }
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ }
+ /**
+ * Handle in dialog INFO request.
+ * @internal
+ */
+ onInfoRequest(request) {
+ this.logger.log("Session.onInfoRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`INFO received while in state ${this.state}, dropping request`);
+ return;
+ }
+ if (this.delegate && this.delegate.onInfo) {
+ const info = new _info__WEBPACK_IMPORTED_MODULE_6__["Info"](request);
+ this.delegate.onInfo(info);
+ }
+ else {
+ // FIXME: TODO: We should reject request...
+ //
+ // If a UA receives an INFO request associated with an Info Package that
+ // the UA has not indicated willingness to receive, the UA MUST send a
+ // 469 (Bad Info Package) response (see Section 11.6), which contains a
+ // Recv-Info header field with Info Packages for which the UA is willing
+ // to receive INFO requests.
+ // https://tools.ietf.org/html/rfc6086#section-4.2.2
+ request.accept();
+ }
+ }
+ /**
+ * Handle in dialog INVITE request.
+ * @internal
+ */
+ onInviteRequest(request) {
+ this.logger.log("Session.onInviteRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`INVITE received while in state ${this.state}, dropping request`);
+ return;
+ }
+ // set pending ACK flag
+ this.pendingReinviteAck = true;
+ // TODO: would be nice to have core track and set the Contact header,
+ // but currently the session which is setting it is holding onto it.
+ const extraHeaders = ["Contact: " + this._contact];
+ // Handle P-Asserted-Identity
+ if (request.message.hasHeader("P-Asserted-Identity")) {
+ const header = request.message.getHeader("P-Asserted-Identity");
+ if (!header) {
+ throw new Error("Header undefined.");
+ }
+ this._assertedIdentity = _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].nameAddrHeaderParse(header);
+ }
+ const options = {
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptionsReInvite,
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiersReInvite
+ };
+ this.generateResponseOfferAnswerInDialog(options)
+ .then((body) => {
+ const outgoingResponse = request.accept({ statusCode: 200, extraHeaders, body });
+ if (this.delegate && this.delegate.onInvite) {
+ this.delegate.onInvite(request.message, outgoingResponse.message, 200);
+ }
+ })
+ .catch((error) => {
+ this.logger.error(error.message);
+ this.logger.error("Failed to handle to re-INVITE request");
+ if (!this.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ this.logger.error(this.dialog.signalingState);
+ // If we don't have a local/remote offer...
+ if (this.dialog.signalingState === _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Stable) {
+ const outgoingResponse = request.reject({ statusCode: 488 }); // Not Acceptable Here
+ if (this.delegate && this.delegate.onInvite) {
+ this.delegate.onInvite(request.message, outgoingResponse.message, 488);
+ }
+ return;
+ }
+ // Otherwise rollback
+ this.rollbackOffer()
+ .then(() => {
+ const outgoingResponse = request.reject({ statusCode: 488 }); // Not Acceptable Here
+ if (this.delegate && this.delegate.onInvite) {
+ this.delegate.onInvite(request.message, outgoingResponse.message, 488);
+ }
+ })
+ .catch((errorRollback) => {
+ // No way to recover, so terminate session and mark as failed.
+ this.logger.error(errorRollback.message);
+ this.logger.error("Failed to rollback offer on re-INVITE request");
+ const outgoingResponse = request.reject({ statusCode: 488 }); // Not Acceptable Here
+ // A BYE should only be sent if session is not already terminated.
+ // For example, a BYE may be sent/received while re-INVITE is outstanding.
+ // Note that the ACK was already sent by the transaction, so just need to send BYE.
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ if (!this.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ const extraHeadersBye = [];
+ extraHeadersBye.push("Reason: " + this.getReasonHeaderValue(500, "Internal Server Error"));
+ this.dialog.bye(undefined, { extraHeaders });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated);
+ }
+ if (this.delegate && this.delegate.onInvite) {
+ this.delegate.onInvite(request.message, outgoingResponse.message, 488);
+ }
+ });
+ });
+ }
+ /**
+ * Handle in dialog MESSAGE request.
+ * @internal
+ */
+ onMessageRequest(request) {
+ this.logger.log("Session.onMessageRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`MESSAGE received while in state ${this.state}, dropping request`);
+ return;
+ }
+ if (this.delegate && this.delegate.onMessage) {
+ const message = new _message__WEBPACK_IMPORTED_MODULE_7__["Message"](request);
+ this.delegate.onMessage(message);
+ }
+ else {
+ request.accept();
+ }
+ }
+ /**
+ * Handle in dialog NOTIFY request.
+ * @internal
+ */
+ onNotifyRequest(request) {
+ this.logger.log("Session.onNotifyRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`NOTIFY received while in state ${this.state}, dropping request`);
+ return;
+ }
+ // If this a NOTIFY associated with the progress of a REFER,
+ // look to delegate handling to the associated callback.
+ if (this.onNotify) {
+ const notification = new _notification__WEBPACK_IMPORTED_MODULE_8__["Notification"](request);
+ this.onNotify(notification);
+ return;
+ }
+ // Otherwise accept the NOTIFY.
+ if (this.delegate && this.delegate.onNotify) {
+ const notification = new _notification__WEBPACK_IMPORTED_MODULE_8__["Notification"](request);
+ this.delegate.onNotify(notification);
+ }
+ else {
+ request.accept();
+ }
+ }
+ /**
+ * Handle in dialog PRACK request.
+ * @internal
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onPrackRequest(request) {
+ this.logger.log("Session.onPrackRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`PRACK received while in state ${this.state}, dropping request`);
+ return;
+ }
+ throw new Error("Unimplemented.");
+ }
+ /**
+ * Handle in dialog REFER request.
+ * @internal
+ */
+ onReferRequest(request) {
+ this.logger.log("Session.onReferRequest");
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ this.logger.error(`REFER received while in state ${this.state}, dropping request`);
+ return;
+ }
+ // REFER is a SIP request and is constructed as defined in [1]. A REFER
+ // request MUST contain exactly one Refer-To header field value.
+ // https://tools.ietf.org/html/rfc3515#section-2.4.1
+ if (!request.message.hasHeader("refer-to")) {
+ this.logger.warn("Invalid REFER packet. A refer-to header is required. Rejecting.");
+ request.reject();
+ return;
+ }
+ const referral = new _referral__WEBPACK_IMPORTED_MODULE_9__["Referral"](request, this);
+ if (this.delegate && this.delegate.onRefer) {
+ this.delegate.onRefer(referral);
+ }
+ else {
+ this.logger.log("No delegate available to handle REFER, automatically accepting and following.");
+ referral
+ .accept()
+ .then(() => referral.makeInviter(this._referralInviterOptions).invite())
+ .catch((error) => {
+ // FIXME: logging and eating error...
+ this.logger.error(error.message);
+ });
+ }
+ }
+ /**
+ * Generate an offer or answer for a response to an INVITE request.
+ * If a remote offer was provided in the request, set the remote
+ * description and get a local answer. If a remote offer was not
+ * provided, generates a local offer.
+ * @internal
+ */
+ generateResponseOfferAnswer(request, options) {
+ if (this.dialog) {
+ return this.generateResponseOfferAnswerInDialog(options);
+ }
+ const body = Object(_core__WEBPACK_IMPORTED_MODULE_0__["getBody"])(request.message);
+ if (!body || body.contentDisposition !== "session") {
+ return this.getOffer(options);
+ }
+ else {
+ return this.setOfferAndGetAnswer(body, options);
+ }
+ }
+ /**
+ * Generate an offer or answer for a response to an INVITE request
+ * when a dialog (early or otherwise) has already been established.
+ * This method may NOT be called if a dialog has yet to be established.
+ * @internal
+ */
+ generateResponseOfferAnswerInDialog(options) {
+ if (!this.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ switch (this.dialog.signalingState) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Initial:
+ return this.getOffer(options);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveLocalOffer:
+ // o Once the UAS has sent or received an answer to the initial
+ // offer, it MUST NOT generate subsequent offers in any responses
+ // to the initial INVITE. This means that a UAS based on this
+ // specification alone can never generate subsequent offers until
+ // completion of the initial transaction.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ return Promise.resolve(undefined);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveRemoteOffer:
+ if (!this.dialog.offer) {
+ throw new Error(`Session offer undefined in signaling state ${this.dialog.signalingState}.`);
+ }
+ return this.setOfferAndGetAnswer(this.dialog.offer, options);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Stable:
+ // o Once the UAS has sent or received an answer to the initial
+ // offer, it MUST NOT generate subsequent offers in any responses
+ // to the initial INVITE. This means that a UAS based on this
+ // specification alone can never generate subsequent offers until
+ // completion of the initial transaction.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established) {
+ return Promise.resolve(undefined);
+ }
+ // In dialog INVITE without offer, get an offer for the response.
+ return this.getOffer(options);
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Closed:
+ throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
+ default:
+ throw new Error(`Invalid signaling state ${this.dialog.signalingState}.`);
+ }
+ }
+ /**
+ * Get local offer.
+ * @internal
+ */
+ getOffer(options) {
+ const sdh = this.setupSessionDescriptionHandler();
+ const sdhOptions = options.sessionDescriptionHandlerOptions;
+ const sdhModifiers = options.sessionDescriptionHandlerModifiers;
+ const sdhPostICEGatheringModifiers = options.sessionDescriptionHandlerModifiersPostICEGathering;
+ // This is intentionally written very defensively. Don't trust SDH to behave.
+ try {
+ return sdh
+ .getDescription(sdhOptions, sdhModifiers, sdhPostICEGatheringModifiers)
+ .then((bodyAndContentType) => Object(_core__WEBPACK_IMPORTED_MODULE_0__["fromBodyLegacy"])(bodyAndContentType))
+ .catch((error) => {
+ // don't trust SDH to reject with Error
+ this.logger.error("Session.getOffer: SDH getDescription rejected...");
+ const e = error instanceof Error ? error : new Error("Session.getOffer unknown error.");
+ this.logger.error(e.message);
+ throw e;
+ });
+ }
+ catch (error) {
+ // don't trust SDH to throw an Error
+ this.logger.error("Session.getOffer: SDH getDescription threw...");
+ const e = error instanceof Error ? error : new Error(error);
+ this.logger.error(e.message);
+ return Promise.reject(e);
+ }
+ }
+ /**
+ * Rollback local/remote offer.
+ * @internal
+ */
+ rollbackOffer() {
+ const sdh = this.setupSessionDescriptionHandler();
+ if (sdh.rollbackDescription === undefined) {
+ return Promise.resolve();
+ }
+ // This is intentionally written very defensively. Don't trust SDH to behave.
+ try {
+ return sdh.rollbackDescription().catch((error) => {
+ // don't trust SDH to reject with Error
+ this.logger.error("Session.rollbackOffer: SDH rollbackDescription rejected...");
+ const e = error instanceof Error ? error : new Error("Session.rollbackOffer unknown error.");
+ this.logger.error(e.message);
+ throw e;
+ });
+ }
+ catch (error) {
+ // don't trust SDH to throw an Error
+ this.logger.error("Session.rollbackOffer: SDH rollbackDescription threw...");
+ const e = error instanceof Error ? error : new Error(error);
+ this.logger.error(e.message);
+ return Promise.reject(e);
+ }
+ }
+ /**
+ * Set remote answer.
+ * @internal
+ */
+ setAnswer(answer, options) {
+ const sdh = this.setupSessionDescriptionHandler();
+ const sdhOptions = options.sessionDescriptionHandlerOptions;
+ const sdhModifiers = options.sessionDescriptionHandlerModifiers;
+ // This is intentionally written very defensively. Don't trust SDH to behave.
+ try {
+ if (!sdh.hasDescription(answer.contentType)) {
+ return Promise.reject(new _exceptions__WEBPACK_IMPORTED_MODULE_5__["ContentTypeUnsupportedError"]());
+ }
+ }
+ catch (error) {
+ this.logger.error("Session.setAnswer: SDH hasDescription threw...");
+ const e = error instanceof Error ? error : new Error(error);
+ this.logger.error(e.message);
+ return Promise.reject(e);
+ }
+ try {
+ return sdh.setDescription(answer.content, sdhOptions, sdhModifiers).catch((error) => {
+ // don't trust SDH to reject with Error
+ this.logger.error("Session.setAnswer: SDH setDescription rejected...");
+ const e = error instanceof Error ? error : new Error("Session.setAnswer unknown error.");
+ this.logger.error(e.message);
+ throw e;
+ });
+ }
+ catch (error) {
+ // don't trust SDH to throw an Error
+ this.logger.error("Session.setAnswer: SDH setDescription threw...");
+ const e = error instanceof Error ? error : new Error(error);
+ this.logger.error(e.message);
+ return Promise.reject(e);
+ }
+ }
+ /**
+ * Set remote offer and get local answer.
+ * @internal
+ */
+ setOfferAndGetAnswer(offer, options) {
+ const sdh = this.setupSessionDescriptionHandler();
+ const sdhOptions = options.sessionDescriptionHandlerOptions;
+ const sdhModifiers = options.sessionDescriptionHandlerModifiers;
+ const sdhModifiersPostICEGathering = options.sessionDescriptionHandlerModifiersPostICEGathering;
+ // This is intentionally written very defensively. Don't trust SDH to behave.
+ try {
+ if (!sdh.hasDescription(offer.contentType)) {
+ return Promise.reject(new _exceptions__WEBPACK_IMPORTED_MODULE_5__["ContentTypeUnsupportedError"]());
+ }
+ }
+ catch (error) {
+ this.logger.error("Session.setOfferAndGetAnswer: SDH hasDescription threw...");
+ const e = error instanceof Error ? error : new Error(error);
+ this.logger.error(e.message);
+ return Promise.reject(e);
+ }
+ try {
+ return sdh
+ .setDescription(offer.content, sdhOptions, sdhModifiers)
+ .then(() => sdh.getDescription(sdhOptions, sdhModifiers, sdhModifiersPostICEGathering))
+ .then((bodyAndContentType) => Object(_core__WEBPACK_IMPORTED_MODULE_0__["fromBodyLegacy"])(bodyAndContentType))
+ .catch((error) => {
+ // don't trust SDH to reject with Error
+ this.logger.error("Session.setOfferAndGetAnswer: SDH setDescription or getDescription rejected...");
+ const e = error instanceof Error ? error : new Error("Session.setOfferAndGetAnswer unknown error.");
+ this.logger.error(e.message);
+ throw e;
+ });
+ }
+ catch (error) {
+ // don't trust SDH to throw an Error
+ this.logger.error("Session.setOfferAndGetAnswer: SDH setDescription or getDescription threw...");
+ const e = error instanceof Error ? error : new Error(error);
+ this.logger.error(e.message);
+ return Promise.reject(e);
+ }
+ }
+ /**
+ * SDH for confirmed dialog.
+ * @internal
+ */
+ setSessionDescriptionHandler(sdh) {
+ if (this._sessionDescriptionHandler) {
+ throw new Error("Session description handler defined.");
+ }
+ this._sessionDescriptionHandler = sdh;
+ }
+ /**
+ * SDH for confirmed dialog.
+ * @internal
+ */
+ setupSessionDescriptionHandler() {
+ var _a;
+ if (this._sessionDescriptionHandler) {
+ return this._sessionDescriptionHandler;
+ }
+ this._sessionDescriptionHandler = this.sessionDescriptionHandlerFactory(this, this.userAgent.configuration.sessionDescriptionHandlerFactoryOptions);
+ if ((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onSessionDescriptionHandler) {
+ this.delegate.onSessionDescriptionHandler(this._sessionDescriptionHandler, false);
+ }
+ return this._sessionDescriptionHandler;
+ }
+ /**
+ * Transition session state.
+ * @internal
+ */
+ stateTransition(newState) {
+ const invalidTransition = () => {
+ throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
+ };
+ // Validate transition
+ switch (this._state) {
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Initial:
+ if (newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Establishing &&
+ newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established &&
+ newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating &&
+ newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Establishing:
+ if (newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established &&
+ newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating &&
+ newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Established:
+ if (newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating && newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminating:
+ if (newState !== _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated:
+ invalidTransition();
+ break;
+ default:
+ throw new Error("Unrecognized state.");
+ }
+ // Transition
+ this._state = newState;
+ this.logger.log(`Session ${this.id} transitioned to state ${this._state}`);
+ this._stateEventEmitter.emit(this._state);
+ // Dispose
+ if (newState === _session_state__WEBPACK_IMPORTED_MODULE_10__["SessionState"].Terminated) {
+ this.dispose();
+ }
+ }
+ copyRequestOptions(requestOptions = {}) {
+ const extraHeaders = requestOptions.extraHeaders ? requestOptions.extraHeaders.slice() : undefined;
+ const body = requestOptions.body
+ ? {
+ contentDisposition: requestOptions.body.contentDisposition || "render",
+ contentType: requestOptions.body.contentType || "text/plain",
+ content: requestOptions.body.content || ""
+ }
+ : undefined;
+ return {
+ extraHeaders,
+ body
+ };
+ }
+ getReasonHeaderValue(code, reason) {
+ const cause = code;
+ let text = Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["getReasonPhrase"])(code);
+ if (!text && reason) {
+ text = reason;
+ }
+ return "SIP;cause=" + cause + ';text="' + text + '"';
+ }
+ referExtraHeaders(referTo) {
+ const extraHeaders = [];
+ extraHeaders.push("Referred-By: <" + this.userAgent.configuration.uri + ">");
+ extraHeaders.push("Contact: " + this._contact);
+ extraHeaders.push("Allow: " + ["ACK", "CANCEL", "INVITE", "MESSAGE", "BYE", "OPTIONS", "INFO", "NOTIFY", "REFER"].toString());
+ extraHeaders.push("Refer-To: " + referTo);
+ return extraHeaders;
+ }
+ referToString(target) {
+ let referTo;
+ if (target instanceof _core__WEBPACK_IMPORTED_MODULE_0__["URI"]) {
+ // REFER without Replaces (Blind Transfer)
+ referTo = target.toString();
+ }
+ else {
+ // REFER with Replaces (Attended Transfer)
+ if (!target.dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ const displayName = target.remoteIdentity.friendlyName;
+ const remoteTarget = target.dialog.remoteTarget.toString();
+ const callId = target.dialog.callId;
+ const remoteTag = target.dialog.remoteTag;
+ const localTag = target.dialog.localTag;
+ const replaces = encodeURIComponent(`${callId};to-tag=${remoteTag};from-tag=${localTag}`);
+ referTo = `"${displayName}" <${remoteTarget}?Replaces=${replaces}>`;
+ }
+ return referTo;
+ }
+}
+
+
+/***/ }),
+/* 115 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Message", function() { return Message; });
+/**
+ * A received message (incoming MESSAGE).
+ * @public
+ */
+class Message {
+ /** @internal */
+ constructor(incomingMessageRequest) {
+ this.incomingMessageRequest = incomingMessageRequest;
+ }
+ /** Incoming MESSAGE request message. */
+ get request() {
+ return this.incomingMessageRequest.message;
+ }
+ /** Accept the request. */
+ accept(options) {
+ this.incomingMessageRequest.accept(options);
+ return Promise.resolve();
+ }
+ /** Reject the request. */
+ reject(options) {
+ this.incomingMessageRequest.reject(options);
+ return Promise.resolve();
+ }
+}
+
+
+/***/ }),
+/* 116 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Notification", function() { return Notification; });
+/**
+ * A notification of an event (incoming NOTIFY).
+ * @public
+ */
+class Notification {
+ /** @internal */
+ constructor(incomingNotifyRequest) {
+ this.incomingNotifyRequest = incomingNotifyRequest;
+ }
+ /** Incoming NOTIFY request message. */
+ get request() {
+ return this.incomingNotifyRequest.message;
+ }
+ /** Accept the request. */
+ accept(options) {
+ this.incomingNotifyRequest.accept(options);
+ return Promise.resolve();
+ }
+ /** Reject the request. */
+ reject(options) {
+ this.incomingNotifyRequest.reject(options);
+ return Promise.resolve();
+ }
+}
+
+
+/***/ }),
+/* 117 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Referral", function() { return Referral; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * A request to establish a {@link Session} elsewhere (incoming REFER).
+ * @public
+ */
+class Referral {
+ /** @internal */
+ constructor(incomingReferRequest, session) {
+ this.incomingReferRequest = incomingReferRequest;
+ this.session = session;
+ }
+ get referTo() {
+ const referTo = this.incomingReferRequest.message.parseHeader("refer-to");
+ if (!(referTo instanceof _core__WEBPACK_IMPORTED_MODULE_0__["NameAddrHeader"])) {
+ throw new Error("Failed to parse Refer-To header.");
+ }
+ return referTo;
+ }
+ get referredBy() {
+ return this.incomingReferRequest.message.getHeader("referred-by");
+ }
+ get replaces() {
+ return this.referTo.uri.getHeader("replaces");
+ }
+ /** Incoming REFER request message. */
+ get request() {
+ return this.incomingReferRequest.message;
+ }
+ /** Accept the request. */
+ accept(options = { statusCode: 202 }) {
+ this.incomingReferRequest.accept(options);
+ return Promise.resolve();
+ }
+ /** Reject the request. */
+ reject(options) {
+ this.incomingReferRequest.reject(options);
+ return Promise.resolve();
+ }
+ /**
+ * Creates an inviter which may be used to send an out of dialog INVITE request.
+ *
+ * @remarks
+ * This a helper method to create an Inviter which will execute the referral
+ * of the `Session` which was referred. The appropriate headers are set and
+ * the referred `Session` is linked to the new `Session`. Note that only a
+ * single instance of the `Inviter` will be created and returned (if called
+ * more than once a reference to the same `Inviter` will be returned every time).
+ *
+ * @param options - Options bucket.
+ * @param modifiers - Session description handler modifiers.
+ */
+ makeInviter(options) {
+ if (this.inviter) {
+ return this.inviter;
+ }
+ const targetURI = this.referTo.uri.clone();
+ targetURI.clearHeaders();
+ options = options || {};
+ const extraHeaders = (options.extraHeaders || []).slice();
+ const replaces = this.replaces;
+ if (replaces) {
+ // decodeURIComponent is a holdover from 2c086eb4. Not sure that it is actually necessary
+ extraHeaders.push("Replaces: " + decodeURIComponent(replaces));
+ }
+ const referredBy = this.referredBy;
+ if (referredBy) {
+ extraHeaders.push("Referred-By: " + referredBy);
+ }
+ options.extraHeaders = extraHeaders;
+ this.inviter = this.session.userAgent._makeInviter(targetURI, options);
+ this.inviter._referred = this.session;
+ this.session._referral = this.inviter;
+ return this.inviter;
+ }
+}
+
+
+/***/ }),
+/* 118 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SessionState", function() { return SessionState; });
+/**
+ * {@link Session} state.
+ *
+ * @remarks
+ * The {@link Session} behaves in a deterministic manner according to the following
+ * Finite State Machine (FSM).
+ * ```txt
+ * ___________________________________________________________
+ * | ____________________________________________ |
+ * | | ____________________________ | |
+ * Session | | | v v v
+ * Constructed -> Initial -> Establishing -> Established -> Terminating -> Terminated
+ * | |___________________________^ ^
+ * |_______________________________________________|
+ * ```
+ * @public
+ */
+var SessionState;
+(function (SessionState) {
+ /**
+ * If `Inviter`, INVITE not sent yet.
+ * If `Invitation`, received INVITE (but no final response sent yet).
+ */
+ SessionState["Initial"] = "Initial";
+ /**
+ * If `Inviter`, sent INVITE and waiting for a final response.
+ * If `Invitation`, received INVITE and attempting to send 200 final response (but has not sent it yet).
+ */
+ SessionState["Establishing"] = "Establishing";
+ /**
+ * If `Inviter`, sent INVITE and received 200 final response and sent ACK.
+ * If `Invitation`, received INVITE and sent 200 final response.
+ */
+ SessionState["Established"] = "Established";
+ /**
+ * If `Inviter`, sent INVITE, sent CANCEL and now waiting for 487 final response to ACK (or 200 to ACK & BYE).
+ * If `Invitation`, received INVITE, sent 200 final response and now waiting on ACK and upon receipt will attempt BYE
+ * (as the protocol specification requires, before sending a BYE we must receive the ACK - so we are waiting).
+ */
+ SessionState["Terminating"] = "Terminating";
+ /**
+ * If `Inviter`, sent INVITE and received non-200 final response (or sent/received BYE after receiving 200).
+ * If `Invitation`, received INVITE and sent non-200 final response (or sent/received BYE after sending 200).
+ */
+ SessionState["Terminated"] = "Terminated";
+})(SessionState || (SessionState = {}));
+
+
+/***/ }),
+/* 119 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SIPExtension", function() { return SIPExtension; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "UserAgentRegisteredOptionTags", function() { return UserAgentRegisteredOptionTags; });
+/**
+ * SIP extension support level.
+ * @public
+ */
+var SIPExtension;
+(function (SIPExtension) {
+ SIPExtension["Required"] = "Required";
+ SIPExtension["Supported"] = "Supported";
+ SIPExtension["Unsupported"] = "Unsupported";
+})(SIPExtension || (SIPExtension = {}));
+/**
+ * SIP Option Tags
+ * @remarks
+ * http://www.iana.org/assignments/sip-parameters/sip-parameters.xhtml#sip-parameters-4
+ * @public
+ */
+const UserAgentRegisteredOptionTags = {
+ "100rel": true,
+ "199": true,
+ answermode: true,
+ "early-session": true,
+ eventlist: true,
+ explicitsub: true,
+ "from-change": true,
+ "geolocation-http": true,
+ "geolocation-sip": true,
+ gin: true,
+ gruu: true,
+ histinfo: true,
+ ice: true,
+ join: true,
+ "multiple-refer": true,
+ norefersub: true,
+ nosub: true,
+ outbound: true,
+ path: true,
+ policy: true,
+ precondition: true,
+ pref: true,
+ privacy: true,
+ "recipient-list-invite": true,
+ "recipient-list-message": true,
+ "recipient-list-subscribe": true,
+ replaces: true,
+ "resource-priority": true,
+ "sdp-anat": true,
+ "sec-agree": true,
+ tdialog: true,
+ timer: true,
+ uui: true // RFC 7433
+};
+
+
+/***/ }),
+/* 120 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 121 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 122 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 123 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Inviter", function() { return Inviter; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _core_messages_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+/* harmony import */ var _session__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(114);
+/* harmony import */ var _session_state__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(118);
+/* harmony import */ var _user_agent_options__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(119);
+
+
+
+
+
+/**
+ * An inviter offers to establish a {@link Session} (outgoing INVITE).
+ * @public
+ */
+class Inviter extends _session__WEBPACK_IMPORTED_MODULE_2__["Session"] {
+ /**
+ * Constructs a new instance of the `Inviter` class.
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @param targetURI - Request URI identifying the target of the message.
+ * @param options - Options bucket. See {@link InviterOptions} for details.
+ */
+ constructor(userAgent, targetURI, options = {}) {
+ super(userAgent, options);
+ /** True if dispose() has been called. */
+ this.disposed = false;
+ /** True if early media use is enabled. */
+ this.earlyMedia = false;
+ /** The early media session description handlers. */
+ this.earlyMediaSessionDescriptionHandlers = new Map();
+ /** True if cancel() was called. */
+ this.isCanceled = false;
+ /** True if initial INVITE without SDP. */
+ this.inviteWithoutSdp = false;
+ this.logger = userAgent.getLogger("sip.Inviter");
+ // Early media
+ this.earlyMedia = options.earlyMedia !== undefined ? options.earlyMedia : this.earlyMedia;
+ // From tag
+ this.fromTag = Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["newTag"])();
+ // Invite without SDP
+ this.inviteWithoutSdp = options.inviteWithoutSdp !== undefined ? options.inviteWithoutSdp : this.inviteWithoutSdp;
+ // Inviter options (could do better copying these options)
+ const inviterOptions = Object.assign({}, options);
+ inviterOptions.params = Object.assign({}, options.params);
+ // Anonymous call
+ const anonymous = options.anonymous || false;
+ // Contact
+ const contact = userAgent.contact.toString({
+ anonymous,
+ // Do not add ;ob in initial forming dialog requests if the
+ // registration over the current connection got a GRUU URI.
+ outbound: anonymous ? !userAgent.contact.tempGruu : !userAgent.contact.pubGruu
+ });
+ // FIXME: TODO: We should not be parsing URIs here as if it fails we have to throw an exception
+ // which is not something we want our constructor to do. URIs should be passed in as params.
+ // URIs
+ if (anonymous && userAgent.configuration.uri) {
+ inviterOptions.params.fromDisplayName = "Anonymous";
+ inviterOptions.params.fromUri = "sip:anonymous@anonymous.invalid";
+ }
+ let fromURI = userAgent.userAgentCore.configuration.aor;
+ if (inviterOptions.params.fromUri) {
+ fromURI =
+ typeof inviterOptions.params.fromUri === "string"
+ ? _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(inviterOptions.params.fromUri)
+ : inviterOptions.params.fromUri;
+ }
+ if (!fromURI) {
+ throw new TypeError("Invalid from URI: " + inviterOptions.params.fromUri);
+ }
+ let toURI = targetURI;
+ if (inviterOptions.params.toUri) {
+ toURI =
+ typeof inviterOptions.params.toUri === "string"
+ ? _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(inviterOptions.params.toUri)
+ : inviterOptions.params.toUri;
+ }
+ if (!toURI) {
+ throw new TypeError("Invalid to URI: " + inviterOptions.params.toUri);
+ }
+ // Params
+ const messageOptions = Object.assign({}, inviterOptions.params);
+ messageOptions.fromTag = this.fromTag;
+ // Extra headers
+ const extraHeaders = (inviterOptions.extraHeaders || []).slice();
+ if (anonymous && userAgent.configuration.uri) {
+ extraHeaders.push("P-Preferred-Identity: " + userAgent.configuration.uri.toString());
+ extraHeaders.push("Privacy: id");
+ }
+ extraHeaders.push("Contact: " + contact);
+ extraHeaders.push("Allow: " + ["ACK", "CANCEL", "INVITE", "MESSAGE", "BYE", "OPTIONS", "INFO", "NOTIFY", "REFER"].toString());
+ if (userAgent.configuration.sipExtension100rel === _user_agent_options__WEBPACK_IMPORTED_MODULE_4__["SIPExtension"].Required) {
+ extraHeaders.push("Require: 100rel");
+ }
+ if (userAgent.configuration.sipExtensionReplaces === _user_agent_options__WEBPACK_IMPORTED_MODULE_4__["SIPExtension"].Required) {
+ extraHeaders.push("Require: replaces");
+ }
+ inviterOptions.extraHeaders = extraHeaders;
+ // Body
+ const body = undefined;
+ // Make initial outgoing request message
+ this.outgoingRequestMessage = userAgent.userAgentCore.makeOutgoingRequestMessage(_core__WEBPACK_IMPORTED_MODULE_0__["C"].INVITE, targetURI, fromURI, toURI, messageOptions, extraHeaders, body);
+ // Session parent properties
+ this._contact = contact;
+ this._referralInviterOptions = inviterOptions;
+ this._renderbody = options.renderbody;
+ this._rendertype = options.rendertype;
+ // Modifiers and options for initial INVITE transaction
+ if (options.sessionDescriptionHandlerModifiers) {
+ this.sessionDescriptionHandlerModifiers = options.sessionDescriptionHandlerModifiers;
+ }
+ if (options.sessionDescriptionHandlerOptions) {
+ this.sessionDescriptionHandlerOptions = options.sessionDescriptionHandlerOptions;
+ }
+ // Modifiers and options for re-INVITE transactions
+ if (options.sessionDescriptionHandlerModifiersReInvite) {
+ this.sessionDescriptionHandlerModifiersReInvite = options.sessionDescriptionHandlerModifiersReInvite;
+ }
+ if (options.sessionDescriptionHandlerOptionsReInvite) {
+ this.sessionDescriptionHandlerOptionsReInvite = options.sessionDescriptionHandlerOptionsReInvite;
+ }
+ if (options.sessionDescriptionHandlerModifiersPostICEGathering) {
+ this.sessionDescriptionHandlerModifiersPostICEGathering = options.sessionDescriptionHandlerModifiersPostICEGathering;
+ }
+ // Identifier
+ this._id = this.outgoingRequestMessage.callId + this.fromTag;
+ // Add to the user agent's session collection.
+ this.userAgent._sessions[this._id] = this;
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ // Only run through this once. It can and does get called multiple times
+ // depending on the what the sessions state is when first called.
+ // For example, if called when "establishing" it will be called again
+ // at least once when the session transitions to "terminated".
+ // Regardless, running through this more than once is pointless.
+ if (this.disposed) {
+ return Promise.resolve();
+ }
+ this.disposed = true;
+ // Dispose of early dialog media
+ this.disposeEarlyMedia();
+ // If the final response for the initial INVITE not yet been received, cancel it
+ switch (this.state) {
+ case _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Initial:
+ return this.cancel().then(() => super.dispose());
+ case _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing:
+ return this.cancel().then(() => super.dispose());
+ case _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Established:
+ return super.dispose();
+ case _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminating:
+ return super.dispose();
+ case _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated:
+ return super.dispose();
+ default:
+ throw new Error("Unknown state.");
+ }
+ }
+ /**
+ * Initial outgoing INVITE request message body.
+ */
+ get body() {
+ return this.outgoingRequestMessage.body;
+ }
+ /**
+ * The identity of the local user.
+ */
+ get localIdentity() {
+ return this.outgoingRequestMessage.from;
+ }
+ /**
+ * The identity of the remote user.
+ */
+ get remoteIdentity() {
+ return this.outgoingRequestMessage.to;
+ }
+ /**
+ * Initial outgoing INVITE request message.
+ */
+ get request() {
+ return this.outgoingRequestMessage;
+ }
+ /**
+ * Cancels the INVITE request.
+ *
+ * @remarks
+ * Sends a CANCEL request.
+ * Resolves once the response sent, otherwise rejects.
+ *
+ * After sending a CANCEL request the expectation is that a 487 final response
+ * will be received for the INVITE. However a 200 final response to the INVITE
+ * may nonetheless arrive (it's a race between the CANCEL reaching the UAS before
+ * the UAS sends a 200) in which case an ACK & BYE will be sent. The net effect
+ * is that this method will terminate the session regardless of the race.
+ * @param options - Options bucket.
+ */
+ cancel(options = {}) {
+ this.logger.log("Inviter.cancel");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Initial && this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing) {
+ const error = new Error(`Invalid session state ${this.state}`);
+ this.logger.error(error.message);
+ return Promise.reject(error);
+ }
+ // flag canceled
+ this.isCanceled = true;
+ // transition state
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminating);
+ // helper function
+ function getCancelReason(code, reason) {
+ if ((code && code < 200) || code > 699) {
+ throw new TypeError("Invalid statusCode: " + code);
+ }
+ else if (code) {
+ const cause = code;
+ const text = Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["getReasonPhrase"])(code) || reason;
+ return "SIP;cause=" + cause + ';text="' + text + '"';
+ }
+ }
+ if (this.outgoingInviteRequest) {
+ // the CANCEL may not be respected by peer(s), so don't transition to terminated
+ let cancelReason;
+ if (options.statusCode && options.reasonPhrase) {
+ cancelReason = getCancelReason(options.statusCode, options.reasonPhrase);
+ }
+ this.outgoingInviteRequest.cancel(cancelReason, options);
+ }
+ else {
+ this.logger.warn("Canceled session before INVITE was sent");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ }
+ return Promise.resolve();
+ }
+ /**
+ * Sends the INVITE request.
+ *
+ * @remarks
+ * TLDR...
+ * 1) Only one offer/answer exchange permitted during initial INVITE.
+ * 2) No "early media" if the initial offer is in an INVITE (default behavior).
+ * 3) If "early media" and the initial offer is in an INVITE, no INVITE forking.
+ *
+ * 1) Only one offer/answer exchange permitted during initial INVITE.
+ *
+ * Our implementation replaces the following bullet point...
+ *
+ * o After having sent or received an answer to the first offer, the
+ * UAC MAY generate subsequent offers in requests based on rules
+ * specified for that method, but only if it has received answers
+ * to any previous offers, and has not sent any offers to which it
+ * hasn't gotten an answer.
+ * https://tools.ietf.org/html/rfc3261#section-13.2.1
+ *
+ * ...with...
+ *
+ * o After having sent or received an answer to the first offer, the
+ * UAC MUST NOT generate subsequent offers in requests based on rules
+ * specified for that method.
+ *
+ * ...which in combination with this bullet point...
+ *
+ * o Once the UAS has sent or received an answer to the initial
+ * offer, it MUST NOT generate subsequent offers in any responses
+ * to the initial INVITE. This means that a UAS based on this
+ * specification alone can never generate subsequent offers until
+ * completion of the initial transaction.
+ * https://tools.ietf.org/html/rfc3261#section-13.2.1
+ *
+ * ...ensures that EXACTLY ONE offer/answer exchange will occur
+ * during an initial out of dialog INVITE request made by our UAC.
+ *
+ *
+ * 2) No "early media" if the initial offer is in an INVITE (default behavior).
+ *
+ * While our implementation adheres to the following bullet point...
+ *
+ * o If the initial offer is in an INVITE, the answer MUST be in a
+ * reliable non-failure message from UAS back to UAC which is
+ * correlated to that INVITE. For this specification, that is
+ * only the final 2xx response to that INVITE. That same exact
+ * answer MAY also be placed in any provisional responses sent
+ * prior to the answer. The UAC MUST treat the first session
+ * description it receives as the answer, and MUST ignore any
+ * session descriptions in subsequent responses to the initial
+ * INVITE.
+ * https://tools.ietf.org/html/rfc3261#section-13.2.1
+ *
+ * We have made the following implementation decision with regard to early media...
+ *
+ * o If the initial offer is in the INVITE, the answer from the
+ * UAS back to the UAC will establish a media session only
+ * only after the final 2xx response to that INVITE is received.
+ *
+ * The reason for this decision is rooted in a restriction currently
+ * inherent in WebRTC. Specifically, while a SIP INVITE request with an
+ * initial offer may fork resulting in more than one provisional answer,
+ * there is currently no easy/good way to to "fork" an offer generated
+ * by a peer connection. In particular, a WebRTC offer currently may only
+ * be matched with one answer and we have no good way to know which
+ * "provisional answer" is going to be the "final answer". So we have
+ * decided to punt and not create any "early media" sessions in this case.
+ *
+ * The upshot is that if you want "early media", you must not put the
+ * initial offer in the INVITE. Instead, force the UAS to provide the
+ * initial offer by sending an INVITE without an offer. In the WebRTC
+ * case this allows us to create a unique peer connection with a unique
+ * answer for every provisional offer with "early media" on all of them.
+ *
+ *
+ * 3) If "early media" and the initial offer is in an INVITE, no INVITE forking.
+ *
+ * The default behavior may be altered and "early media" utilized if the
+ * initial offer is in the an INVITE by setting the `earlyMedia` options.
+ * However in that case the INVITE request MUST NOT fork. This allows for
+ * "early media" in environments where the forking behavior of the SIP
+ * servers being utilized is configured to disallow forking.
+ */
+ invite(options = {}) {
+ this.logger.log("Inviter.invite");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Initial) {
+ // re-invite
+ return super.invite(options);
+ }
+ // Modifiers and options for initial INVITE transaction
+ if (options.sessionDescriptionHandlerModifiers) {
+ this.sessionDescriptionHandlerModifiers = options.sessionDescriptionHandlerModifiers;
+ }
+ if (options.sessionDescriptionHandlerOptions) {
+ this.sessionDescriptionHandlerOptions = options.sessionDescriptionHandlerOptions;
+ }
+ if (options.sessionDescriptionHandlerModifiersPostICEGathering) {
+ this.sessionDescriptionHandlerModifiersPostICEGathering = options.sessionDescriptionHandlerModifiersPostICEGathering;
+ }
+ // just send an INVITE with no sdp...
+ if (options.withoutSdp || this.inviteWithoutSdp) {
+ if (this._renderbody && this._rendertype) {
+ this.outgoingRequestMessage.body = { contentType: this._rendertype, body: this._renderbody };
+ }
+ // transition state
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing);
+ return Promise.resolve(this.sendInvite(options));
+ }
+ // get an offer and send it in an INVITE
+ const offerOptions = {
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers,
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions,
+ sessionDescriptionHandlerModifiersPostICEGathering: this.sessionDescriptionHandlerModifiersPostICEGathering
+ };
+ return this.getOffer(offerOptions)
+ .then((body) => {
+ this.outgoingRequestMessage.body = { body: body.content, contentType: body.contentType };
+ // transition state
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing);
+ return this.sendInvite(options);
+ })
+ .catch((error) => {
+ this.logger.log(error.message);
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ throw error;
+ });
+ }
+ /**
+ * 13.2.1 Creating the Initial INVITE
+ *
+ * Since the initial INVITE represents a request outside of a dialog,
+ * its construction follows the procedures of Section 8.1.1. Additional
+ * processing is required for the specific case of INVITE.
+ *
+ * An Allow header field (Section 20.5) SHOULD be present in the INVITE.
+ * It indicates what methods can be invoked within a dialog, on the UA
+ * sending the INVITE, for the duration of the dialog. For example, a
+ * UA capable of receiving INFO requests within a dialog [34] SHOULD
+ * include an Allow header field listing the INFO method.
+ *
+ * A Supported header field (Section 20.37) SHOULD be present in the
+ * INVITE. It enumerates all the extensions understood by the UAC.
+ *
+ * An Accept (Section 20.1) header field MAY be present in the INVITE.
+ * It indicates which Content-Types are acceptable to the UA, in both
+ * the response received by it, and in any subsequent requests sent to
+ * it within dialogs established by the INVITE. The Accept header field
+ * is especially useful for indicating support of various session
+ * description formats.
+ *
+ * The UAC MAY add an Expires header field (Section 20.19) to limit the
+ * validity of the invitation. If the time indicated in the Expires
+ * header field is reached and no final answer for the INVITE has been
+ * received, the UAC core SHOULD generate a CANCEL request for the
+ * INVITE, as per Section 9.
+ *
+ * A UAC MAY also find it useful to add, among others, Subject (Section
+ * 20.36), Organization (Section 20.25) and User-Agent (Section 20.41)
+ * header fields. They all contain information related to the INVITE.
+ *
+ * The UAC MAY choose to add a message body to the INVITE. Section
+ * 8.1.1.10 deals with how to construct the header fields -- Content-
+ * Type among others -- needed to describe the message body.
+ *
+ * https://tools.ietf.org/html/rfc3261#section-13.2.1
+ */
+ sendInvite(options = {}) {
+ // There are special rules for message bodies that contain a session
+ // description - their corresponding Content-Disposition is "session".
+ // SIP uses an offer/answer model where one UA sends a session
+ // description, called the offer, which contains a proposed description
+ // of the session. The offer indicates the desired communications means
+ // (audio, video, games), parameters of those means (such as codec
+ // types) and addresses for receiving media from the answerer. The
+ // other UA responds with another session description, called the
+ // answer, which indicates which communications means are accepted, the
+ // parameters that apply to those means, and addresses for receiving
+ // media from the offerer. An offer/answer exchange is within the
+ // context of a dialog, so that if a SIP INVITE results in multiple
+ // dialogs, each is a separate offer/answer exchange. The offer/answer
+ // model defines restrictions on when offers and answers can be made
+ // (for example, you cannot make a new offer while one is in progress).
+ // This results in restrictions on where the offers and answers can
+ // appear in SIP messages. In this specification, offers and answers
+ // can only appear in INVITE requests and responses, and ACK. The usage
+ // of offers and answers is further restricted. For the initial INVITE
+ // transaction, the rules are:
+ //
+ // o The initial offer MUST be in either an INVITE or, if not there,
+ // in the first reliable non-failure message from the UAS back to
+ // the UAC. In this specification, that is the final 2xx
+ // response.
+ //
+ // o If the initial offer is in an INVITE, the answer MUST be in a
+ // reliable non-failure message from UAS back to UAC which is
+ // correlated to that INVITE. For this specification, that is
+ // only the final 2xx response to that INVITE. That same exact
+ // answer MAY also be placed in any provisional responses sent
+ // prior to the answer. The UAC MUST treat the first session
+ // description it receives as the answer, and MUST ignore any
+ // session descriptions in subsequent responses to the initial
+ // INVITE.
+ //
+ // o If the initial offer is in the first reliable non-failure
+ // message from the UAS back to UAC, the answer MUST be in the
+ // acknowledgement for that message (in this specification, ACK
+ // for a 2xx response).
+ //
+ // o After having sent or received an answer to the first offer, the
+ // UAC MAY generate subsequent offers in requests based on rules
+ // specified for that method, but only if it has received answers
+ // to any previous offers, and has not sent any offers to which it
+ // hasn't gotten an answer.
+ //
+ // o Once the UAS has sent or received an answer to the initial
+ // offer, it MUST NOT generate subsequent offers in any responses
+ // to the initial INVITE. This means that a UAS based on this
+ // specification alone can never generate subsequent offers until
+ // completion of the initial transaction.
+ //
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ // 5 The Offer/Answer Model and PRACK
+ //
+ // RFC 3261 describes guidelines for the sets of messages in which
+ // offers and answers [3] can appear. Based on those guidelines, this
+ // extension provides additional opportunities for offer/answer
+ // exchanges.
+ // If the INVITE contained an offer, the UAS MAY generate an answer in a
+ // reliable provisional response (assuming these are supported by the
+ // UAC). That results in the establishment of the session before
+ // completion of the call. Similarly, if a reliable provisional
+ // response is the first reliable message sent back to the UAC, and the
+ // INVITE did not contain an offer, one MUST appear in that reliable
+ // provisional response.
+ // If the UAC receives a reliable provisional response with an offer
+ // (this would occur if the UAC sent an INVITE without an offer, in
+ // which case the first reliable provisional response will contain the
+ // offer), it MUST generate an answer in the PRACK. If the UAC receives
+ // a reliable provisional response with an answer, it MAY generate an
+ // additional offer in the PRACK. If the UAS receives a PRACK with an
+ // offer, it MUST place the answer in the 2xx to the PRACK.
+ // Once an answer has been sent or received, the UA SHOULD establish the
+ // session based on the parameters of the offer and answer, even if the
+ // original INVITE itself has not been responded to.
+ // If the UAS had placed a session description in any reliable
+ // provisional response that is unacknowledged when the INVITE is
+ // accepted, the UAS MUST delay sending the 2xx until the provisional
+ // response is acknowledged. Otherwise, the reliability of the 1xx
+ // cannot be guaranteed, and reliability is needed for proper operation
+ // of the offer/answer exchange.
+ // All user agents that support this extension MUST support all
+ // offer/answer exchanges that are possible based on the rules in
+ // Section 13.2 of RFC 3261, based on the existence of INVITE and PRACK
+ // as requests, and 2xx and reliable 1xx as non-failure reliable
+ // responses.
+ //
+ // https://tools.ietf.org/html/rfc3262#section-5
+ ////
+ // The Offer/Answer Model Implementation
+ //
+ // The offer/answer model is straight forward, but one MUST READ the specifications...
+ //
+ // 13.2.1 Creating the Initial INVITE (paragraph 8 in particular)
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ //
+ // 5 The Offer/Answer Model and PRACK
+ // https://tools.ietf.org/html/rfc3262#section-5
+ //
+ // Session Initiation Protocol (SIP) Usage of the Offer/Answer Model
+ // https://tools.ietf.org/html/rfc6337
+ ////
+ ////
+ // TODO: The Offer/Answer Model Implementation
+ //
+ // Currently if `earlyMedia` is enabled and the INVITE request forks,
+ // the session is terminated if the early dialog does not match the
+ // confirmed dialog. This restriction make sense in a WebRTC environment,
+ // but there are other environments where this restriction does not hold.
+ //
+ // So while we currently cannot make the offer in INVITE+forking+webrtc
+ // case work, we propose doing the following...
+ //
+ // OPTION 1
+ // - add a `earlyMediaForking` option and
+ // - require SDH.setDescription() to be callable multiple times.
+ //
+ // OPTION 2
+ // 1) modify SDH Factory to provide an initial offer without giving us the SDH, and then...
+ // 2) stick that offer in the initial INVITE, and when 183 with initial answer is received...
+ // 3) ask SDH Factory if it supports "earlyRemoteAnswer"
+ // a) if true, ask SDH Factory to createSDH(localOffer).then((sdh) => sdh.setDescription(remoteAnswer)
+ // b) if false, defer getting a SDH until 2xx response is received
+ //
+ // Our supplied WebRTC SDH will default to behavior 3b which works in forking environment (without)
+ // early media if initial offer is in the INVITE). We will, however, provide an "inviteWillNotFork"
+ // option which if set to "true" will have our supplied WebRTC SDH behave in the 3a manner.
+ // That will result in
+ // - early media working with initial offer in the INVITE, and...
+ // - if the INVITE forks, the session terminating with an ERROR that reads like
+ // "You set 'inviteWillNotFork' to true but the INVITE forked. You can't eat your cake, and have it too."
+ // - furthermore, we accept that users will report that error to us as "bug" regardless
+ //
+ // So, SDH Factory is going to end up with a new interface along the lines of...
+ //
+ // interface SessionDescriptionHandlerFactory {
+ // makeLocalOffer(): Promise;
+ // makeSessionDescriptionHandler(
+ // initialOffer: ContentTypeAndBody, offerType: "local" | "remote"
+ // ): Promise;
+ // supportsEarlyRemoteAnswer: boolean;
+ // supportsContentType(contentType: string): boolean;
+ // getDescription(description: ContentTypeAndBody): Promise
+ // setDescription(description: ContentTypeAndBody): Promise
+ // }
+ ////
+ // Send the INVITE request.
+ this.outgoingInviteRequest = this.userAgent.userAgentCore.invite(this.outgoingRequestMessage, {
+ onAccept: (inviteResponse) => {
+ // Our transaction layer is "non-standard" in that it will only
+ // pass us a 2xx response once per branch, so there is no need to
+ // worry about dealing with 2xx retransmissions. However, we can
+ // and do still get 2xx responses for multiple branches (when an
+ // INVITE is forked) which may create multiple confirmed dialogs.
+ // Herein we are acking and sending a bye to any confirmed dialogs
+ // which arrive beyond the first one. This is the desired behavior
+ // for most applications (but certainly not all).
+ // If we already received a confirmed dialog, ack & bye this additional confirmed session.
+ if (this.dialog) {
+ this.logger.log("Additional confirmed dialog, sending ACK and BYE");
+ this.ackAndBye(inviteResponse);
+ // We do NOT transition state in this case (this is an "extra" dialog)
+ return;
+ }
+ // If the user requested cancellation, ack & bye this session.
+ if (this.isCanceled) {
+ this.logger.log("Canceled session accepted, sending ACK and BYE");
+ this.ackAndBye(inviteResponse);
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ return;
+ }
+ this.notifyReferer(inviteResponse);
+ this.onAccept(inviteResponse)
+ .then(() => {
+ this.disposeEarlyMedia();
+ })
+ .catch(() => {
+ this.disposeEarlyMedia();
+ })
+ .then(() => {
+ if (options.requestDelegate && options.requestDelegate.onAccept) {
+ options.requestDelegate.onAccept(inviteResponse);
+ }
+ });
+ },
+ onProgress: (inviteResponse) => {
+ // If the user requested cancellation, ignore response.
+ if (this.isCanceled) {
+ return;
+ }
+ this.notifyReferer(inviteResponse);
+ this.onProgress(inviteResponse)
+ .catch(() => {
+ this.disposeEarlyMedia();
+ })
+ .then(() => {
+ if (options.requestDelegate && options.requestDelegate.onProgress) {
+ options.requestDelegate.onProgress(inviteResponse);
+ }
+ });
+ },
+ onRedirect: (inviteResponse) => {
+ this.notifyReferer(inviteResponse);
+ this.onRedirect(inviteResponse);
+ if (options.requestDelegate && options.requestDelegate.onRedirect) {
+ options.requestDelegate.onRedirect(inviteResponse);
+ }
+ },
+ onReject: (inviteResponse) => {
+ this.notifyReferer(inviteResponse);
+ this.onReject(inviteResponse);
+ if (options.requestDelegate && options.requestDelegate.onReject) {
+ options.requestDelegate.onReject(inviteResponse);
+ }
+ },
+ onTrying: (inviteResponse) => {
+ this.notifyReferer(inviteResponse);
+ this.onTrying(inviteResponse);
+ if (options.requestDelegate && options.requestDelegate.onTrying) {
+ options.requestDelegate.onTrying(inviteResponse);
+ }
+ }
+ });
+ return this.outgoingInviteRequest;
+ }
+ disposeEarlyMedia() {
+ this.earlyMediaSessionDescriptionHandlers.forEach((sessionDescriptionHandler) => {
+ sessionDescriptionHandler.close();
+ });
+ this.earlyMediaSessionDescriptionHandlers.clear();
+ }
+ notifyReferer(response) {
+ if (!this._referred) {
+ return;
+ }
+ if (!(this._referred instanceof _session__WEBPACK_IMPORTED_MODULE_2__["Session"])) {
+ throw new Error("Referred session not instance of session");
+ }
+ if (!this._referred.dialog) {
+ return;
+ }
+ if (!response.message.statusCode) {
+ throw new Error("Status code undefined.");
+ }
+ if (!response.message.reasonPhrase) {
+ throw new Error("Reason phrase undefined.");
+ }
+ const statusCode = response.message.statusCode;
+ const reasonPhrase = response.message.reasonPhrase;
+ const body = `SIP/2.0 ${statusCode} ${reasonPhrase}`.trim();
+ const outgoingNotifyRequest = this._referred.dialog.notify(undefined, {
+ extraHeaders: ["Event: refer", "Subscription-State: terminated"],
+ body: {
+ contentDisposition: "render",
+ contentType: "message/sipfrag",
+ content: body
+ }
+ });
+ // The implicit subscription created by a REFER is the same as a
+ // subscription created with a SUBSCRIBE request. The agent issuing the
+ // REFER can terminate this subscription prematurely by unsubscribing
+ // using the mechanisms described in [2]. Terminating a subscription,
+ // either by explicitly unsubscribing or rejecting NOTIFY, is not an
+ // indication that the referenced request should be withdrawn or
+ // abandoned.
+ // https://tools.ietf.org/html/rfc3515#section-2.4.4
+ // FIXME: TODO: This should be done in a subscribe dialog to satisfy the above.
+ // If the notify is rejected, stop sending NOTIFY requests.
+ outgoingNotifyRequest.delegate = {
+ onReject: () => {
+ this._referred = undefined;
+ }
+ };
+ }
+ /**
+ * Handle final response to initial INVITE.
+ * @param inviteResponse - 2xx response.
+ */
+ onAccept(inviteResponse) {
+ this.logger.log("Inviter.onAccept");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing) {
+ this.logger.error(`Accept received while in state ${this.state}, dropping response`);
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ const response = inviteResponse.message;
+ const session = inviteResponse.session;
+ // Ported behavior.
+ if (response.hasHeader("P-Asserted-Identity")) {
+ this._assertedIdentity = _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].nameAddrHeaderParse(response.getHeader("P-Asserted-Identity"));
+ }
+ // We have a confirmed dialog.
+ session.delegate = {
+ onAck: (ackRequest) => this.onAckRequest(ackRequest),
+ onBye: (byeRequest) => this.onByeRequest(byeRequest),
+ onInfo: (infoRequest) => this.onInfoRequest(infoRequest),
+ onInvite: (inviteRequest) => this.onInviteRequest(inviteRequest),
+ onMessage: (messageRequest) => this.onMessageRequest(messageRequest),
+ onNotify: (notifyRequest) => this.onNotifyRequest(notifyRequest),
+ onPrack: (prackRequest) => this.onPrackRequest(prackRequest),
+ onRefer: (referRequest) => this.onReferRequest(referRequest)
+ };
+ this._dialog = session;
+ switch (session.signalingState) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Initial:
+ // INVITE without offer, so MUST have offer at this point, so invalid state.
+ this.logger.error("Received 2xx response to INVITE without a session description");
+ this.ackAndBye(inviteResponse, 400, "Missing session description");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ return Promise.reject(new Error("Bad Media Description"));
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveLocalOffer:
+ // INVITE with offer, so MUST have answer at this point, so invalid state.
+ this.logger.error("Received 2xx response to INVITE without a session description");
+ this.ackAndBye(inviteResponse, 400, "Missing session description");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ return Promise.reject(new Error("Bad Media Description"));
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveRemoteOffer: {
+ // INVITE without offer, received offer in 2xx, so MUST send answer in ACK.
+ if (!this._dialog.offer) {
+ throw new Error(`Session offer undefined in signaling state ${this._dialog.signalingState}.`);
+ }
+ const options = {
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers,
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions
+ };
+ return this.setOfferAndGetAnswer(this._dialog.offer, options)
+ .then((body) => {
+ inviteResponse.ack({ body });
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Established);
+ })
+ .catch((error) => {
+ this.ackAndBye(inviteResponse, 488, "Invalid session description");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ throw error;
+ });
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Stable: {
+ // If INVITE without offer and we have already completed the initial exchange.
+ if (this.earlyMediaSessionDescriptionHandlers.size > 0) {
+ const sdh = this.earlyMediaSessionDescriptionHandlers.get(session.id);
+ if (!sdh) {
+ throw new Error("Session description handler undefined.");
+ }
+ this.setSessionDescriptionHandler(sdh);
+ this.earlyMediaSessionDescriptionHandlers.delete(session.id);
+ inviteResponse.ack();
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Established);
+ return Promise.resolve();
+ }
+ // If INVITE with offer and we used an "early" answer in a provisional response for media
+ if (this.earlyMediaDialog) {
+ // If early media dialog doesn't match confirmed dialog, we must unfortunately fail.
+ // This limitation stems from how WebRTC currently implements its offer/answer model.
+ // There are details elsewhere, but in short a WebRTC offer cannot be forked.
+ if (this.earlyMediaDialog !== session) {
+ if (this.earlyMedia) {
+ const message = "You have set the 'earlyMedia' option to 'true' which requires that your INVITE requests " +
+ "do not fork and yet this INVITE request did in fact fork. Consequentially and not surprisingly " +
+ "the end point which accepted the INVITE (confirmed dialog) does not match the end point with " +
+ "which early media has been setup (early dialog) and thus this session is unable to proceed. " +
+ "In accordance with the SIP specifications, the SIP servers your end point is connected to " +
+ "determine if an INVITE forks and the forking behavior of those servers cannot be controlled " +
+ "by this library. If you wish to use early media with this library you must configure those " +
+ "servers accordingly. Alternatively you may set the 'earlyMedia' to 'false' which will allow " +
+ "this library to function with any INVITE requests which do fork.";
+ this.logger.error(message);
+ }
+ const error = new Error("Early media dialog does not equal confirmed dialog, terminating session");
+ this.logger.error(error.message);
+ this.ackAndBye(inviteResponse, 488, "Not Acceptable Here");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ return Promise.reject(error);
+ }
+ // Otherwise we are good to go.
+ inviteResponse.ack();
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Established);
+ return Promise.resolve();
+ }
+ // If INVITE with offer and we have been waiting till now to apply the answer.
+ const answer = session.answer;
+ if (!answer) {
+ throw new Error("Answer is undefined.");
+ }
+ const options = {
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers,
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions
+ };
+ return this.setAnswer(answer, options)
+ .then(() => {
+ // This session has completed an initial offer/answer exchange...
+ let ackOptions;
+ if (this._renderbody && this._rendertype) {
+ ackOptions = {
+ body: { contentDisposition: "render", contentType: this._rendertype, content: this._renderbody }
+ };
+ }
+ inviteResponse.ack(ackOptions);
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Established);
+ })
+ .catch((error) => {
+ this.logger.error(error.message);
+ this.ackAndBye(inviteResponse, 488, "Not Acceptable Here");
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ throw error;
+ });
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Closed:
+ // Dialog has terminated.
+ return Promise.reject(new Error("Terminated."));
+ default:
+ throw new Error("Unknown session signaling state.");
+ }
+ }
+ /**
+ * Handle provisional response to initial INVITE.
+ * @param inviteResponse - 1xx response.
+ */
+ onProgress(inviteResponse) {
+ var _a;
+ this.logger.log("Inviter.onProgress");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing) {
+ this.logger.error(`Progress received while in state ${this.state}, dropping response`);
+ return Promise.reject(new Error(`Invalid session state ${this.state}`));
+ }
+ if (!this.outgoingInviteRequest) {
+ throw new Error("Outgoing INVITE request undefined.");
+ }
+ const response = inviteResponse.message;
+ const session = inviteResponse.session;
+ // Ported - Set assertedIdentity.
+ if (response.hasHeader("P-Asserted-Identity")) {
+ this._assertedIdentity = _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].nameAddrHeaderParse(response.getHeader("P-Asserted-Identity"));
+ }
+ // If a provisional response is received for an initial request, and
+ // that response contains a Require header field containing the option
+ // tag 100rel, the response is to be sent reliably. If the response is
+ // a 100 (Trying) (as opposed to 101 to 199), this option tag MUST be
+ // ignored, and the procedures below MUST NOT be used.
+ // https://tools.ietf.org/html/rfc3262#section-4
+ const requireHeader = response.getHeader("require");
+ const rseqHeader = response.getHeader("rseq");
+ const rseq = requireHeader && requireHeader.includes("100rel") && rseqHeader ? Number(rseqHeader) : undefined;
+ const responseReliable = !!rseq;
+ const extraHeaders = [];
+ if (responseReliable) {
+ extraHeaders.push("RAck: " + response.getHeader("rseq") + " " + response.getHeader("cseq"));
+ }
+ switch (session.signalingState) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Initial:
+ // INVITE without offer and session still has no offer (and no answer).
+ if (responseReliable) {
+ // Similarly, if a reliable provisional
+ // response is the first reliable message sent back to the UAC, and the
+ // INVITE did not contain an offer, one MUST appear in that reliable
+ // provisional response.
+ // https://tools.ietf.org/html/rfc3262#section-5
+ this.logger.warn("First reliable provisional response received MUST contain an offer when INVITE does not contain an offer.");
+ // FIXME: Known popular UA's currently end up here...
+ inviteResponse.prack({ extraHeaders });
+ }
+ return Promise.resolve();
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveLocalOffer:
+ // INVITE with offer and session only has that initial local offer.
+ if (responseReliable) {
+ inviteResponse.prack({ extraHeaders });
+ }
+ return Promise.resolve();
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].HaveRemoteOffer:
+ if (!responseReliable) {
+ // The initial offer MUST be in either an INVITE or, if not there,
+ // in the first reliable non-failure message from the UAS back to
+ // the UAC.
+ // https://tools.ietf.org/html/rfc3261#section-13.2.1
+ // According to Section 13.2.1 of [RFC3261], 'The first reliable
+ // non-failure message' must have an offer if there is no offer in the
+ // INVITE request. This means that the User Agent (UA) that receives
+ // the INVITE request without an offer must include an offer in the
+ // first reliable response with 100rel extension. If no reliable
+ // provisional response has been sent, the User Agent Server (UAS) must
+ // include an offer when sending 2xx response.
+ // https://tools.ietf.org/html/rfc6337#section-2.2
+ this.logger.warn("Non-reliable provisional response MUST NOT contain an initial offer, discarding response.");
+ return Promise.resolve();
+ }
+ {
+ // If the initial offer is in the first reliable non-failure
+ // message from the UAS back to UAC, the answer MUST be in the
+ // acknowledgement for that message
+ const sdh = this.sessionDescriptionHandlerFactory(this, this.userAgent.configuration.sessionDescriptionHandlerFactoryOptions || {});
+ if ((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onSessionDescriptionHandler) {
+ this.delegate.onSessionDescriptionHandler(sdh, true);
+ }
+ this.earlyMediaSessionDescriptionHandlers.set(session.id, sdh);
+ return sdh
+ .setDescription(response.body, this.sessionDescriptionHandlerOptions, this.sessionDescriptionHandlerModifiers)
+ .then(() => sdh.getDescription(this.sessionDescriptionHandlerOptions, this.sessionDescriptionHandlerModifiers, this.sessionDescriptionHandlerModifiersPostICEGathering))
+ .then((description) => {
+ const body = {
+ contentDisposition: "session",
+ contentType: description.contentType,
+ content: description.body
+ };
+ inviteResponse.prack({ extraHeaders, body });
+ })
+ .catch((error) => {
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ throw error;
+ });
+ }
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Stable:
+ // This session has completed an initial offer/answer exchange, so...
+ // - INVITE with SDP and this provisional response MAY be reliable
+ // - INVITE without SDP and this provisional response MAY be reliable
+ if (responseReliable) {
+ inviteResponse.prack({ extraHeaders });
+ }
+ if (this.earlyMedia && !this.earlyMediaDialog) {
+ this.earlyMediaDialog = session;
+ const answer = session.answer;
+ if (!answer) {
+ throw new Error("Answer is undefined.");
+ }
+ const options = {
+ sessionDescriptionHandlerModifiers: this.sessionDescriptionHandlerModifiers,
+ sessionDescriptionHandlerOptions: this.sessionDescriptionHandlerOptions
+ };
+ return this.setAnswer(answer, options).catch((error) => {
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ throw error;
+ });
+ }
+ return Promise.resolve();
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SignalingState"].Closed:
+ // Dialog has terminated.
+ return Promise.reject(new Error("Terminated."));
+ default:
+ throw new Error("Unknown session signaling state.");
+ }
+ }
+ /**
+ * Handle final response to initial INVITE.
+ * @param inviteResponse - 3xx response.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onRedirect(inviteResponse) {
+ this.logger.log("Inviter.onRedirect");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing && this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminating) {
+ this.logger.error(`Redirect received while in state ${this.state}, dropping response`);
+ return;
+ }
+ // transition state
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ }
+ /**
+ * Handle final response to initial INVITE.
+ * @param inviteResponse - 4xx, 5xx, or 6xx response.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onReject(inviteResponse) {
+ this.logger.log("Inviter.onReject");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing && this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminating) {
+ this.logger.error(`Reject received while in state ${this.state}, dropping response`);
+ return;
+ }
+ // transition state
+ this.stateTransition(_session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Terminated);
+ }
+ /**
+ * Handle final response to initial INVITE.
+ * @param inviteResponse - 100 response.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onTrying(inviteResponse) {
+ this.logger.log("Inviter.onTrying");
+ // validate state
+ if (this.state !== _session_state__WEBPACK_IMPORTED_MODULE_3__["SessionState"].Establishing) {
+ this.logger.error(`Trying received while in state ${this.state}, dropping response`);
+ return;
+ }
+ }
+}
+
+
+/***/ }),
+/* 124 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 125 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 126 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Messager", function() { return Messager; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+
+/**
+ * A messager sends a {@link Message} (outgoing MESSAGE).
+ * @public
+ */
+class Messager {
+ /**
+ * Constructs a new instance of the `Messager` class.
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @param targetURI - Request URI identifying the target of the message.
+ * @param content - Content for the body of the message.
+ * @param contentType - Content type of the body of the message.
+ * @param options - Options bucket. See {@link MessagerOptions} for details.
+ */
+ constructor(userAgent, targetURI, content, contentType = "text/plain", options = {}) {
+ // Logger
+ this.logger = userAgent.getLogger("sip.Messager");
+ // Default options params
+ options.params = options.params || {};
+ // URIs
+ let fromURI = userAgent.userAgentCore.configuration.aor;
+ if (options.params.fromUri) {
+ fromURI =
+ typeof options.params.fromUri === "string" ? _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(options.params.fromUri) : options.params.fromUri;
+ }
+ if (!fromURI) {
+ throw new TypeError("Invalid from URI: " + options.params.fromUri);
+ }
+ let toURI = targetURI;
+ if (options.params.toUri) {
+ toURI = typeof options.params.toUri === "string" ? _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(options.params.toUri) : options.params.toUri;
+ }
+ if (!toURI) {
+ throw new TypeError("Invalid to URI: " + options.params.toUri);
+ }
+ // Message params
+ const params = options.params ? Object.assign({}, options.params) : {};
+ // Extra headers
+ const extraHeaders = (options.extraHeaders || []).slice();
+ // Body
+ const contentDisposition = "render";
+ const body = {
+ contentDisposition,
+ contentType,
+ content
+ };
+ // Build the request
+ this.request = userAgent.userAgentCore.makeOutgoingRequestMessage(_core__WEBPACK_IMPORTED_MODULE_0__["C"].MESSAGE, targetURI, fromURI, toURI, params, extraHeaders, body);
+ // User agent
+ this.userAgent = userAgent;
+ }
+ /**
+ * Send the message.
+ */
+ message(options = {}) {
+ this.userAgent.userAgentCore.request(this.request, options.requestDelegate);
+ return Promise.resolve();
+ }
+}
+
+
+/***/ }),
+/* 127 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 128 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 129 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "PublisherState", function() { return PublisherState; });
+/**
+ * {@link Publisher} state.
+ * @remarks
+ * The {@link Publisher} behaves in a deterministic manner according to the following
+ * Finite State Machine (FSM).
+ * ```txt
+ * __________________________________________
+ * | __________________________ |
+ * Publisher | | v v
+ * Constructed -> Initial -> Published -> Unpublished -> Terminated
+ * | ^____________| ^
+ * |______________________________|
+ * ```
+ * @public
+ */
+var PublisherState;
+(function (PublisherState) {
+ PublisherState["Initial"] = "Initial";
+ PublisherState["Published"] = "Published";
+ PublisherState["Unpublished"] = "Unpublished";
+ PublisherState["Terminated"] = "Terminated";
+})(PublisherState || (PublisherState = {}));
+
+
+/***/ }),
+/* 130 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 131 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Publisher", function() { return Publisher; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _emitter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(108);
+/* harmony import */ var _publisher_state__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(129);
+
+
+
+/**
+ * A publisher publishes a publication (outgoing PUBLISH).
+ * @public
+ */
+class Publisher {
+ /**
+ * Constructs a new instance of the `Publisher` class.
+ *
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @param targetURI - Request URI identifying the target of the message.
+ * @param eventType - The event type identifying the published document.
+ * @param options - Options bucket. See {@link PublisherOptions} for details.
+ */
+ constructor(userAgent, targetURI, eventType, options = {}) {
+ this.disposed = false;
+ /** The publication state. */
+ this._state = _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Initial;
+ // state emitter
+ this._stateEventEmitter = new _emitter__WEBPACK_IMPORTED_MODULE_1__["EmitterImpl"]();
+ this.userAgent = userAgent;
+ options.extraHeaders = (options.extraHeaders || []).slice();
+ options.contentType = options.contentType || "text/plain";
+ if (typeof options.expires !== "number" || options.expires % 1 !== 0) {
+ options.expires = 3600;
+ }
+ else {
+ options.expires = Number(options.expires);
+ }
+ if (typeof options.unpublishOnClose !== "boolean") {
+ options.unpublishOnClose = true;
+ }
+ this.target = targetURI;
+ this.event = eventType;
+ this.options = options;
+ this.pubRequestExpires = options.expires;
+ this.logger = userAgent.getLogger("sip.Publisher");
+ const params = options.params || {};
+ const fromURI = params.fromUri ? params.fromUri : userAgent.userAgentCore.configuration.aor;
+ const toURI = params.toUri ? params.toUri : targetURI;
+ let body;
+ if (options.body && options.contentType) {
+ const contentDisposition = "render";
+ const contentType = options.contentType;
+ const content = options.body;
+ body = {
+ contentDisposition,
+ contentType,
+ content
+ };
+ }
+ const extraHeaders = (options.extraHeaders || []).slice();
+ // Build the request
+ this.request = userAgent.userAgentCore.makeOutgoingRequestMessage(_core__WEBPACK_IMPORTED_MODULE_0__["C"].PUBLISH, targetURI, fromURI, toURI, params, extraHeaders, body);
+ // Identifier
+ this.id = this.target.toString() + ":" + this.event;
+ // Add to the user agent's publisher collection.
+ this.userAgent._publishers[this.id] = this;
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ if (this.disposed) {
+ return Promise.resolve();
+ }
+ this.disposed = true;
+ this.logger.log(`Publisher ${this.id} in state ${this.state} is being disposed`);
+ // Remove from the user agent's publisher collection
+ delete this.userAgent._publishers[this.id];
+ // Send unpublish, if requested
+ if (this.options.unpublishOnClose && this.state === _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Published) {
+ return this.unpublish();
+ }
+ if (this.publishRefreshTimer) {
+ clearTimeout(this.publishRefreshTimer);
+ this.publishRefreshTimer = undefined;
+ }
+ this.pubRequestBody = undefined;
+ this.pubRequestExpires = 0;
+ this.pubRequestEtag = undefined;
+ return Promise.resolve();
+ }
+ /** The publication state. */
+ get state() {
+ return this._state;
+ }
+ /** Emits when the publisher state changes. */
+ get stateChange() {
+ return this._stateEventEmitter;
+ }
+ /**
+ * Publish.
+ * @param content - Body to publish
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ publish(content, options = {}) {
+ // Clean up before the run
+ if (this.publishRefreshTimer) {
+ clearTimeout(this.publishRefreshTimer);
+ this.publishRefreshTimer = undefined;
+ }
+ // is Initial or Modify request
+ this.options.body = content;
+ this.pubRequestBody = this.options.body;
+ if (this.pubRequestExpires === 0) {
+ // This is Initial request after unpublish
+ if (this.options.expires === undefined) {
+ throw new Error("Expires undefined.");
+ }
+ this.pubRequestExpires = this.options.expires;
+ this.pubRequestEtag = undefined;
+ }
+ this.sendPublishRequest();
+ return Promise.resolve();
+ }
+ /**
+ * Unpublish.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ unpublish(options = {}) {
+ // Clean up before the run
+ if (this.publishRefreshTimer) {
+ clearTimeout(this.publishRefreshTimer);
+ this.publishRefreshTimer = undefined;
+ }
+ this.pubRequestBody = undefined;
+ this.pubRequestExpires = 0;
+ if (this.pubRequestEtag !== undefined) {
+ this.sendPublishRequest();
+ }
+ return Promise.resolve();
+ }
+ /** @internal */
+ receiveResponse(response) {
+ const statusCode = response.statusCode || 0;
+ switch (true) {
+ case /^1[0-9]{2}$/.test(statusCode.toString()):
+ break;
+ case /^2[0-9]{2}$/.test(statusCode.toString()):
+ // Set SIP-Etag
+ if (response.hasHeader("SIP-ETag")) {
+ this.pubRequestEtag = response.getHeader("SIP-ETag");
+ }
+ else {
+ this.logger.warn("SIP-ETag header missing in a 200-class response to PUBLISH");
+ }
+ // Update Expire
+ if (response.hasHeader("Expires")) {
+ const expires = Number(response.getHeader("Expires"));
+ if (typeof expires === "number" && expires >= 0 && expires <= this.pubRequestExpires) {
+ this.pubRequestExpires = expires;
+ }
+ else {
+ this.logger.warn("Bad Expires header in a 200-class response to PUBLISH");
+ }
+ }
+ else {
+ this.logger.warn("Expires header missing in a 200-class response to PUBLISH");
+ }
+ if (this.pubRequestExpires !== 0) {
+ // Schedule refresh
+ this.publishRefreshTimer = setTimeout(() => this.refreshRequest(), this.pubRequestExpires * 900);
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Published);
+ }
+ else {
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished);
+ }
+ break;
+ case /^412$/.test(statusCode.toString()):
+ // 412 code means no matching ETag - possibly the PUBLISH expired
+ // Resubmit as new request, if the current request is not a "remove"
+ if (this.pubRequestEtag !== undefined && this.pubRequestExpires !== 0) {
+ this.logger.warn("412 response to PUBLISH, recovering");
+ this.pubRequestEtag = undefined;
+ if (this.options.body === undefined) {
+ throw new Error("Body undefined.");
+ }
+ this.publish(this.options.body);
+ }
+ else {
+ this.logger.warn("412 response to PUBLISH, recovery failed");
+ this.pubRequestExpires = 0;
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished);
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated);
+ }
+ break;
+ case /^423$/.test(statusCode.toString()):
+ // 423 code means we need to adjust the Expires interval up
+ if (this.pubRequestExpires !== 0 && response.hasHeader("Min-Expires")) {
+ const minExpires = Number(response.getHeader("Min-Expires"));
+ if (typeof minExpires === "number" || minExpires > this.pubRequestExpires) {
+ this.logger.warn("423 code in response to PUBLISH, adjusting the Expires value and trying to recover");
+ this.pubRequestExpires = minExpires;
+ if (this.options.body === undefined) {
+ throw new Error("Body undefined.");
+ }
+ this.publish(this.options.body);
+ }
+ else {
+ this.logger.warn("Bad 423 response Min-Expires header received for PUBLISH");
+ this.pubRequestExpires = 0;
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished);
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated);
+ }
+ }
+ else {
+ this.logger.warn("423 response to PUBLISH, recovery failed");
+ this.pubRequestExpires = 0;
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished);
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated);
+ }
+ break;
+ default:
+ this.pubRequestExpires = 0;
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished);
+ this.stateTransition(_publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated);
+ break;
+ }
+ // Do the cleanup
+ if (this.pubRequestExpires === 0) {
+ if (this.publishRefreshTimer) {
+ clearTimeout(this.publishRefreshTimer);
+ this.publishRefreshTimer = undefined;
+ }
+ this.pubRequestBody = undefined;
+ this.pubRequestEtag = undefined;
+ }
+ }
+ /** @internal */
+ send() {
+ return this.userAgent.userAgentCore.publish(this.request, {
+ onAccept: (response) => this.receiveResponse(response.message),
+ onProgress: (response) => this.receiveResponse(response.message),
+ onRedirect: (response) => this.receiveResponse(response.message),
+ onReject: (response) => this.receiveResponse(response.message),
+ onTrying: (response) => this.receiveResponse(response.message)
+ });
+ }
+ refreshRequest() {
+ // Clean up before the run
+ if (this.publishRefreshTimer) {
+ clearTimeout(this.publishRefreshTimer);
+ this.publishRefreshTimer = undefined;
+ }
+ // This is Refresh request
+ this.pubRequestBody = undefined;
+ if (this.pubRequestEtag === undefined) {
+ throw new Error("Etag undefined");
+ }
+ if (this.pubRequestExpires === 0) {
+ throw new Error("Expires zero");
+ }
+ this.sendPublishRequest();
+ }
+ sendPublishRequest() {
+ const reqOptions = Object.assign({}, this.options);
+ reqOptions.extraHeaders = (this.options.extraHeaders || []).slice();
+ reqOptions.extraHeaders.push("Event: " + this.event);
+ reqOptions.extraHeaders.push("Expires: " + this.pubRequestExpires);
+ if (this.pubRequestEtag !== undefined) {
+ reqOptions.extraHeaders.push("SIP-If-Match: " + this.pubRequestEtag);
+ }
+ const ruri = this.target;
+ const params = this.options.params || {};
+ let bodyAndContentType;
+ if (this.pubRequestBody !== undefined) {
+ if (this.options.contentType === undefined) {
+ throw new Error("Content type undefined.");
+ }
+ bodyAndContentType = {
+ body: this.pubRequestBody,
+ contentType: this.options.contentType
+ };
+ }
+ let body;
+ if (bodyAndContentType) {
+ body = Object(_core__WEBPACK_IMPORTED_MODULE_0__["fromBodyLegacy"])(bodyAndContentType);
+ }
+ this.request = this.userAgent.userAgentCore.makeOutgoingRequestMessage(_core__WEBPACK_IMPORTED_MODULE_0__["C"].PUBLISH, ruri, params.fromUri ? params.fromUri : this.userAgent.userAgentCore.configuration.aor, params.toUri ? params.toUri : this.target, params, reqOptions.extraHeaders, body);
+ return this.send();
+ }
+ /**
+ * Transition publication state.
+ */
+ stateTransition(newState) {
+ const invalidTransition = () => {
+ throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
+ };
+ // Validate transition
+ switch (this._state) {
+ case _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Initial:
+ if (newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Published &&
+ newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished &&
+ newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Published:
+ if (newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished && newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Unpublished:
+ if (newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Published && newState !== _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated:
+ invalidTransition();
+ break;
+ default:
+ throw new Error("Unrecognized state.");
+ }
+ // Transition
+ this._state = newState;
+ this.logger.log(`Publication transitioned to state ${this._state}`);
+ this._stateEventEmitter.emit(this._state);
+ // Dispose
+ if (newState === _publisher_state__WEBPACK_IMPORTED_MODULE_2__["PublisherState"].Terminated) {
+ this.dispose();
+ }
+ }
+}
+
+
+/***/ }),
+/* 132 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 133 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 134 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "RegistererState", function() { return RegistererState; });
+/**
+ * {@link Registerer} state.
+ * @remarks
+ * The {@link Registerer} behaves in a deterministic manner according to the following
+ * Finite State Machine (FSM).
+ * ```txt
+ * __________________________________________
+ * | __________________________ |
+ * Registerer | | v v
+ * Constructed -> Initial -> Registered -> Unregistered -> Terminated
+ * | ^____________| ^
+ * |______________________________|
+ * ```
+ * @public
+ */
+var RegistererState;
+(function (RegistererState) {
+ RegistererState["Initial"] = "Initial";
+ RegistererState["Registered"] = "Registered";
+ RegistererState["Unregistered"] = "Unregistered";
+ RegistererState["Terminated"] = "Terminated";
+})(RegistererState || (RegistererState = {}));
+
+
+/***/ }),
+/* 135 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 136 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Registerer", function() { return Registerer; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _emitter__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(108);
+/* harmony import */ var _exceptions__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(3);
+/* harmony import */ var _registerer_state__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(134);
+
+
+
+
+/**
+ * A registerer registers a contact for an address of record (outgoing REGISTER).
+ * @public
+ */
+class Registerer {
+ /**
+ * Constructs a new instance of the `Registerer` class.
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @param options - Options bucket. See {@link RegistererOptions} for details.
+ */
+ constructor(userAgent, options = {}) {
+ this.disposed = false;
+ /** The contacts returned from the most recent accepted REGISTER request. */
+ this._contacts = [];
+ /** The number of seconds to wait before retrying to register. */
+ this._retryAfter = undefined;
+ /** The registration state. */
+ this._state = _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Initial;
+ /** True is waiting for final response to outstanding REGISTER request. */
+ this._waiting = false;
+ // state emitter
+ this._stateEventEmitter = new _emitter__WEBPACK_IMPORTED_MODULE_1__["EmitterImpl"]();
+ // waiting emitter
+ this._waitingEventEmitter = new _emitter__WEBPACK_IMPORTED_MODULE_1__["EmitterImpl"]();
+ // Set user agent
+ this.userAgent = userAgent;
+ // Default registrar is domain portion of user agent uri
+ const defaultUserAgentRegistrar = userAgent.configuration.uri.clone();
+ defaultUserAgentRegistrar.user = undefined;
+ // Initialize configuration
+ this.options = Object.assign(Object.assign(Object.assign({}, Registerer.defaultOptions()), { registrar: defaultUserAgentRegistrar }), Registerer.stripUndefinedProperties(options));
+ // Make sure we are not using references to array options
+ this.options.extraContactHeaderParams = (this.options.extraContactHeaderParams || []).slice();
+ this.options.extraHeaders = (this.options.extraHeaders || []).slice();
+ // Make sure we are not using references to registrar uri
+ if (!this.options.registrar) {
+ throw new Error("Registrar undefined.");
+ }
+ this.options.registrar = this.options.registrar.clone();
+ // Set instanceId and regId conditional defaults and validate
+ if (this.options.regId && !this.options.instanceId) {
+ this.options.instanceId = Registerer.newUUID();
+ }
+ else if (!this.options.regId && this.options.instanceId) {
+ this.options.regId = 1;
+ }
+ if (this.options.instanceId && _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].parse(this.options.instanceId, "uuid") === -1) {
+ throw new Error("Invalid instanceId.");
+ }
+ if (this.options.regId && this.options.regId < 0) {
+ throw new Error("Invalid regId.");
+ }
+ const registrar = this.options.registrar;
+ const fromURI = (this.options.params && this.options.params.fromUri) || userAgent.userAgentCore.configuration.aor;
+ const toURI = (this.options.params && this.options.params.toUri) || userAgent.configuration.uri;
+ const params = this.options.params || {};
+ const extraHeaders = (options.extraHeaders || []).slice();
+ // Build the request
+ this.request = userAgent.userAgentCore.makeOutgoingRequestMessage(_core__WEBPACK_IMPORTED_MODULE_0__["C"].REGISTER, registrar, fromURI, toURI, params, extraHeaders, undefined);
+ // Registration expires
+ this.expires = this.options.expires || Registerer.defaultExpires;
+ if (this.expires < 0) {
+ throw new Error("Invalid expires.");
+ }
+ // initialize logger
+ this.logger = userAgent.getLogger("sip.Registerer");
+ if (this.options.logConfiguration) {
+ this.logger.log("Configuration:");
+ Object.keys(this.options).forEach((key) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const value = this.options[key];
+ switch (key) {
+ case "registrar":
+ this.logger.log("· " + key + ": " + value);
+ break;
+ default:
+ this.logger.log("· " + key + ": " + JSON.stringify(value));
+ }
+ });
+ }
+ // Identifier
+ this.id = this.request.callId + this.request.from.parameters.tag;
+ // Add to the user agent's session collection.
+ this.userAgent._registerers[this.id] = this;
+ }
+ /** Default registerer options. */
+ static defaultOptions() {
+ return {
+ expires: Registerer.defaultExpires,
+ extraContactHeaderParams: [],
+ extraHeaders: [],
+ logConfiguration: true,
+ instanceId: "",
+ params: {},
+ regId: 0,
+ registrar: new _core__WEBPACK_IMPORTED_MODULE_0__["URI"]("sip", "anonymous", "anonymous.invalid")
+ };
+ }
+ // http://stackoverflow.com/users/109538/broofa
+ static newUUID() {
+ const UUID = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = Math.floor(Math.random() * 16);
+ const v = c === "x" ? r : (r % 4) + 8;
+ return v.toString(16);
+ });
+ return UUID;
+ }
+ /**
+ * Strip properties with undefined values from options.
+ * This is a work around while waiting for missing vs undefined to be addressed (or not)...
+ * https://github.com/Microsoft/TypeScript/issues/13195
+ * @param options - Options to reduce
+ */
+ static stripUndefinedProperties(options) {
+ return Object.keys(options).reduce((object, key) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (options[key] !== undefined) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ object[key] = options[key];
+ }
+ return object;
+ }, {});
+ }
+ /** The registered contacts. */
+ get contacts() {
+ return this._contacts.slice();
+ }
+ /**
+ * The number of seconds to wait before retrying to register.
+ * @defaultValue `undefined`
+ * @remarks
+ * When the server rejects a registration request, if it provides a suggested
+ * duration to wait before retrying, that value is available here when and if
+ * the state transitions to `Unsubscribed`. It is also available during the
+ * callback to `onReject` after a call to `register`. (Note that if the state
+ * if already `Unsubscribed`, a rejected request created by `register` will
+ * not cause the state to transition to `Unsubscribed`. One way to avoid this
+ * case is to dispose of `Registerer` when unregistered and create a new
+ * `Registerer` for any attempts to retry registering.)
+ * @example
+ * ```ts
+ * // Checking for retry after on state change
+ * registerer.stateChange.addListener((newState) => {
+ * switch (newState) {
+ * case RegistererState.Unregistered:
+ * const retryAfter = registerer.retryAfter;
+ * break;
+ * }
+ * });
+ *
+ * // Checking for retry after on request rejection
+ * registerer.register({
+ * requestDelegate: {
+ * onReject: () => {
+ * const retryAfter = registerer.retryAfter;
+ * }
+ * }
+ * });
+ * ```
+ */
+ get retryAfter() {
+ return this._retryAfter;
+ }
+ /** The registration state. */
+ get state() {
+ return this._state;
+ }
+ /** Emits when the registerer state changes. */
+ get stateChange() {
+ return this._stateEventEmitter;
+ }
+ /** Destructor. */
+ dispose() {
+ if (this.disposed) {
+ return Promise.resolve();
+ }
+ this.disposed = true;
+ this.logger.log(`Registerer ${this.id} in state ${this.state} is being disposed`);
+ // Remove from the user agent's registerer collection
+ delete this.userAgent._registerers[this.id];
+ // If registered, unregisters and resolves after final response received.
+ return new Promise((resolve) => {
+ const doClose = () => {
+ // If we are registered, unregister and resolve after our state changes
+ if (!this.waiting && this._state === _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered) {
+ this.stateChange.addListener(() => {
+ this.terminated();
+ resolve();
+ }, { once: true });
+ this.unregister();
+ return;
+ }
+ // Otherwise just resolve
+ this.terminated();
+ resolve();
+ };
+ // If we are waiting for an outstanding request, wait for it to finish and then try closing.
+ // Otherwise just try closing.
+ if (this.waiting) {
+ this.waitingChange.addListener(() => {
+ doClose();
+ }, { once: true });
+ }
+ else {
+ doClose();
+ }
+ });
+ }
+ /**
+ * Sends the REGISTER request.
+ * @remarks
+ * If successful, sends re-REGISTER requests prior to registration expiration until `unsubscribe()` is called.
+ * Rejects with `RequestPendingError` if a REGISTER request is already in progress.
+ */
+ register(options = {}) {
+ if (this.state === _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ this.stateError();
+ throw new Error("Registerer terminated. Unable to register.");
+ }
+ if (this.disposed) {
+ this.stateError();
+ throw new Error("Registerer disposed. Unable to register.");
+ }
+ // UAs MUST NOT send a new registration (that is, containing new Contact
+ // header field values, as opposed to a retransmission) until they have
+ // received a final response from the registrar for the previous one or
+ // the previous REGISTER request has timed out.
+ // https://tools.ietf.org/html/rfc3261#section-10.2
+ if (this.waiting) {
+ this.waitingWarning();
+ const error = new _exceptions__WEBPACK_IMPORTED_MODULE_2__["RequestPendingError"]("REGISTER request already in progress, waiting for final response");
+ return Promise.reject(error);
+ }
+ // Options
+ if (options.requestOptions) {
+ this.options = Object.assign(Object.assign({}, this.options), options.requestOptions);
+ }
+ // Extra headers
+ const extraHeaders = (this.options.extraHeaders || []).slice();
+ extraHeaders.push("Contact: " + this.generateContactHeader(this.expires));
+ // this is UA.C.ALLOWED_METHODS, removed to get around circular dependency
+ extraHeaders.push("Allow: " + ["ACK", "CANCEL", "INVITE", "MESSAGE", "BYE", "OPTIONS", "INFO", "NOTIFY", "REFER"].toString());
+ // Call-ID: All registrations from a UAC SHOULD use the same Call-ID
+ // header field value for registrations sent to a particular
+ // registrar.
+ //
+ // CSeq: The CSeq value guarantees proper ordering of REGISTER
+ // requests. A UA MUST increment the CSeq value by one for each
+ // REGISTER request with the same Call-ID.
+ // https://tools.ietf.org/html/rfc3261#section-10.2
+ this.request.cseq++;
+ this.request.setHeader("cseq", this.request.cseq + " REGISTER");
+ this.request.extraHeaders = extraHeaders;
+ this.waitingToggle(true);
+ const outgoingRegisterRequest = this.userAgent.userAgentCore.register(this.request, {
+ onAccept: (response) => {
+ let expires;
+ // FIXME: This does NOT appear to be to spec and should be removed.
+ // I haven't found anywhere that an Expires header may be used in a response.
+ if (response.message.hasHeader("expires")) {
+ expires = Number(response.message.getHeader("expires"));
+ }
+ // 8. The registrar returns a 200 (OK) response. The response MUST
+ // contain Contact header field values enumerating all current
+ // bindings. Each Contact value MUST feature an "expires"
+ // parameter indicating its expiration interval chosen by the
+ // registrar. The response SHOULD include a Date header field.
+ // https://tools.ietf.org/html/rfc3261#section-10.3
+ this._contacts = response.message.getHeaders("contact");
+ let contacts = this._contacts.length;
+ if (!contacts) {
+ this.logger.error("No Contact header in response to REGISTER, dropping response.");
+ this.unregistered();
+ return;
+ }
+ // The 200 (OK) response from the registrar contains a list of Contact
+ // fields enumerating all current bindings. The UA compares each
+ // contact address to see if it created the contact address, using
+ // comparison rules in Section 19.1.4. If so, it updates the expiration
+ // time interval according to the expires parameter or, if absent, the
+ // Expires field value. The UA then issues a REGISTER request for each
+ // of its bindings before the expiration interval has elapsed.
+ // https://tools.ietf.org/html/rfc3261#section-10.2.4
+ let contact;
+ while (contacts--) {
+ contact = response.message.parseHeader("contact", contacts);
+ if (!contact) {
+ throw new Error("Contact undefined");
+ }
+ if (contact.uri.user === this.userAgent.contact.uri.user) {
+ expires = Number(contact.getParam("expires"));
+ break;
+ }
+ contact = undefined;
+ }
+ // There must be a matching contact.
+ if (contact === undefined) {
+ this.logger.error("No Contact header pointing to us, dropping response");
+ this.unregistered();
+ this.waitingToggle(false);
+ return;
+ }
+ // The contact must have an expires.
+ if (expires === undefined) {
+ this.logger.error("Contact pointing to us is missing expires parameter, dropping response");
+ this.unregistered();
+ this.waitingToggle(false);
+ return;
+ }
+ // Save gruu values
+ if (contact.hasParam("temp-gruu")) {
+ const gruu = contact.getParam("temp-gruu");
+ if (gruu) {
+ this.userAgent.contact.tempGruu = _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(gruu.replace(/"/g, ""));
+ }
+ }
+ if (contact.hasParam("pub-gruu")) {
+ const gruu = contact.getParam("pub-gruu");
+ if (gruu) {
+ this.userAgent.contact.pubGruu = _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(gruu.replace(/"/g, ""));
+ }
+ }
+ this.registered(expires);
+ if (options.requestDelegate && options.requestDelegate.onAccept) {
+ options.requestDelegate.onAccept(response);
+ }
+ this.waitingToggle(false);
+ },
+ onProgress: (response) => {
+ if (options.requestDelegate && options.requestDelegate.onProgress) {
+ options.requestDelegate.onProgress(response);
+ }
+ },
+ onRedirect: (response) => {
+ this.logger.error("Redirect received. Not supported.");
+ this.unregistered();
+ if (options.requestDelegate && options.requestDelegate.onRedirect) {
+ options.requestDelegate.onRedirect(response);
+ }
+ this.waitingToggle(false);
+ },
+ onReject: (response) => {
+ if (response.message.statusCode === 423) {
+ // If a UA receives a 423 (Interval Too Brief) response, it MAY retry
+ // the registration after making the expiration interval of all contact
+ // addresses in the REGISTER request equal to or greater than the
+ // expiration interval within the Min-Expires header field of the 423
+ // (Interval Too Brief) response.
+ // https://tools.ietf.org/html/rfc3261#section-10.2.8
+ //
+ // The registrar MAY choose an expiration less than the requested
+ // expiration interval. If and only if the requested expiration
+ // interval is greater than zero AND smaller than one hour AND
+ // less than a registrar-configured minimum, the registrar MAY
+ // reject the registration with a response of 423 (Interval Too
+ // Brief). This response MUST contain a Min-Expires header field
+ // that states the minimum expiration interval the registrar is
+ // willing to honor. It then skips the remaining steps.
+ // https://tools.ietf.org/html/rfc3261#section-10.3
+ if (!response.message.hasHeader("min-expires")) {
+ // This response MUST contain a Min-Expires header field
+ this.logger.error("423 response received for REGISTER without Min-Expires, dropping response");
+ this.unregistered();
+ this.waitingToggle(false);
+ return;
+ }
+ // Increase our registration interval to the suggested minimum
+ this.expires = Number(response.message.getHeader("min-expires"));
+ // Attempt the registration again immediately
+ this.waitingToggle(false);
+ this.register();
+ return;
+ }
+ this.logger.warn(`Failed to register, status code ${response.message.statusCode}`);
+ // The Retry-After header field can be used with a 500 (Server Internal
+ // Error) or 503 (Service Unavailable) response to indicate how long the
+ // service is expected to be unavailable to the requesting client...
+ // https://tools.ietf.org/html/rfc3261#section-20.33
+ let retryAfterDuration = NaN;
+ if (response.message.statusCode === 500 || response.message.statusCode === 503) {
+ const header = response.message.getHeader("retry-after");
+ if (header) {
+ retryAfterDuration = Number.parseInt(header, undefined);
+ }
+ }
+ // Set for the state change (if any) and the delegate callback (if any)
+ this._retryAfter = isNaN(retryAfterDuration) ? undefined : retryAfterDuration;
+ this.unregistered();
+ if (options.requestDelegate && options.requestDelegate.onReject) {
+ options.requestDelegate.onReject(response);
+ }
+ this._retryAfter = undefined;
+ this.waitingToggle(false);
+ },
+ onTrying: (response) => {
+ if (options.requestDelegate && options.requestDelegate.onTrying) {
+ options.requestDelegate.onTrying(response);
+ }
+ }
+ });
+ return Promise.resolve(outgoingRegisterRequest);
+ }
+ /**
+ * Sends the REGISTER request with expires equal to zero.
+ * @remarks
+ * Rejects with `RequestPendingError` if a REGISTER request is already in progress.
+ */
+ unregister(options = {}) {
+ if (this.state === _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ this.stateError();
+ throw new Error("Registerer terminated. Unable to register.");
+ }
+ if (this.disposed) {
+ if (this.state !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered) {
+ // allows unregister while disposing and registered
+ this.stateError();
+ throw new Error("Registerer disposed. Unable to register.");
+ }
+ }
+ // UAs MUST NOT send a new registration (that is, containing new Contact
+ // header field values, as opposed to a retransmission) until they have
+ // received a final response from the registrar for the previous one or
+ // the previous REGISTER request has timed out.
+ // https://tools.ietf.org/html/rfc3261#section-10.2
+ if (this.waiting) {
+ this.waitingWarning();
+ const error = new _exceptions__WEBPACK_IMPORTED_MODULE_2__["RequestPendingError"]("REGISTER request already in progress, waiting for final response");
+ return Promise.reject(error);
+ }
+ if (this._state !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered && !options.all) {
+ this.logger.warn("Not currently registered, but sending an unregister anyway.");
+ }
+ // Extra headers
+ const extraHeaders = ((options.requestOptions && options.requestOptions.extraHeaders) || []).slice();
+ this.request.extraHeaders = extraHeaders;
+ // Registrations are soft state and expire unless refreshed, but can
+ // also be explicitly removed. A client can attempt to influence the
+ // expiration interval selected by the registrar as described in Section
+ // 10.2.1. A UA requests the immediate removal of a binding by
+ // specifying an expiration interval of "0" for that contact address in
+ // a REGISTER request. UAs SHOULD support this mechanism so that
+ // bindings can be removed before their expiration interval has passed.
+ //
+ // The REGISTER-specific Contact header field value of "*" applies to
+ // all registrations, but it MUST NOT be used unless the Expires header
+ // field is present with a value of "0".
+ // https://tools.ietf.org/html/rfc3261#section-10.2.2
+ if (options.all) {
+ extraHeaders.push("Contact: *");
+ extraHeaders.push("Expires: 0");
+ }
+ else {
+ extraHeaders.push("Contact: " + this.generateContactHeader(0));
+ }
+ // Call-ID: All registrations from a UAC SHOULD use the same Call-ID
+ // header field value for registrations sent to a particular
+ // registrar.
+ //
+ // CSeq: The CSeq value guarantees proper ordering of REGISTER
+ // requests. A UA MUST increment the CSeq value by one for each
+ // REGISTER request with the same Call-ID.
+ // https://tools.ietf.org/html/rfc3261#section-10.2
+ this.request.cseq++;
+ this.request.setHeader("cseq", this.request.cseq + " REGISTER");
+ // Pre-emptive clear the registration timer to avoid a race condition where
+ // this timer fires while waiting for a final response to the unsubscribe.
+ if (this.registrationTimer !== undefined) {
+ clearTimeout(this.registrationTimer);
+ this.registrationTimer = undefined;
+ }
+ this.waitingToggle(true);
+ const outgoingRegisterRequest = this.userAgent.userAgentCore.register(this.request, {
+ onAccept: (response) => {
+ this._contacts = response.message.getHeaders("contact"); // Update contacts
+ this.unregistered();
+ if (options.requestDelegate && options.requestDelegate.onAccept) {
+ options.requestDelegate.onAccept(response);
+ }
+ this.waitingToggle(false);
+ },
+ onProgress: (response) => {
+ if (options.requestDelegate && options.requestDelegate.onProgress) {
+ options.requestDelegate.onProgress(response);
+ }
+ },
+ onRedirect: (response) => {
+ this.logger.error("Unregister redirected. Not currently supported.");
+ this.unregistered();
+ if (options.requestDelegate && options.requestDelegate.onRedirect) {
+ options.requestDelegate.onRedirect(response);
+ }
+ this.waitingToggle(false);
+ },
+ onReject: (response) => {
+ this.logger.error(`Unregister rejected with status code ${response.message.statusCode}`);
+ this.unregistered();
+ if (options.requestDelegate && options.requestDelegate.onReject) {
+ options.requestDelegate.onReject(response);
+ }
+ this.waitingToggle(false);
+ },
+ onTrying: (response) => {
+ if (options.requestDelegate && options.requestDelegate.onTrying) {
+ options.requestDelegate.onTrying(response);
+ }
+ }
+ });
+ return Promise.resolve(outgoingRegisterRequest);
+ }
+ /**
+ * Clear registration timers.
+ */
+ clearTimers() {
+ if (this.registrationTimer !== undefined) {
+ clearTimeout(this.registrationTimer);
+ this.registrationTimer = undefined;
+ }
+ if (this.registrationExpiredTimer !== undefined) {
+ clearTimeout(this.registrationExpiredTimer);
+ this.registrationExpiredTimer = undefined;
+ }
+ }
+ /**
+ * Generate Contact Header
+ */
+ generateContactHeader(expires) {
+ let contact = this.userAgent.contact.toString();
+ if (this.options.regId && this.options.instanceId) {
+ contact += ";reg-id=" + this.options.regId;
+ contact += ';+sip.instance=""';
+ }
+ if (this.options.extraContactHeaderParams) {
+ this.options.extraContactHeaderParams.forEach((header) => {
+ contact += ";" + header;
+ });
+ }
+ contact += ";expires=" + expires;
+ return contact;
+ }
+ /**
+ * Helper function, called when registered.
+ */
+ registered(expires) {
+ this.clearTimers();
+ // Re-Register before the expiration interval has elapsed.
+ // For that, decrease the expires value. ie: 3 seconds
+ this.registrationTimer = setTimeout(() => {
+ this.registrationTimer = undefined;
+ this.register();
+ }, expires * 1000 - 3000);
+ // We are unregistered if the registration expires.
+ this.registrationExpiredTimer = setTimeout(() => {
+ this.logger.warn("Registration expired");
+ this.unregistered();
+ }, expires * 1000);
+ if (this._state !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered) {
+ this.stateTransition(_registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered);
+ }
+ }
+ /**
+ * Helper function, called when unregistered.
+ */
+ unregistered() {
+ this.clearTimers();
+ if (this._state !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Unregistered) {
+ this.stateTransition(_registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Unregistered);
+ }
+ }
+ /**
+ * Helper function, called when terminated.
+ */
+ terminated() {
+ this.clearTimers();
+ if (this._state !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ this.stateTransition(_registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated);
+ }
+ }
+ /**
+ * Transition registration state.
+ */
+ stateTransition(newState) {
+ const invalidTransition = () => {
+ throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
+ };
+ // Validate transition
+ switch (this._state) {
+ case _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Initial:
+ if (newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered &&
+ newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Unregistered &&
+ newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered:
+ if (newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Unregistered && newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Unregistered:
+ if (newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Registered && newState !== _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated:
+ invalidTransition();
+ break;
+ default:
+ throw new Error("Unrecognized state.");
+ }
+ // Transition
+ this._state = newState;
+ this.logger.log(`Registration transitioned to state ${this._state}`);
+ this._stateEventEmitter.emit(this._state);
+ // Dispose
+ if (newState === _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated) {
+ this.dispose();
+ }
+ }
+ /** True if the registerer is currently waiting for final response to a REGISTER request. */
+ get waiting() {
+ return this._waiting;
+ }
+ /** Emits when the registerer waiting state changes. */
+ get waitingChange() {
+ return this._waitingEventEmitter;
+ }
+ /**
+ * Toggle waiting.
+ */
+ waitingToggle(waiting) {
+ if (this._waiting === waiting) {
+ throw new Error(`Invalid waiting transition from ${this._waiting} to ${waiting}`);
+ }
+ this._waiting = waiting;
+ this.logger.log(`Waiting toggled to ${this._waiting}`);
+ this._waitingEventEmitter.emit(this._waiting);
+ }
+ /** Hopefully helpful as the standard behavior has been found to be unexpected. */
+ waitingWarning() {
+ let message = "An attempt was made to send a REGISTER request while a prior one was still in progress.";
+ message += " RFC 3261 requires UAs MUST NOT send a new registration until they have received a final response";
+ message += " from the registrar for the previous one or the previous REGISTER request has timed out.";
+ message += " Note that if the transport disconnects, you still must wait for the prior request to time out before";
+ message +=
+ " sending a new REGISTER request or alternatively dispose of the current Registerer and create a new Registerer.";
+ this.logger.warn(message);
+ }
+ /** Hopefully helpful as the standard behavior has been found to be unexpected. */
+ stateError() {
+ const reason = this.state === _registerer_state__WEBPACK_IMPORTED_MODULE_3__["RegistererState"].Terminated ? "is in 'Terminated' state" : "has been disposed";
+ let message = `An attempt was made to send a REGISTER request when the Registerer ${reason}.`;
+ message += " The Registerer transitions to 'Terminated' when Registerer.dispose() is called.";
+ message += " Perhaps you called UserAgent.stop() which dipsoses of all Registerers?";
+ this.logger.error(message);
+ }
+}
+Registerer.defaultExpires = 600;
+
+
+/***/ }),
+/* 137 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 138 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 139 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 140 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 141 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 142 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 143 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 144 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 145 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 146 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 147 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 148 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Subscriber", function() { return Subscriber; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _core_user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(82);
+/* harmony import */ var _notification__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(116);
+/* harmony import */ var _subscription__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(149);
+/* harmony import */ var _subscription_state__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(150);
+
+
+
+
+
+/**
+ * A subscriber establishes a {@link Subscription} (outgoing SUBSCRIBE).
+ *
+ * @remarks
+ * This is (more or less) an implementation of a "subscriber" as
+ * defined in RFC 6665 "SIP-Specific Event Notifications".
+ * https://tools.ietf.org/html/rfc6665
+ *
+ * @example
+ * ```ts
+ * // Create a new subscriber.
+ * const targetURI = new URI("sip", "alice", "example.com");
+ * const eventType = "example-name"; // https://www.iana.org/assignments/sip-events/sip-events.xhtml
+ * const subscriber = new Subscriber(userAgent, targetURI, eventType);
+ *
+ * // Add delegate to handle event notifications.
+ * subscriber.delegate = {
+ * onNotify: (notification: Notification) => {
+ * // handle notification here
+ * }
+ * };
+ *
+ * // Monitor subscription state changes.
+ * subscriber.stateChange.addListener((newState: SubscriptionState) => {
+ * if (newState === SubscriptionState.Terminated) {
+ * // handle state change here
+ * }
+ * });
+ *
+ * // Attempt to establish the subscription
+ * subscriber.subscribe();
+ *
+ * // Sometime later when done with subscription
+ * subscriber.unsubscribe();
+ * ```
+ *
+ * @public
+ */
+class Subscriber extends _subscription__WEBPACK_IMPORTED_MODULE_3__["Subscription"] {
+ /**
+ * Constructor.
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @param targetURI - The request URI identifying the subscribed event.
+ * @param eventType - The event type identifying the subscribed event.
+ * @param options - Options bucket. See {@link SubscriberOptions} for details.
+ */
+ constructor(userAgent, targetURI, eventType, options = {}) {
+ super(userAgent, options);
+ this.body = undefined;
+ this.logger = userAgent.getLogger("sip.Subscriber");
+ if (options.body) {
+ this.body = {
+ body: options.body,
+ contentType: options.contentType ? options.contentType : "application/sdp"
+ };
+ }
+ this.targetURI = targetURI;
+ // Subscription event
+ this.event = eventType;
+ // Subscription expires
+ if (options.expires === undefined) {
+ this.expires = 3600;
+ }
+ else if (typeof options.expires !== "number") {
+ // pre-typescript type guard
+ this.logger.warn(`Option "expires" must be a number. Using default of 3600.`);
+ this.expires = 3600;
+ }
+ else {
+ this.expires = options.expires;
+ }
+ // Subscription extra headers
+ this.extraHeaders = (options.extraHeaders || []).slice();
+ // Subscription context.
+ this.subscriberRequest = this.initSubscriberRequest();
+ this.outgoingRequestMessage = this.subscriberRequest.message;
+ // Add to UserAgent's collection
+ this.id = this.outgoingRequestMessage.callId + this.outgoingRequestMessage.from.parameters.tag + this.event;
+ this._userAgent._subscriptions[this.id] = this;
+ }
+ /**
+ * Destructor.
+ * @internal
+ */
+ dispose() {
+ if (this.disposed) {
+ return Promise.resolve();
+ }
+ this.logger.log(`Subscription ${this.id} in state ${this.state} is being disposed`);
+ // Remove from the user agent's subscription collection
+ delete this._userAgent._subscriptions[this.id];
+ // Clear timers
+ if (this.retryAfterTimer) {
+ clearTimeout(this.retryAfterTimer);
+ this.retryAfterTimer = undefined;
+ }
+ // Dispose subscriber request
+ this.subscriberRequest.dispose();
+ // Make sure to dispose of our parent, then unsubscribe the
+ // subscription dialog (if need be) and resolve when it has terminated.
+ return super.dispose().then(() => {
+ // If we have never subscribed there is nothing to wait on.
+ // If we are already transitioned to terminated there is no need to unsubscribe again.
+ if (this.state !== _subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Subscribed) {
+ return;
+ }
+ if (!this._dialog) {
+ throw new Error("Dialog undefined.");
+ }
+ if (this._dialog.subscriptionState === _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Pending ||
+ this._dialog.subscriptionState === _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Active) {
+ const dialog = this._dialog;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ return new Promise((resolve, reject) => {
+ dialog.delegate = {
+ onTerminated: () => resolve()
+ };
+ dialog.unsubscribe();
+ });
+ }
+ });
+ }
+ /**
+ * Subscribe to event notifications.
+ *
+ * @remarks
+ * Send an initial SUBSCRIBE request if no subscription as been established.
+ * Sends a re-SUBSCRIBE request if the subscription is "active".
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ subscribe(options = {}) {
+ switch (this.subscriberRequest.state) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Initial:
+ // we can end up here when retrying so only state transition if in SubscriptionState.Initial state
+ if (this.state === _subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Initial) {
+ this.stateTransition(_subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].NotifyWait);
+ }
+ this.subscriberRequest.subscribe().then((result) => {
+ if (result.success) {
+ if (result.success.subscription) {
+ this._dialog = result.success.subscription;
+ this._dialog.delegate = {
+ onNotify: (request) => this.onNotify(request),
+ onRefresh: (request) => this.onRefresh(request),
+ onTerminated: () => {
+ // If a call to unsubscribe will state transition to SubscriptionState.Terminated,
+ // but we can end up here after that if the NOTIFY never arrives and timer N fires.
+ if (this.state !== _subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Terminated) {
+ this.stateTransition(_subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Terminated);
+ }
+ }
+ };
+ }
+ this.onNotify(result.success.request);
+ }
+ else if (result.failure) {
+ this.unsubscribe();
+ }
+ });
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].NotifyWait:
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Pending:
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Active:
+ if (this._dialog) {
+ const request = this._dialog.refresh();
+ request.delegate = {
+ onAccept: (response) => this.onAccepted(response),
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onRedirect: (response) => this.unsubscribe(),
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onReject: (response) => this.unsubscribe()
+ };
+ }
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Terminated:
+ break;
+ default:
+ break;
+ }
+ return Promise.resolve();
+ }
+ /**
+ * {@inheritDoc Subscription.unsubscribe}
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ unsubscribe(options = {}) {
+ if (this.disposed) {
+ return Promise.resolve();
+ }
+ switch (this.subscriberRequest.state) {
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Initial:
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].NotifyWait:
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Pending:
+ if (this._dialog) {
+ this._dialog.unsubscribe();
+ // responses intentionally ignored
+ }
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Active:
+ if (this._dialog) {
+ this._dialog.unsubscribe();
+ // responses intentionally ignored
+ }
+ break;
+ case _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Terminated:
+ break;
+ default:
+ throw new Error("Unknown state.");
+ }
+ this.stateTransition(_subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Terminated);
+ return Promise.resolve();
+ }
+ /**
+ * Sends a re-SUBSCRIBE request if the subscription is "active".
+ * @deprecated Use `subscribe` instead.
+ * @internal
+ */
+ _refresh() {
+ if (this.subscriberRequest.state === _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Active) {
+ return this.subscribe();
+ }
+ return Promise.resolve();
+ }
+ /** @internal */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onAccepted(response) {
+ // NOTE: If you think you should do something with this response,
+ // please make sure you understand what it is you are doing and why.
+ // Per the RFC, the first NOTIFY is all that actually matters.
+ }
+ /** @internal */
+ onNotify(request) {
+ // If we've set state to done, no further processing should take place
+ // and we are only interested in cleaning up after the appropriate NOTIFY.
+ if (this.disposed) {
+ request.accept();
+ return;
+ }
+ // State transition if needed.
+ if (this.state !== _subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Subscribed) {
+ this.stateTransition(_subscription_state__WEBPACK_IMPORTED_MODULE_4__["SubscriptionState"].Subscribed);
+ }
+ // Delegate notification.
+ if (this.delegate && this.delegate.onNotify) {
+ const notification = new _notification__WEBPACK_IMPORTED_MODULE_2__["Notification"](request);
+ this.delegate.onNotify(notification);
+ }
+ else {
+ request.accept();
+ }
+ // If the "Subscription-State" value is SubscriptionState.Terminated, the subscriber
+ // MUST consider the subscription terminated. The "expires" parameter
+ // has no semantics for SubscriptionState.Terminated -- notifiers SHOULD NOT include an
+ // "expires" parameter on a "Subscription-State" header field with a
+ // value of SubscriptionState.Terminated, and subscribers MUST ignore any such
+ // parameter, if present. If a reason code is present, the client
+ // should behave as described below. If no reason code or an unknown
+ // reason code is present, the client MAY attempt to re-subscribe at any
+ // time (unless a "retry-after" parameter is present, in which case the
+ // client SHOULD NOT attempt re-subscription until after the number of
+ // seconds specified by the "retry-after" parameter). The reason codes
+ // defined by this document are:
+ // https://tools.ietf.org/html/rfc6665#section-4.1.3
+ const subscriptionState = request.message.parseHeader("Subscription-State");
+ if (subscriptionState && subscriptionState.state) {
+ switch (subscriptionState.state) {
+ case "terminated":
+ if (subscriptionState.reason) {
+ this.logger.log(`Terminated subscription with reason ${subscriptionState.reason}`);
+ switch (subscriptionState.reason) {
+ case "deactivated":
+ case "timeout":
+ this.initSubscriberRequest();
+ this.subscribe();
+ return;
+ case "probation":
+ case "giveup":
+ this.initSubscriberRequest();
+ if (subscriptionState.params && subscriptionState.params["retry-after"]) {
+ this.retryAfterTimer = setTimeout(() => {
+ this.subscribe();
+ }, subscriptionState.params["retry-after"]);
+ }
+ else {
+ this.subscribe();
+ }
+ return;
+ case "rejected":
+ case "noresource":
+ case "invariant":
+ break;
+ }
+ }
+ this.unsubscribe();
+ break;
+ default:
+ break;
+ }
+ }
+ }
+ /** @internal */
+ onRefresh(request) {
+ request.delegate = {
+ onAccept: (response) => this.onAccepted(response)
+ };
+ }
+ initSubscriberRequest() {
+ const options = {
+ extraHeaders: this.extraHeaders,
+ body: this.body ? Object(_core__WEBPACK_IMPORTED_MODULE_0__["fromBodyLegacy"])(this.body) : undefined
+ };
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ this.subscriberRequest = new SubscriberRequest(this._userAgent.userAgentCore, this.targetURI, this.event, this.expires, options);
+ this.subscriberRequest.delegate = {
+ onAccept: (response) => this.onAccepted(response)
+ };
+ return this.subscriberRequest;
+ }
+}
+class SubscriberRequest {
+ constructor(core, target, event, expires, options, delegate) {
+ this.core = core;
+ this.target = target;
+ this.event = event;
+ this.expires = expires;
+ this.subscribed = false;
+ this.logger = core.loggerFactory.getLogger("sip.Subscriber");
+ this.delegate = delegate;
+ const allowHeader = "Allow: " + _core_user_agent_core_allowed_methods__WEBPACK_IMPORTED_MODULE_1__["AllowedMethods"].toString();
+ const extraHeaders = ((options && options.extraHeaders) || []).slice();
+ extraHeaders.push(allowHeader);
+ extraHeaders.push("Event: " + this.event);
+ extraHeaders.push("Expires: " + this.expires);
+ extraHeaders.push("Contact: " + this.core.configuration.contact.toString());
+ const body = options && options.body;
+ this.message = core.makeOutgoingRequestMessage(_core__WEBPACK_IMPORTED_MODULE_0__["C"].SUBSCRIBE, this.target, this.core.configuration.aor, this.target, {}, extraHeaders, body);
+ }
+ /** Destructor. */
+ dispose() {
+ if (this.request) {
+ this.request.waitNotifyStop();
+ this.request.dispose();
+ this.request = undefined;
+ }
+ }
+ /** Subscription state. */
+ get state() {
+ if (this.subscription) {
+ return this.subscription.subscriptionState;
+ }
+ else if (this.subscribed) {
+ return _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].NotifyWait;
+ }
+ else {
+ return _core__WEBPACK_IMPORTED_MODULE_0__["SubscriptionState"].Initial;
+ }
+ }
+ /**
+ * Establish subscription.
+ * @param options Options bucket.
+ */
+ subscribe() {
+ if (this.subscribed) {
+ return Promise.reject(new Error("Not in initial state. Did you call subscribe more than once?"));
+ }
+ this.subscribed = true;
+ return new Promise((resolve) => {
+ if (!this.message) {
+ throw new Error("Message undefined.");
+ }
+ this.request = this.core.subscribe(this.message, {
+ // This SUBSCRIBE request will be confirmed with a final response.
+ // 200-class responses indicate that the subscription has been accepted
+ // and that a NOTIFY request will be sent immediately.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.1
+ onAccept: (response) => {
+ if (this.delegate && this.delegate.onAccept) {
+ this.delegate.onAccept(response);
+ }
+ },
+ // Due to the potential for out-of-order messages, packet loss, and
+ // forking, the subscriber MUST be prepared to receive NOTIFY requests
+ // before the SUBSCRIBE transaction has completed.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.4
+ onNotify: (requestWithSubscription) => {
+ this.subscription = requestWithSubscription.subscription;
+ if (this.subscription) {
+ this.subscription.autoRefresh = true;
+ }
+ resolve({ success: requestWithSubscription });
+ },
+ // If this Timer N expires prior to the receipt of a NOTIFY request,
+ // the subscriber considers the subscription failed, and cleans up
+ // any state associated with the subscription attempt.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.4
+ onNotifyTimeout: () => {
+ resolve({ failure: {} });
+ },
+ // This SUBSCRIBE request will be confirmed with a final response.
+ // Non-200-class final responses indicate that no subscription or new
+ // dialog usage has been created, and no subsequent NOTIFY request will
+ // be sent.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.1
+ onRedirect: (response) => {
+ resolve({ failure: { response } });
+ },
+ // This SUBSCRIBE request will be confirmed with a final response.
+ // Non-200-class final responses indicate that no subscription or new
+ // dialog usage has been created, and no subsequent NOTIFY request will
+ // be sent.
+ // https://tools.ietf.org/html/rfc6665#section-4.1.2.1
+ onReject: (response) => {
+ resolve({ failure: { response } });
+ }
+ });
+ });
+ }
+}
+
+
+/***/ }),
+/* 149 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Subscription", function() { return Subscription; });
+/* harmony import */ var _emitter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(108);
+/* harmony import */ var _subscription_state__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(150);
+
+
+/**
+ * A subscription provides {@link Notification} of events.
+ *
+ * @remarks
+ * See {@link Subscriber} for details on establishing a subscription.
+ *
+ * @public
+ */
+class Subscription {
+ /**
+ * Constructor.
+ * @param userAgent - User agent. See {@link UserAgent} for details.
+ * @internal
+ */
+ constructor(userAgent, options = {}) {
+ this._disposed = false;
+ this._state = _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Initial;
+ this._logger = userAgent.getLogger("sip.Subscription");
+ this._stateEventEmitter = new _emitter__WEBPACK_IMPORTED_MODULE_0__["EmitterImpl"]();
+ this._userAgent = userAgent;
+ this.delegate = options.delegate;
+ }
+ /**
+ * Destructor.
+ */
+ dispose() {
+ if (this._disposed) {
+ return Promise.resolve();
+ }
+ this._disposed = true;
+ this._stateEventEmitter.removeAllListeners();
+ return Promise.resolve();
+ }
+ /**
+ * The subscribed subscription dialog.
+ */
+ get dialog() {
+ return this._dialog;
+ }
+ /**
+ * True if disposed.
+ * @internal
+ */
+ get disposed() {
+ return this._disposed;
+ }
+ /**
+ * Subscription state. See {@link SubscriptionState} for details.
+ */
+ get state() {
+ return this._state;
+ }
+ /**
+ * Emits when the subscription `state` property changes.
+ */
+ get stateChange() {
+ return this._stateEventEmitter;
+ }
+ /** @internal */
+ stateTransition(newState) {
+ const invalidTransition = () => {
+ throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
+ };
+ // Validate transition
+ switch (this._state) {
+ case _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Initial:
+ if (newState !== _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait && newState !== _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].NotifyWait:
+ if (newState !== _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Subscribed && newState !== _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Subscribed:
+ if (newState !== _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ invalidTransition();
+ }
+ break;
+ case _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated:
+ invalidTransition();
+ break;
+ default:
+ throw new Error("Unrecognized state.");
+ }
+ // Guard against duplicate transition
+ if (this._state === newState) {
+ return;
+ }
+ // Transition
+ this._state = newState;
+ this._logger.log(`Subscription ${this._dialog ? this._dialog.id : undefined} transitioned to ${this._state}`);
+ this._stateEventEmitter.emit(this._state);
+ // Dispose
+ if (newState === _subscription_state__WEBPACK_IMPORTED_MODULE_1__["SubscriptionState"].Terminated) {
+ this.dispose();
+ }
+ }
+}
+
+
+/***/ }),
+/* 150 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SubscriptionState", function() { return SubscriptionState; });
+/**
+ * {@link Subscription} state.
+ * @remarks
+ * The {@link Subscription} behaves in a deterministic manner according to the following
+ * Finite State Machine (FSM).
+ * ```txt
+ * _______________________________________
+ * Subscription | v
+ * Constructed -> Initial -> NotifyWait -> Subscribed -> Terminated
+ * |____________________________^
+ * ```
+ * @public
+ */
+var SubscriptionState;
+(function (SubscriptionState) {
+ SubscriptionState["Initial"] = "Initial";
+ SubscriptionState["NotifyWait"] = "NotifyWait";
+ SubscriptionState["Subscribed"] = "Subscribed";
+ SubscriptionState["Terminated"] = "Terminated";
+})(SubscriptionState || (SubscriptionState = {}));
+
+
+/***/ }),
+/* 151 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 152 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 153 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 154 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 155 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 156 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "TransportState", function() { return TransportState; });
+/**
+ * {@link Transport} state.
+ *
+ * @remarks
+ * The {@link Transport} behaves in a deterministic manner according to the following
+ * Finite State Machine (FSM).
+ * ```txt
+ * ______________________________
+ * | ____________ |
+ * Transport v v | |
+ * Constructed -> Disconnected -> Connecting -> Connected -> Disconnecting
+ * ^ ^ |_____________________^ | |
+ * | |_____________________________| |
+ * |_____________________________________________|
+ * ```
+ * @public
+ */
+var TransportState;
+(function (TransportState) {
+ /**
+ * The `connect()` method was called.
+ */
+ TransportState["Connecting"] = "Connecting";
+ /**
+ * The `connect()` method resolved.
+ */
+ TransportState["Connected"] = "Connected";
+ /**
+ * The `disconnect()` method was called.
+ */
+ TransportState["Disconnecting"] = "Disconnecting";
+ /**
+ * The `connect()` method was rejected, or
+ * the `disconnect()` method completed, or
+ * network connectivity was lost.
+ */
+ TransportState["Disconnected"] = "Disconnected";
+})(TransportState || (TransportState = {}));
+
+
+/***/ }),
+/* 157 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 158 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "UserAgentState", function() { return UserAgentState; });
+/**
+ * {@link UserAgent} state.
+ * @remarks
+ * Valid state transitions:
+ * ```
+ * 1. "Started" --> "Stopped"
+ * 2. "Stopped" --> "Started"
+ * ```
+ * @public
+ */
+var UserAgentState;
+(function (UserAgentState) {
+ UserAgentState["Started"] = "Started";
+ UserAgentState["Stopped"] = "Stopped";
+})(UserAgentState || (UserAgentState = {}));
+
+
+/***/ }),
+/* 159 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "UserAgent", function() { return UserAgent; });
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(5);
+/* harmony import */ var _core_messages_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(32);
+/* harmony import */ var _platform_web_session_description_handler__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(160);
+/* harmony import */ var _platform_web_transport__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(171);
+/* harmony import */ var _version__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(1);
+/* harmony import */ var _emitter__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(108);
+/* harmony import */ var _invitation__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(113);
+/* harmony import */ var _inviter__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(123);
+/* harmony import */ var _message__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(115);
+/* harmony import */ var _notification__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(116);
+/* harmony import */ var _user_agent_options__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(119);
+/* harmony import */ var _user_agent_state__WEBPACK_IMPORTED_MODULE_11__ = __webpack_require__(158);
+
+
+
+
+
+
+
+
+
+
+
+
+/**
+ * A user agent sends and receives requests using a `Transport`.
+ *
+ * @remarks
+ * A user agent (UA) is associated with a user via the user's SIP address of record (AOR)
+ * and acts on behalf of that user to send and receive SIP requests. The user agent can
+ * register to receive incoming requests, as well as create and send outbound messages.
+ * The user agent also maintains the Transport over which its signaling travels.
+ *
+ * @public
+ */
+class UserAgent {
+ /**
+ * Constructs a new instance of the `UserAgent` class.
+ * @param options - Options bucket. See {@link UserAgentOptions} for details.
+ */
+ constructor(options = {}) {
+ /** @internal */
+ this._publishers = {};
+ /** @internal */
+ this._registerers = {};
+ /** @internal */
+ this._sessions = {};
+ /** @internal */
+ this._subscriptions = {};
+ this._state = _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped;
+ /** Unload listener. */
+ this.unloadListener = () => {
+ this.stop();
+ };
+ // state emitter
+ this._stateEventEmitter = new _emitter__WEBPACK_IMPORTED_MODULE_5__["EmitterImpl"]();
+ // initialize delegate
+ this.delegate = options.delegate;
+ // initialize configuration
+ this.options = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, UserAgent.defaultOptions()), { sipjsId: Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["createRandomToken"])(5) }), { uri: new _core__WEBPACK_IMPORTED_MODULE_0__["URI"]("sip", "anonymous." + Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["createRandomToken"])(6), "anonymous.invalid") }), { viaHost: Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["createRandomToken"])(12) + ".invalid" }), UserAgent.stripUndefinedProperties(options));
+ // viaHost is hack
+ if (this.options.hackIpInContact) {
+ if (typeof this.options.hackIpInContact === "boolean" && this.options.hackIpInContact) {
+ const from = 1;
+ const to = 254;
+ const octet = Math.floor(Math.random() * (to - from + 1) + from);
+ // random Test-Net IP (http://tools.ietf.org/html/rfc5735)
+ this.options.viaHost = "192.0.2." + octet;
+ }
+ else if (this.options.hackIpInContact) {
+ this.options.viaHost = this.options.hackIpInContact;
+ }
+ }
+ // initialize logger & logger factory
+ this.loggerFactory = new _core__WEBPACK_IMPORTED_MODULE_0__["LoggerFactory"]();
+ this.logger = this.loggerFactory.getLogger("sip.UserAgent");
+ this.loggerFactory.builtinEnabled = this.options.logBuiltinEnabled;
+ this.loggerFactory.connector = this.options.logConnector;
+ switch (this.options.logLevel) {
+ case "error":
+ this.loggerFactory.level = _core__WEBPACK_IMPORTED_MODULE_0__["Levels"].error;
+ break;
+ case "warn":
+ this.loggerFactory.level = _core__WEBPACK_IMPORTED_MODULE_0__["Levels"].warn;
+ break;
+ case "log":
+ this.loggerFactory.level = _core__WEBPACK_IMPORTED_MODULE_0__["Levels"].log;
+ break;
+ case "debug":
+ this.loggerFactory.level = _core__WEBPACK_IMPORTED_MODULE_0__["Levels"].debug;
+ break;
+ default:
+ break;
+ }
+ if (this.options.logConfiguration) {
+ this.logger.log("Configuration:");
+ Object.keys(this.options).forEach((key) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const value = this.options[key];
+ switch (key) {
+ case "uri":
+ case "sessionDescriptionHandlerFactory":
+ this.logger.log("· " + key + ": " + value);
+ break;
+ case "authorizationPassword":
+ this.logger.log("· " + key + ": " + "NOT SHOWN");
+ break;
+ case "transportConstructor":
+ this.logger.log("· " + key + ": " + value.name);
+ break;
+ default:
+ this.logger.log("· " + key + ": " + JSON.stringify(value));
+ }
+ });
+ }
+ // guard deprecated transport options (remove this in version 16.x)
+ if (this.options.transportOptions) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const optionsDeprecated = this.options.transportOptions;
+ const maxReconnectionAttemptsDeprecated = optionsDeprecated.maxReconnectionAttempts;
+ const reconnectionTimeoutDeprecated = optionsDeprecated.reconnectionTimeout;
+ if (maxReconnectionAttemptsDeprecated !== undefined) {
+ const deprecatedMessage = `The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. ` +
+ "It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
+ this.logger.warn(deprecatedMessage);
+ }
+ if (reconnectionTimeoutDeprecated !== undefined) {
+ const deprecatedMessage = `The transport option "reconnectionTimeout" as has apparently been specified and has been deprecated. ` +
+ "It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
+ this.logger.warn(deprecatedMessage);
+ }
+ // hack
+ if (options.reconnectionDelay === undefined && reconnectionTimeoutDeprecated !== undefined) {
+ this.options.reconnectionDelay = reconnectionTimeoutDeprecated;
+ }
+ if (options.reconnectionAttempts === undefined && maxReconnectionAttemptsDeprecated !== undefined) {
+ this.options.reconnectionAttempts = maxReconnectionAttemptsDeprecated;
+ }
+ }
+ // guard deprecated user agent options (remove this in version 16.x)
+ if (options.reconnectionDelay !== undefined) {
+ const deprecatedMessage = `The user agent option "reconnectionDelay" as has apparently been specified and has been deprecated. ` +
+ "It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
+ this.logger.warn(deprecatedMessage);
+ }
+ if (options.reconnectionAttempts !== undefined) {
+ const deprecatedMessage = `The user agent option "reconnectionAttempts" as has apparently been specified and has been deprecated. ` +
+ "It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
+ this.logger.warn(deprecatedMessage);
+ }
+ // Initialize Transport
+ this._transport = new this.options.transportConstructor(this.getLogger("sip.Transport"), this.options.transportOptions);
+ this.initTransportCallbacks();
+ // Initialize Contact
+ this._contact = this.initContact();
+ // Initialize UserAgentCore
+ this._userAgentCore = this.initCore();
+ if (this.options.autoStart) {
+ this.start();
+ }
+ }
+ /**
+ * Create a URI instance from a string.
+ * @param uri - The string to parse.
+ *
+ * @example
+ * ```ts
+ * const uri = UserAgent.makeURI("sip:edgar@example.com");
+ * ```
+ */
+ static makeURI(uri) {
+ return _core__WEBPACK_IMPORTED_MODULE_0__["Grammar"].URIParse(uri);
+ }
+ /** Default user agent options. */
+ static defaultOptions() {
+ return {
+ allowLegacyNotifications: false,
+ authorizationHa1: "",
+ authorizationPassword: "",
+ authorizationUsername: "",
+ autoStart: false,
+ autoStop: true,
+ delegate: {},
+ displayName: "",
+ forceRport: false,
+ hackAllowUnregisteredOptionTags: false,
+ hackIpInContact: false,
+ hackViaTcp: false,
+ hackViaWs: false,
+ hackWssInTransport: false,
+ logBuiltinEnabled: true,
+ logConfiguration: true,
+ logConnector: () => {
+ /* noop */
+ },
+ logLevel: "log",
+ noAnswerTimeout: 60,
+ preloadedRouteSet: [],
+ reconnectionAttempts: 0,
+ reconnectionDelay: 4,
+ sessionDescriptionHandlerFactory: Object(_platform_web_session_description_handler__WEBPACK_IMPORTED_MODULE_2__["defaultSessionDescriptionHandlerFactory"])(),
+ sessionDescriptionHandlerFactoryOptions: {},
+ sipExtension100rel: _user_agent_options__WEBPACK_IMPORTED_MODULE_10__["SIPExtension"].Unsupported,
+ sipExtensionReplaces: _user_agent_options__WEBPACK_IMPORTED_MODULE_10__["SIPExtension"].Unsupported,
+ sipExtensionExtraSupported: [],
+ sipjsId: "",
+ transportConstructor: _platform_web_transport__WEBPACK_IMPORTED_MODULE_3__["Transport"],
+ transportOptions: {},
+ uri: new _core__WEBPACK_IMPORTED_MODULE_0__["URI"]("sip", "anonymous", "anonymous.invalid"),
+ userAgentString: "SIP.js/" + _version__WEBPACK_IMPORTED_MODULE_4__["LIBRARY_VERSION"],
+ viaHost: ""
+ };
+ }
+ /**
+ * Strip properties with undefined values from options.
+ * This is a work around while waiting for missing vs undefined to be addressed (or not)...
+ * https://github.com/Microsoft/TypeScript/issues/13195
+ * @param options - Options to reduce
+ */
+ static stripUndefinedProperties(options) {
+ return Object.keys(options).reduce((object, key) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (options[key] !== undefined) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ object[key] = options[key];
+ }
+ return object;
+ }, {});
+ }
+ /**
+ * User agent configuration.
+ */
+ get configuration() {
+ return this.options;
+ }
+ /**
+ * User agent contact.
+ */
+ get contact() {
+ return this._contact;
+ }
+ /**
+ * User agent state.
+ */
+ get state() {
+ return this._state;
+ }
+ /**
+ * User agent state change emitter.
+ */
+ get stateChange() {
+ return this._stateEventEmitter;
+ }
+ /**
+ * User agent transport.
+ */
+ get transport() {
+ return this._transport;
+ }
+ /**
+ * User agent core.
+ */
+ get userAgentCore() {
+ return this._userAgentCore;
+ }
+ /**
+ * The logger.
+ */
+ getLogger(category, label) {
+ return this.loggerFactory.getLogger(category, label);
+ }
+ /**
+ * The logger factory.
+ */
+ getLoggerFactory() {
+ return this.loggerFactory;
+ }
+ /**
+ * True if transport is connected.
+ */
+ isConnected() {
+ return this.transport.isConnected();
+ }
+ /**
+ * Reconnect the transport.
+ */
+ reconnect() {
+ if (this.state === _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped) {
+ return Promise.reject(new Error("User agent stopped."));
+ }
+ // Make sure we don't call synchronously
+ return Promise.resolve().then(() => this.transport.connect());
+ }
+ /**
+ * Start the user agent.
+ *
+ * @remarks
+ * Resolves if transport connects, otherwise rejects.
+ *
+ * @example
+ * ```ts
+ * userAgent.start()
+ * .then(() => {
+ * // userAgent.isConnected() === true
+ * })
+ * .catch((error: Error) => {
+ * // userAgent.isConnected() === false
+ * });
+ * ```
+ */
+ start() {
+ if (this.state === _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Started) {
+ this.logger.warn(`User agent already started`);
+ return Promise.resolve();
+ }
+ this.logger.log(`Starting ${this.configuration.uri}`);
+ // Transition state
+ this.transitionState(_user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Started);
+ // TODO: Review this as it is not clear it has any benefit and at worst causes additional load the server.
+ // On unload it may be best to simply in most scenarios to do nothing. Furthermore and regardless, this
+ // kind of behavior seems more appropriate to be managed by the consumer of the API than the API itself.
+ // Should this perhaps be deprecated?
+ //
+ // Add window unload event listener
+ if (this.options.autoStop) {
+ // Google Chrome Packaged Apps don't allow 'unload' listeners: unload is not available in packaged apps
+ const googleChromePackagedApp = typeof chrome !== "undefined" && chrome.app && chrome.app.runtime ? true : false;
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function" && !googleChromePackagedApp) {
+ window.addEventListener("unload", this.unloadListener);
+ }
+ }
+ return this.transport.connect();
+ }
+ /**
+ * Stop the user agent.
+ *
+ * @remarks
+ * Resolves when the user agent has completed a graceful shutdown.
+ * ```txt
+ * 1) Sessions terminate.
+ * 2) Registerers unregister.
+ * 3) Subscribers unsubscribe.
+ * 4) Publishers unpublish.
+ * 5) Transport disconnects.
+ * 6) User Agent Core resets.
+ * ```
+ * NOTE: While this is a "graceful shutdown", it can also be very slow one if you
+ * are waiting for the returned Promise to resolve. The disposal of the clients and
+ * dialogs is done serially - waiting on one to finish before moving on to the next.
+ * This can be slow if there are lot of subscriptions to unsubscribe for example.
+ *
+ * THE SLOW PACE IS INTENTIONAL!
+ * While one could spin them all down in parallel, this could slam the remote server.
+ * It is bad practice to denial of service attack (DoS attack) servers!!!
+ * Moreover, production servers will automatically blacklist clients which send too
+ * many requests in too short a period of time - dropping any additional requests.
+ *
+ * If a different approach to disposing is needed, one can implement whatever is
+ * needed and execute that prior to calling `stop()`. Alternatively one may simply
+ * not wait for the Promise returned by `stop()` to complete.
+ */
+ async stop() {
+ if (this.state === _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped) {
+ this.logger.warn(`User agent already stopped`);
+ return Promise.resolve();
+ }
+ this.logger.log(`Stopping ${this.configuration.uri}`);
+ // Transition state
+ this.transitionState(_user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped);
+ // TODO: See comments with associated complimentary code in start(). Should this perhaps be deprecated?
+ // Remove window unload event listener
+ if (this.options.autoStop) {
+ // Google Chrome Packaged Apps don't allow 'unload' listeners: unload is not available in packaged apps
+ const googleChromePackagedApp = typeof chrome !== "undefined" && chrome.app && chrome.app.runtime ? true : false;
+ if (typeof window !== "undefined" && window.removeEventListener && !googleChromePackagedApp) {
+ window.removeEventListener("unload", this.unloadListener);
+ }
+ }
+ // Be careful here to use a local references as start() can be called
+ // again before we complete and we don't want to touch new clients
+ // and we don't want to step on the new instances (or vice versa).
+ const publishers = Object.assign({}, this._publishers);
+ const registerers = Object.assign({}, this._registerers);
+ const sessions = Object.assign({}, this._sessions);
+ const subscriptions = Object.assign({}, this._subscriptions);
+ const transport = this.transport;
+ const userAgentCore = this.userAgentCore;
+ //
+ // At this point we have completed the state transition and everything
+ // following will effectively run async and MUST NOT cause any issues
+ // if UserAgent.start() is called while the following code continues.
+ //
+ // TODO: Minor optimization.
+ // The disposal in all cases involves, in part, sending messages which
+ // is not worth doing if the transport is not connected as we know attempting
+ // to send messages will be futile. But none of these disposal methods check
+ // if that's is the case and it would be easy for them to do so at this point.
+ // Dispose of Registerers
+ this.logger.log(`Dispose of registerers`);
+ for (const id in registerers) {
+ if (registerers[id]) {
+ await registerers[id].dispose().catch((error) => {
+ this.logger.error(error.message);
+ delete this._registerers[id];
+ throw error;
+ });
+ }
+ }
+ // Dispose of Sessions
+ this.logger.log(`Dispose of sessions`);
+ for (const id in sessions) {
+ if (sessions[id]) {
+ await sessions[id].dispose().catch((error) => {
+ this.logger.error(error.message);
+ delete this._sessions[id];
+ throw error;
+ });
+ }
+ }
+ // Dispose of Subscriptions
+ this.logger.log(`Dispose of subscriptions`);
+ for (const id in subscriptions) {
+ if (subscriptions[id]) {
+ await subscriptions[id].dispose().catch((error) => {
+ this.logger.error(error.message);
+ delete this._subscriptions[id];
+ throw error;
+ });
+ }
+ }
+ // Dispose of Publishers
+ this.logger.log(`Dispose of publishers`);
+ for (const id in publishers) {
+ if (publishers[id]) {
+ await publishers[id].dispose().catch((error) => {
+ this.logger.error(error.message);
+ delete this._publishers[id];
+ throw error;
+ });
+ }
+ }
+ // Dispose of the transport (disconnecting)
+ this.logger.log(`Dispose of transport`);
+ await transport.dispose().catch((error) => {
+ this.logger.error(error.message);
+ throw error;
+ });
+ // Dispose of the user agent core (resetting)
+ this.logger.log(`Dispose of core`);
+ userAgentCore.dispose();
+ }
+ /**
+ * Used to avoid circular references.
+ * @internal
+ */
+ _makeInviter(targetURI, options) {
+ return new _inviter__WEBPACK_IMPORTED_MODULE_7__["Inviter"](this, targetURI, options);
+ }
+ /**
+ * Attempt reconnection up to `maxReconnectionAttempts` times.
+ * @param reconnectionAttempt - Current attempt number.
+ */
+ attemptReconnection(reconnectionAttempt = 1) {
+ const reconnectionAttempts = this.options.reconnectionAttempts;
+ const reconnectionDelay = this.options.reconnectionDelay;
+ if (reconnectionAttempt > reconnectionAttempts) {
+ this.logger.log(`Maximum reconnection attempts reached`);
+ return;
+ }
+ this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - trying`);
+ setTimeout(() => {
+ this.reconnect()
+ .then(() => {
+ this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - succeeded`);
+ })
+ .catch((error) => {
+ this.logger.error(error.message);
+ this.logger.log(`Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - failed`);
+ this.attemptReconnection(++reconnectionAttempt);
+ });
+ }, reconnectionAttempt === 1 ? 0 : reconnectionDelay * 1000);
+ }
+ /**
+ * Initialize contact.
+ */
+ initContact() {
+ const contactName = Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["createRandomToken"])(8); // FIXME: should be configurable
+ const contactTransport = this.options.hackWssInTransport ? "wss" : "ws"; // FIXME: clearly broken for non ws transports
+ const contact = {
+ pubGruu: undefined,
+ tempGruu: undefined,
+ uri: new _core__WEBPACK_IMPORTED_MODULE_0__["URI"]("sip", contactName, this.options.viaHost, undefined, { transport: contactTransport }),
+ toString: (contactToStringOptions = {}) => {
+ const anonymous = contactToStringOptions.anonymous || false;
+ const outbound = contactToStringOptions.outbound || false;
+ let contactString = "<";
+ if (anonymous) {
+ contactString += this.contact.tempGruu || `sip:anonymous@anonymous.invalid;transport=${contactTransport}`;
+ }
+ else {
+ contactString += this.contact.pubGruu || this.contact.uri;
+ }
+ if (outbound) {
+ contactString += ";ob";
+ }
+ contactString += ">";
+ return contactString;
+ }
+ };
+ return contact;
+ }
+ /**
+ * Initialize user agent core.
+ */
+ initCore() {
+ // supported options
+ let supportedOptionTags = [];
+ supportedOptionTags.push("outbound"); // TODO: is this really supported?
+ if (this.options.sipExtension100rel === _user_agent_options__WEBPACK_IMPORTED_MODULE_10__["SIPExtension"].Supported) {
+ supportedOptionTags.push("100rel");
+ }
+ if (this.options.sipExtensionReplaces === _user_agent_options__WEBPACK_IMPORTED_MODULE_10__["SIPExtension"].Supported) {
+ supportedOptionTags.push("replaces");
+ }
+ if (this.options.sipExtensionExtraSupported) {
+ supportedOptionTags.push(...this.options.sipExtensionExtraSupported);
+ }
+ if (!this.options.hackAllowUnregisteredOptionTags) {
+ supportedOptionTags = supportedOptionTags.filter((optionTag) => _user_agent_options__WEBPACK_IMPORTED_MODULE_10__["UserAgentRegisteredOptionTags"][optionTag]);
+ }
+ supportedOptionTags = Array.from(new Set(supportedOptionTags)); // array of unique values
+ // FIXME: TODO: This was ported, but this is and was just plain broken.
+ const supportedOptionTagsResponse = supportedOptionTags.slice();
+ if (this.contact.pubGruu || this.contact.tempGruu) {
+ supportedOptionTagsResponse.push("gruu");
+ }
+ // core configuration
+ const userAgentCoreConfiguration = {
+ aor: this.options.uri,
+ contact: this.contact,
+ displayName: this.options.displayName,
+ loggerFactory: this.loggerFactory,
+ hackViaTcp: this.options.hackViaTcp,
+ hackViaWs: this.options.hackViaWs,
+ routeSet: this.options.preloadedRouteSet,
+ supportedOptionTags,
+ supportedOptionTagsResponse,
+ sipjsId: this.options.sipjsId,
+ userAgentHeaderFieldValue: this.options.userAgentString,
+ viaForceRport: this.options.forceRport,
+ viaHost: this.options.viaHost,
+ authenticationFactory: () => {
+ const username = this.options.authorizationUsername
+ ? this.options.authorizationUsername
+ : this.options.uri.user; // if authorization username not provided, use uri user as username
+ const password = this.options.authorizationPassword ? this.options.authorizationPassword : undefined;
+ const ha1 = this.options.authorizationHa1 ? this.options.authorizationHa1 : undefined;
+ return new _core__WEBPACK_IMPORTED_MODULE_0__["DigestAuthentication"](this.getLoggerFactory(), ha1, username, password);
+ },
+ transportAccessor: () => this.transport
+ };
+ const userAgentCoreDelegate = {
+ onInvite: (incomingInviteRequest) => {
+ var _a;
+ const invitation = new _invitation__WEBPACK_IMPORTED_MODULE_6__["Invitation"](this, incomingInviteRequest);
+ incomingInviteRequest.delegate = {
+ onCancel: (cancel) => {
+ invitation._onCancel(cancel);
+ },
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ onTransportError: (error) => {
+ // A server transaction MUST NOT discard transaction state based only on
+ // encountering a non-recoverable transport error when sending a
+ // response. Instead, the associated INVITE server transaction state
+ // machine MUST remain in its current state. (Timers will eventually
+ // cause it to transition to the "Terminated" state).
+ // https://tools.ietf.org/html/rfc6026#section-7.1
+ // As noted in the comment above, we are to leaving it to the transaction
+ // timers to eventually cause the transaction to sort itself out in the case
+ // of a transport failure in an invite server transaction. This delegate method
+ // is here simply here for completeness and to make it clear that it provides
+ // nothing more than informational hook into the core. That is, if you think
+ // you should be trying to deal with a transport error here, you are likely wrong.
+ this.logger.error("A transport error has occurred while handling an incoming INVITE request.");
+ }
+ };
+ // FIXME: Ported - 100 Trying send should be configurable.
+ // Only required if TU will not respond in 200ms.
+ // https://tools.ietf.org/html/rfc3261#section-17.2.1
+ incomingInviteRequest.trying();
+ // The Replaces header contains information used to match an existing
+ // SIP dialog (call-id, to-tag, and from-tag). Upon receiving an INVITE
+ // with a Replaces header, the User Agent (UA) attempts to match this
+ // information with a confirmed or early dialog.
+ // https://tools.ietf.org/html/rfc3891#section-3
+ if (this.options.sipExtensionReplaces !== _user_agent_options__WEBPACK_IMPORTED_MODULE_10__["SIPExtension"].Unsupported) {
+ const message = incomingInviteRequest.message;
+ const replaces = message.parseHeader("replaces");
+ if (replaces) {
+ const callId = replaces.call_id;
+ if (typeof callId !== "string") {
+ throw new Error("Type of call id is not string");
+ }
+ const toTag = replaces.replaces_to_tag;
+ if (typeof toTag !== "string") {
+ throw new Error("Type of to tag is not string");
+ }
+ const fromTag = replaces.replaces_from_tag;
+ if (typeof fromTag !== "string") {
+ throw new Error("type of from tag is not string");
+ }
+ const targetDialogId = callId + toTag + fromTag;
+ const targetDialog = this.userAgentCore.dialogs.get(targetDialogId);
+ // If no match is found, the UAS rejects the INVITE and returns a 481
+ // Call/Transaction Does Not Exist response. Likewise, if the Replaces
+ // header field matches a dialog which was not created with an INVITE,
+ // the UAS MUST reject the request with a 481 response.
+ // https://tools.ietf.org/html/rfc3891#section-3
+ if (!targetDialog) {
+ invitation.reject({ statusCode: 481 });
+ return;
+ }
+ // If the Replaces header field matches a confirmed dialog, it checks
+ // for the presence of the "early-only" flag in the Replaces header
+ // field. (This flag allows the UAC to prevent a potentially
+ // undesirable race condition described in Section 7.1.) If the flag is
+ // present, the UA rejects the request with a 486 Busy response.
+ // https://tools.ietf.org/html/rfc3891#section-3
+ if (!targetDialog.early && replaces.early_only === true) {
+ invitation.reject({ statusCode: 486 });
+ return;
+ }
+ // Provide a handle on the session being replaced.
+ const targetSession = this._sessions[callId + fromTag] || this._sessions[callId + toTag] || undefined;
+ if (!targetSession) {
+ throw new Error("Session does not exist.");
+ }
+ invitation._replacee = targetSession;
+ }
+ }
+ // Delegate invitation handling.
+ if ((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onInvite) {
+ if (invitation.autoSendAnInitialProvisionalResponse) {
+ invitation.progress().then(() => {
+ var _a;
+ if (((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onInvite) === undefined) {
+ throw new Error("onInvite undefined.");
+ }
+ this.delegate.onInvite(invitation);
+ });
+ return;
+ }
+ this.delegate.onInvite(invitation);
+ return;
+ }
+ // A common scenario occurs when the callee is currently not willing or
+ // able to take additional calls at this end system. A 486 (Busy Here)
+ // SHOULD be returned in such a scenario.
+ // https://tools.ietf.org/html/rfc3261#section-13.3.1.3
+ invitation.reject({ statusCode: 486 });
+ },
+ onMessage: (incomingMessageRequest) => {
+ if (this.delegate && this.delegate.onMessage) {
+ const message = new _message__WEBPACK_IMPORTED_MODULE_8__["Message"](incomingMessageRequest);
+ this.delegate.onMessage(message);
+ }
+ else {
+ // Accept the MESSAGE request, but do nothing with it.
+ incomingMessageRequest.accept();
+ }
+ },
+ onNotify: (incomingNotifyRequest) => {
+ // NOTIFY requests are sent to inform subscribers of changes in state to
+ // which the subscriber has a subscription. Subscriptions are created
+ // using the SUBSCRIBE method. In legacy implementations, it is
+ // possible that other means of subscription creation have been used.
+ // However, this specification does not allow the creation of
+ // subscriptions except through SUBSCRIBE requests and (for backwards-
+ // compatibility) REFER requests [RFC3515].
+ // https://tools.ietf.org/html/rfc6665#section-3.2
+ if (this.delegate && this.delegate.onNotify) {
+ const notification = new _notification__WEBPACK_IMPORTED_MODULE_9__["Notification"](incomingNotifyRequest);
+ this.delegate.onNotify(notification);
+ }
+ else {
+ // Per the above which obsoletes https://tools.ietf.org/html/rfc3265,
+ // the use of out of dialog NOTIFY is obsolete, but...
+ if (this.options.allowLegacyNotifications) {
+ incomingNotifyRequest.accept(); // Accept the NOTIFY request, but do nothing with it.
+ }
+ else {
+ incomingNotifyRequest.reject({ statusCode: 481 });
+ }
+ }
+ },
+ onRefer: (incomingReferRequest) => {
+ this.logger.warn("Received an out of dialog REFER request");
+ // TOOD: this.delegate.onRefer(...)
+ if (this.delegate && this.delegate.onReferRequest) {
+ this.delegate.onReferRequest(incomingReferRequest);
+ }
+ else {
+ incomingReferRequest.reject({ statusCode: 405 });
+ }
+ },
+ onRegister: (incomingRegisterRequest) => {
+ this.logger.warn("Received an out of dialog REGISTER request");
+ // TOOD: this.delegate.onRegister(...)
+ if (this.delegate && this.delegate.onRegisterRequest) {
+ this.delegate.onRegisterRequest(incomingRegisterRequest);
+ }
+ else {
+ incomingRegisterRequest.reject({ statusCode: 405 });
+ }
+ },
+ onSubscribe: (incomingSubscribeRequest) => {
+ this.logger.warn("Received an out of dialog SUBSCRIBE request");
+ // TOOD: this.delegate.onSubscribe(...)
+ if (this.delegate && this.delegate.onSubscribeRequest) {
+ this.delegate.onSubscribeRequest(incomingSubscribeRequest);
+ }
+ else {
+ incomingSubscribeRequest.reject({ statusCode: 405 });
+ }
+ }
+ };
+ return new _core__WEBPACK_IMPORTED_MODULE_0__["UserAgentCore"](userAgentCoreConfiguration, userAgentCoreDelegate);
+ }
+ initTransportCallbacks() {
+ this.transport.onConnect = () => this.onTransportConnect();
+ this.transport.onDisconnect = (error) => this.onTransportDisconnect(error);
+ this.transport.onMessage = (message) => this.onTransportMessage(message);
+ }
+ onTransportConnect() {
+ if (this.state === _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped) {
+ return;
+ }
+ if (this.delegate && this.delegate.onConnect) {
+ this.delegate.onConnect();
+ }
+ }
+ onTransportDisconnect(error) {
+ if (this.state === _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped) {
+ return;
+ }
+ if (this.delegate && this.delegate.onDisconnect) {
+ this.delegate.onDisconnect(error);
+ }
+ // Only attempt to reconnect if network/server dropped the connection.
+ if (error && this.options.reconnectionAttempts > 0) {
+ this.attemptReconnection();
+ }
+ }
+ onTransportMessage(messageString) {
+ const message = _core__WEBPACK_IMPORTED_MODULE_0__["Parser"].parseMessage(messageString, this.getLogger("sip.Parser"));
+ if (!message) {
+ this.logger.warn("Failed to parse incoming message. Dropping.");
+ return;
+ }
+ if (this.state === _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped && message instanceof _core__WEBPACK_IMPORTED_MODULE_0__["IncomingRequestMessage"]) {
+ this.logger.warn(`Received ${message.method} request while stopped. Dropping.`);
+ return;
+ }
+ // A valid SIP request formulated by a UAC MUST, at a minimum, contain
+ // the following header fields: To, From, CSeq, Call-ID, Max-Forwards,
+ // and Via; all of these header fields are mandatory in all SIP
+ // requests.
+ // https://tools.ietf.org/html/rfc3261#section-8.1.1
+ const hasMinimumHeaders = () => {
+ const mandatoryHeaders = ["from", "to", "call_id", "cseq", "via"];
+ for (const header of mandatoryHeaders) {
+ if (!message.hasHeader(header)) {
+ this.logger.warn(`Missing mandatory header field : ${header}.`);
+ return false;
+ }
+ }
+ return true;
+ };
+ // Request Checks
+ if (message instanceof _core__WEBPACK_IMPORTED_MODULE_0__["IncomingRequestMessage"]) {
+ // This is port of SanityCheck.minimumHeaders().
+ if (!hasMinimumHeaders()) {
+ this.logger.warn(`Request missing mandatory header field. Dropping.`);
+ return;
+ }
+ // FIXME: This is non-standard and should be a configurable behavior (desirable regardless).
+ // Custom SIP.js check to reject request from ourself (this instance of SIP.js).
+ // This is port of SanityCheck.rfc3261_16_3_4().
+ if (!message.toTag && message.callId.substr(0, 5) === this.options.sipjsId) {
+ this.userAgentCore.replyStateless(message, { statusCode: 482 });
+ return;
+ }
+ // FIXME: This should be Transport check before we get here (Section 18).
+ // Custom SIP.js check to reject requests if body length wrong.
+ // This is port of SanityCheck.rfc3261_18_3_request().
+ const len = Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["utf8Length"])(message.body);
+ const contentLength = message.getHeader("content-length");
+ if (contentLength && len < Number(contentLength)) {
+ this.userAgentCore.replyStateless(message, { statusCode: 400 });
+ return;
+ }
+ }
+ // Response Checks
+ if (message instanceof _core__WEBPACK_IMPORTED_MODULE_0__["IncomingResponseMessage"]) {
+ // This is port of SanityCheck.minimumHeaders().
+ if (!hasMinimumHeaders()) {
+ this.logger.warn(`Response missing mandatory header field. Dropping.`);
+ return;
+ }
+ // Custom SIP.js check to drop responses if multiple Via headers.
+ // This is port of SanityCheck.rfc3261_8_1_3_3().
+ if (message.getHeaders("via").length > 1) {
+ this.logger.warn("More than one Via header field present in the response. Dropping.");
+ return;
+ }
+ // FIXME: This should be Transport check before we get here (Section 18).
+ // Custom SIP.js check to drop responses if bad Via header.
+ // This is port of SanityCheck.rfc3261_18_1_2().
+ if (message.via.host !== this.options.viaHost || message.via.port !== undefined) {
+ this.logger.warn("Via sent-by in the response does not match UA Via host value. Dropping.");
+ return;
+ }
+ // FIXME: This should be Transport check before we get here (Section 18).
+ // Custom SIP.js check to reject requests if body length wrong.
+ // This is port of SanityCheck.rfc3261_18_3_response().
+ const len = Object(_core_messages_utils__WEBPACK_IMPORTED_MODULE_1__["utf8Length"])(message.body);
+ const contentLength = message.getHeader("content-length");
+ if (contentLength && len < Number(contentLength)) {
+ this.logger.warn("Message body length is lower than the value in Content-Length header field. Dropping.");
+ return;
+ }
+ }
+ // Handle Request
+ if (message instanceof _core__WEBPACK_IMPORTED_MODULE_0__["IncomingRequestMessage"]) {
+ this.userAgentCore.receiveIncomingRequestFromTransport(message);
+ return;
+ }
+ // Handle Response
+ if (message instanceof _core__WEBPACK_IMPORTED_MODULE_0__["IncomingResponseMessage"]) {
+ this.userAgentCore.receiveIncomingResponseFromTransport(message);
+ return;
+ }
+ throw new Error("Invalid message type.");
+ }
+ /**
+ * Transition state.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ transitionState(newState, error) {
+ const invalidTransition = () => {
+ throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
+ };
+ // Validate state transition
+ switch (this._state) {
+ case _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Started:
+ if (newState !== _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped) {
+ invalidTransition();
+ }
+ break;
+ case _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Stopped:
+ if (newState !== _user_agent_state__WEBPACK_IMPORTED_MODULE_11__["UserAgentState"].Started) {
+ invalidTransition();
+ }
+ break;
+ default:
+ throw new Error("Unknown state.");
+ }
+ // Update state
+ this.logger.log(`Transitioned from ${this._state} to ${newState}`);
+ this._state = newState;
+ this._stateEventEmitter.emit(this._state);
+ }
+}
+
+
+/***/ }),
+/* 160 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _media_stream_factory_default__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(161);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "defaultMediaStreamFactory", function() { return _media_stream_factory_default__WEBPACK_IMPORTED_MODULE_0__["defaultMediaStreamFactory"]; });
+
+/* harmony import */ var _media_stream_factory__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(162);
+/* harmony import */ var _media_stream_factory__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_media_stream_factory__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _media_stream_factory__WEBPACK_IMPORTED_MODULE_1__) if(["defaultMediaStreamFactory","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _media_stream_factory__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _peer_connection_configuration_default__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(163);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "defaultPeerConnectionConfiguration", function() { return _peer_connection_configuration_default__WEBPACK_IMPORTED_MODULE_2__["defaultPeerConnectionConfiguration"]; });
+
+/* harmony import */ var _peer_connection_delegate__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(164);
+/* harmony import */ var _peer_connection_delegate__WEBPACK_IMPORTED_MODULE_3___default = /*#__PURE__*/__webpack_require__.n(_peer_connection_delegate__WEBPACK_IMPORTED_MODULE_3__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _peer_connection_delegate__WEBPACK_IMPORTED_MODULE_3__) if(["defaultMediaStreamFactory","defaultPeerConnectionConfiguration","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _peer_connection_delegate__WEBPACK_IMPORTED_MODULE_3__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler_configuration__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(165);
+/* harmony import */ var _session_description_handler_configuration__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_session_description_handler_configuration__WEBPACK_IMPORTED_MODULE_4__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler_configuration__WEBPACK_IMPORTED_MODULE_4__) if(["defaultMediaStreamFactory","defaultPeerConnectionConfiguration","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler_configuration__WEBPACK_IMPORTED_MODULE_4__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler_factory_default__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(166);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "defaultSessionDescriptionHandlerFactory", function() { return _session_description_handler_factory_default__WEBPACK_IMPORTED_MODULE_5__["defaultSessionDescriptionHandlerFactory"]; });
+
+/* harmony import */ var _session_description_handler_factory_options__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(168);
+/* harmony import */ var _session_description_handler_factory_options__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(_session_description_handler_factory_options__WEBPACK_IMPORTED_MODULE_6__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler_factory_options__WEBPACK_IMPORTED_MODULE_6__) if(["defaultMediaStreamFactory","defaultPeerConnectionConfiguration","defaultSessionDescriptionHandlerFactory","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler_factory_options__WEBPACK_IMPORTED_MODULE_6__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(169);
+/* harmony import */ var _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_7___default = /*#__PURE__*/__webpack_require__.n(_session_description_handler_factory__WEBPACK_IMPORTED_MODULE_7__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_7__) if(["defaultMediaStreamFactory","defaultPeerConnectionConfiguration","defaultSessionDescriptionHandlerFactory","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler_factory__WEBPACK_IMPORTED_MODULE_7__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler_options__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(170);
+/* harmony import */ var _session_description_handler_options__WEBPACK_IMPORTED_MODULE_8___default = /*#__PURE__*/__webpack_require__.n(_session_description_handler_options__WEBPACK_IMPORTED_MODULE_8__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler_options__WEBPACK_IMPORTED_MODULE_8__) if(["defaultMediaStreamFactory","defaultPeerConnectionConfiguration","defaultSessionDescriptionHandlerFactory","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler_options__WEBPACK_IMPORTED_MODULE_8__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(167);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SessionDescriptionHandler", function() { return _session_description_handler__WEBPACK_IMPORTED_MODULE_9__["SessionDescriptionHandler"]; });
+
+/**
+ * A SessionDescriptionHandler for web browsers.
+ * @packageDocumentation
+ */
+
+
+
+
+
+
+
+
+
+
+
+
+/***/ }),
+/* 161 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "defaultMediaStreamFactory", function() { return defaultMediaStreamFactory; });
+/**
+ * Function which returns a MediaStreamFactory.
+ * @public
+ */
+function defaultMediaStreamFactory() {
+ return (constraints) => {
+ // if no audio or video, return a media stream without tracks
+ if (!constraints.audio && !constraints.video) {
+ return Promise.resolve(new MediaStream());
+ }
+ // getUserMedia() is a powerful feature which can only be used in secure contexts; in insecure contexts,
+ // navigator.mediaDevices is undefined, preventing access to getUserMedia(). A secure context is, in short,
+ // a page loaded using HTTPS or the file:/// URL scheme, or a page loaded from localhost.
+ // https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#Privacy_and_security
+ if (navigator.mediaDevices === undefined) {
+ return Promise.reject(new Error("Media devices not available in insecure contexts."));
+ }
+ return navigator.mediaDevices.getUserMedia.call(navigator.mediaDevices, constraints);
+ };
+}
+
+
+/***/ }),
+/* 162 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 163 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "defaultPeerConnectionConfiguration", function() { return defaultPeerConnectionConfiguration; });
+/**
+ * Function which returns an RTCConfiguration.
+ * @public
+ */
+function defaultPeerConnectionConfiguration() {
+ const configuration = {
+ bundlePolicy: "balanced",
+ certificates: undefined,
+ iceCandidatePoolSize: 0,
+ iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
+ iceTransportPolicy: "all",
+ peerIdentity: undefined,
+ rtcpMuxPolicy: "require"
+ };
+ return configuration;
+}
+
+
+/***/ }),
+/* 164 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 165 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 166 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "defaultSessionDescriptionHandlerFactory", function() { return defaultSessionDescriptionHandlerFactory; });
+/* harmony import */ var _media_stream_factory_default__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(161);
+/* harmony import */ var _peer_connection_configuration_default__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(163);
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(167);
+
+
+
+/**
+ * Function which returns a SessionDescriptionHandlerFactory.
+ * @remarks
+ * See {@link defaultPeerConnectionConfiguration} for the default peer connection configuration.
+ * The ICE gathering timeout defaults to 5000ms.
+ * @param mediaStreamFactory - MediaStream factory.
+ * @public
+ */
+function defaultSessionDescriptionHandlerFactory(mediaStreamFactory) {
+ return (session, options) => {
+ // provide a default media stream factory if need be
+ if (mediaStreamFactory === undefined) {
+ mediaStreamFactory = Object(_media_stream_factory_default__WEBPACK_IMPORTED_MODULE_0__["defaultMediaStreamFactory"])();
+ }
+ // make sure we allow `0` to be passed in so timeout can be disabled
+ const iceGatheringTimeout = (options === null || options === void 0 ? void 0 : options.iceGatheringTimeout) !== undefined ? options === null || options === void 0 ? void 0 : options.iceGatheringTimeout : 5000;
+ // merge passed factory options into default session description configuration
+ const sessionDescriptionHandlerConfiguration = {
+ iceGatheringTimeout,
+ peerConnectionConfiguration: Object.assign(Object.assign({}, Object(_peer_connection_configuration_default__WEBPACK_IMPORTED_MODULE_1__["defaultPeerConnectionConfiguration"])()), options === null || options === void 0 ? void 0 : options.peerConnectionConfiguration)
+ };
+ const logger = session.userAgent.getLogger("sip.SessionDescriptionHandler");
+ return new _session_description_handler__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandler"](logger, mediaStreamFactory, sessionDescriptionHandlerConfiguration);
+ };
+}
+
+
+/***/ }),
+/* 167 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SessionDescriptionHandler", function() { return SessionDescriptionHandler; });
+/**
+ * A base class implementing a WebRTC session description handler for sip.js.
+ * @remarks
+ * It is expected/intended to be extended by specific WebRTC based applications.
+ * @privateRemarks
+ * So do not put application specific implementation in here.
+ * @public
+ */
+class SessionDescriptionHandler {
+ /**
+ * Constructor
+ * @param logger - A logger
+ * @param mediaStreamFactory - A factory to provide a MediaStream
+ * @param options - Options passed from the SessionDescriptionHandleFactory
+ */
+ constructor(logger, mediaStreamFactory, sessionDescriptionHandlerConfiguration) {
+ logger.debug("SessionDescriptionHandler.constructor");
+ this.logger = logger;
+ this.mediaStreamFactory = mediaStreamFactory;
+ this.sessionDescriptionHandlerConfiguration = sessionDescriptionHandlerConfiguration;
+ this._localMediaStream = new MediaStream();
+ this._remoteMediaStream = new MediaStream();
+ this._peerConnection = new RTCPeerConnection(sessionDescriptionHandlerConfiguration === null || sessionDescriptionHandlerConfiguration === void 0 ? void 0 : sessionDescriptionHandlerConfiguration.peerConnectionConfiguration);
+ this.initPeerConnectionEventHandlers();
+ }
+ /**
+ * The local media stream currently being sent.
+ *
+ * @remarks
+ * The local media stream initially has no tracks, so the presence of tracks
+ * should not be assumed. Furthermore, tracks may be added or removed if the
+ * local media changes - for example, on upgrade from audio only to a video session.
+ * At any given time there will be at most one audio track and one video track
+ * (it's possible that this restriction may not apply to sub-classes).
+ * Use `MediaStream.onaddtrack` or add a listener for the `addtrack` event
+ * to detect when a new track becomes available:
+ * https://developer.mozilla.org/en-US/docs/Web/API/MediaStream/onaddtrack
+ */
+ get localMediaStream() {
+ return this._localMediaStream;
+ }
+ /**
+ * The remote media stream currently being received.
+ *
+ * @remarks
+ * The remote media stream initially has no tracks, so the presence of tracks
+ * should not be assumed. Furthermore, tracks may be added or removed if the
+ * remote media changes - for example, on upgrade from audio only to a video session.
+ * At any given time there will be at most one audio track and one video track
+ * (it's possible that this restriction may not apply to sub-classes).
+ * Use `MediaStream.onaddtrack` or add a listener for the `addtrack` event
+ * to detect when a new track becomes available:
+ * https://developer.mozilla.org/en-US/docs/Web/API/MediaStream/onaddtrack
+ */
+ get remoteMediaStream() {
+ return this._remoteMediaStream;
+ }
+ /**
+ * The data channel. Undefined before it is created.
+ */
+ get dataChannel() {
+ return this._dataChannel;
+ }
+ /**
+ * The peer connection. Undefined if peer connection has closed.
+ *
+ * @remarks
+ * While access to the underlying `RTCPeerConnection` is provided, note that
+ * using methods with modify it may break the operation of this class.
+ * In particular, this class depends on exclusive access to the
+ * event handler properties. If you need access to the peer connection
+ * events, either register for events using `addEventListener()` on
+ * the `RTCPeerConnection` or set the `peerConnectionDelegate` on
+ * this `SessionDescriptionHandler`.
+ */
+ get peerConnection() {
+ return this._peerConnection;
+ }
+ /**
+ * A delegate which provides access to the peer connection event handlers.
+ *
+ * @remarks
+ * Setting the peer connection event handlers directly is not supported
+ * and may break this class. As this class depends on exclusive access
+ * to them, a delegate may be set which provides alternative access to
+ * the event handlers in a fashion which is supported.
+ */
+ get peerConnectionDelegate() {
+ return this._peerConnectionDelegate;
+ }
+ set peerConnectionDelegate(delegate) {
+ this._peerConnectionDelegate = delegate;
+ }
+ // The addtrack event does not get fired when JavaScript code explicitly adds tracks to the stream (by calling addTrack()).
+ // https://developer.mozilla.org/en-US/docs/Web/API/MediaStream/onaddtrack
+ static dispatchAddTrackEvent(stream, track) {
+ stream.dispatchEvent(new MediaStreamTrackEvent("addtrack", { track }));
+ }
+ // The removetrack event does not get fired when JavaScript code explicitly removes tracks from the stream (by calling removeTrack()).
+ // https://developer.mozilla.org/en-US/docs/Web/API/MediaStream/onremovetrack
+ static dispatchRemoveTrackEvent(stream, track) {
+ stream.dispatchEvent(new MediaStreamTrackEvent("removetrack", { track }));
+ }
+ /**
+ * Stop tracks and close peer connection.
+ */
+ close() {
+ this.logger.debug("SessionDescriptionHandler.close");
+ if (this._peerConnection === undefined) {
+ return;
+ }
+ this._peerConnection.getReceivers().forEach((receiver) => {
+ receiver.track && receiver.track.stop();
+ });
+ this._peerConnection.getSenders().forEach((sender) => {
+ sender.track && sender.track.stop();
+ });
+ if (this._dataChannel) {
+ this._dataChannel.close();
+ }
+ this._peerConnection.close();
+ this._peerConnection = undefined;
+ }
+ /**
+ * Creates an offer or answer.
+ * @param options - Options bucket.
+ * @param modifiers - Modifiers.
+ */
+ getDescription(options, modifiers, postICEGatheringModifiers) {
+ var _a, _b;
+ this.logger.debug("SessionDescriptionHandler.getDescription");
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ // Callback on data channel creation
+ this.onDataChannel = options === null || options === void 0 ? void 0 : options.onDataChannel;
+ // ICE will restart upon applying an offer created with the iceRestart option
+ const iceRestart = (_a = options === null || options === void 0 ? void 0 : options.offerOptions) === null || _a === void 0 ? void 0 : _a.iceRestart;
+ // ICE gathering timeout may be set on a per call basis, otherwise the configured default is used
+ const iceTimeout = (options === null || options === void 0 ? void 0 : options.iceGatheringTimeout) === undefined
+ ? (_b = this.sessionDescriptionHandlerConfiguration) === null || _b === void 0 ? void 0 : _b.iceGatheringTimeout : options === null || options === void 0 ? void 0 : options.iceGatheringTimeout;
+ return this.getLocalMediaStream(options)
+ .then(() => this.createDataChannel(options))
+ .then(() => this.createLocalOfferOrAnswer(options))
+ .then((sessionDescription) => this.applyModifiers(sessionDescription, modifiers))
+ .then((sessionDescription) => this.setLocalSessionDescription(sessionDescription))
+ .then(() => this.waitForIceGatheringComplete(iceRestart, iceTimeout))
+ .then(() => this.getLocalSessionDescription())
+ .then((sessionDescription) => this.applyPostICEGatheringModifiers(sessionDescription, postICEGatheringModifiers))
+ .then((sessionDescription) => {
+ return {
+ body: sessionDescription.sdp,
+ contentType: "application/sdp"
+ };
+ })
+ .catch((error) => {
+ this.logger.error("SessionDescriptionHandler.getDescription failed - " + error);
+ throw error;
+ });
+ }
+ /**
+ * Returns true if the SessionDescriptionHandler can handle the Content-Type described by a SIP message.
+ * @param contentType - The content type that is in the SIP Message.
+ */
+ hasDescription(contentType) {
+ this.logger.debug("SessionDescriptionHandler.hasDescription");
+ return contentType === "application/sdp";
+ }
+ /**
+ * Send DTMF via RTP (RFC 4733).
+ * Returns true if DTMF send is successful, false otherwise.
+ * @param tones - A string containing DTMF digits.
+ * @param options - Options object to be used by sendDtmf.
+ */
+ sendDtmf(tones, options) {
+ this.logger.debug("SessionDescriptionHandler.sendDtmf");
+ if (this._peerConnection === undefined) {
+ this.logger.error("SessionDescriptionHandler.sendDtmf failed - peer connection closed");
+ return false;
+ }
+ const senders = this._peerConnection.getSenders();
+ if (senders.length === 0) {
+ this.logger.error("SessionDescriptionHandler.sendDtmf failed - no senders");
+ return false;
+ }
+ const dtmfSender = senders[0].dtmf;
+ if (!dtmfSender) {
+ this.logger.error("SessionDescriptionHandler.sendDtmf failed - no DTMF sender");
+ return false;
+ }
+ const duration = options === null || options === void 0 ? void 0 : options.duration;
+ const interToneGap = options === null || options === void 0 ? void 0 : options.interToneGap;
+ try {
+ dtmfSender.insertDTMF(tones, duration, interToneGap);
+ }
+ catch (e) {
+ this.logger.error(e);
+ return false;
+ }
+ this.logger.log("SessionDescriptionHandler.sendDtmf sent via RTP: " + tones.toString());
+ return true;
+ }
+ /**
+ * Sets an offer or answer.
+ * @param sdp - The session description.
+ * @param options - Options bucket.
+ * @param modifiers - Modifiers.
+ */
+ setDescription(sdp, options, modifiers) {
+ this.logger.debug("SessionDescriptionHandler.setDescription");
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ // Callback on data channel creation
+ this.onDataChannel = options === null || options === void 0 ? void 0 : options.onDataChannel;
+ // SDP type
+ const type = this._peerConnection.signalingState === "have-local-offer" ? "answer" : "offer";
+ return this.getLocalMediaStream(options)
+ .then(() => this.applyModifiers({ sdp, type }, modifiers))
+ .then((sessionDescription) => this.setRemoteSessionDescription(sessionDescription))
+ .catch((error) => {
+ this.logger.error("SessionDescriptionHandler.setDescription failed - " + error);
+ throw error;
+ });
+ }
+ /**
+ * Applies modifiers to SDP prior to setting the local or remote description.
+ * @param sdp - SDP to modify.
+ * @param modifiers - Modifiers to apply.
+ */
+ applyModifiers(sdp, modifiers) {
+ this.logger.debug("SessionDescriptionHandler.applyModifiers");
+ if (!modifiers || modifiers.length === 0) {
+ return Promise.resolve(sdp);
+ }
+ return modifiers
+ .reduce((cur, next) => cur.then(next), Promise.resolve(sdp))
+ .then((modified) => {
+ this.logger.debug("SessionDescriptionHandler.applyModifiers - modified sdp");
+ if (!modified.sdp || !modified.type) {
+ throw new Error("Invalid SDP.");
+ }
+ return { sdp: modified.sdp, type: modified.type };
+ });
+ }
+ /**
+ * Applies modifiers to SDP after ICE Gathering collection is done.
+ * This modifier is applied to local SDP, only.
+ * @param sdp - SDP to modify.
+ * @param modifiers - Modifiers to apply.
+ */
+ applyPostICEGatheringModifiers(sdp, modifiers) {
+ this.logger.debug("SessionDescriptionHandler.applyPostICEGatheringModifiers");
+ return this.applyModifiers(sdp, modifiers);
+ }
+ /**
+ * Create a data channel.
+ * @remarks
+ * Only creates a data channel if SessionDescriptionHandlerOptions.dataChannel is true.
+ * Only creates a data channel if creating a local offer.
+ * Only if one does not already exist.
+ * @param options - Session description handler options.
+ */
+ createDataChannel(options) {
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ // only create a data channel if requested
+ if ((options === null || options === void 0 ? void 0 : options.dataChannel) !== true) {
+ return Promise.resolve();
+ }
+ // do not create a data channel if we already have one
+ if (this._dataChannel) {
+ return Promise.resolve();
+ }
+ switch (this._peerConnection.signalingState) {
+ case "stable":
+ // if we are stable, assume we are creating a local offer so create a data channel
+ this.logger.debug("SessionDescriptionHandler.createDataChannel - creating data channel");
+ try {
+ this._dataChannel = this._peerConnection.createDataChannel((options === null || options === void 0 ? void 0 : options.dataChannelLabel) || "", options === null || options === void 0 ? void 0 : options.dataChannelOptions);
+ if (this.onDataChannel) {
+ this.onDataChannel(this._dataChannel);
+ }
+ return Promise.resolve();
+ }
+ catch (error) {
+ return Promise.reject(error);
+ }
+ case "have-remote-offer":
+ return Promise.resolve();
+ case "have-local-offer":
+ case "have-local-pranswer":
+ case "have-remote-pranswer":
+ case "closed":
+ default:
+ return Promise.reject(new Error("Invalid signaling state " + this._peerConnection.signalingState));
+ }
+ }
+ /**
+ * Depending on current signaling state, create a local offer or answer.
+ * @param options - Session description handler options.
+ */
+ createLocalOfferOrAnswer(options) {
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ switch (this._peerConnection.signalingState) {
+ case "stable":
+ // if we are stable, assume we are creating a local offer
+ this.logger.debug("SessionDescriptionHandler.createLocalOfferOrAnswer - creating SDP offer");
+ return this._peerConnection.createOffer(options === null || options === void 0 ? void 0 : options.offerOptions);
+ case "have-remote-offer":
+ // if we have a remote offer, assume we are creating a local answer
+ this.logger.debug("SessionDescriptionHandler.createLocalOfferOrAnswer - creating SDP answer");
+ return this._peerConnection.createAnswer(options === null || options === void 0 ? void 0 : options.answerOptions);
+ case "have-local-offer":
+ case "have-local-pranswer":
+ case "have-remote-pranswer":
+ case "closed":
+ default:
+ return Promise.reject(new Error("Invalid signaling state " + this._peerConnection.signalingState));
+ }
+ }
+ /**
+ * Get a media stream from the media stream factory and set the local media stream.
+ * @param options - Session description handler options.
+ */
+ getLocalMediaStream(options) {
+ this.logger.debug("SessionDescriptionHandler.getLocalMediaStream");
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ let constraints = Object.assign({}, options === null || options === void 0 ? void 0 : options.constraints);
+ // if we already have a local media stream...
+ if (this.localMediaStreamConstraints) {
+ // ignore constraint "downgrades"
+ constraints.audio = constraints.audio || this.localMediaStreamConstraints.audio;
+ constraints.video = constraints.video || this.localMediaStreamConstraints.video;
+ // if constraints have not changed, do not get a new media stream
+ if (JSON.stringify(this.localMediaStreamConstraints.audio) === JSON.stringify(constraints.audio) &&
+ JSON.stringify(this.localMediaStreamConstraints.video) === JSON.stringify(constraints.video)) {
+ return Promise.resolve();
+ }
+ }
+ else {
+ // if no constraints have been specified, default to audio for initial media stream
+ if (constraints.audio === undefined && constraints.video === undefined) {
+ constraints = { audio: true };
+ }
+ }
+ this.localMediaStreamConstraints = constraints;
+ return this.mediaStreamFactory(constraints, this).then((mediaStream) => this.setLocalMediaStream(mediaStream));
+ }
+ /**
+ * Sets the peer connection's sender tracks and local media stream tracks.
+ *
+ * @remarks
+ * Only the first audio and video tracks of the provided MediaStream are utilized.
+ * Adds tracks if audio and/or video tracks are not already present, otherwise replaces tracks.
+ *
+ * @param stream - Media stream containing tracks to be utilized.
+ */
+ setLocalMediaStream(stream) {
+ this.logger.debug("SessionDescriptionHandler.setLocalMediaStream");
+ if (!this._peerConnection) {
+ throw new Error("Peer connection undefined.");
+ }
+ const pc = this._peerConnection;
+ const localStream = this._localMediaStream;
+ const trackUpdates = [];
+ const updateTrack = (newTrack) => {
+ const kind = newTrack.kind;
+ if (kind !== "audio" && kind !== "video") {
+ throw new Error(`Unknown new track kind ${kind}.`);
+ }
+ const sender = pc.getSenders().find((sender) => sender.track && sender.track.kind === kind);
+ if (sender) {
+ trackUpdates.push(new Promise((resolve) => {
+ this.logger.debug(`SessionDescriptionHandler.setLocalMediaStream - replacing sender ${kind} track`);
+ resolve();
+ }).then(() => sender
+ .replaceTrack(newTrack)
+ .then(() => {
+ const oldTrack = localStream.getTracks().find((localTrack) => localTrack.kind === kind);
+ if (oldTrack) {
+ oldTrack.stop();
+ localStream.removeTrack(oldTrack);
+ SessionDescriptionHandler.dispatchRemoveTrackEvent(localStream, oldTrack);
+ }
+ localStream.addTrack(newTrack);
+ SessionDescriptionHandler.dispatchAddTrackEvent(localStream, newTrack);
+ })
+ .catch((error) => {
+ this.logger.error(`SessionDescriptionHandler.setLocalMediaStream - failed to replace sender ${kind} track`);
+ throw error;
+ })));
+ }
+ else {
+ trackUpdates.push(new Promise((resolve) => {
+ this.logger.debug(`SessionDescriptionHandler.setLocalMediaStream - adding sender ${kind} track`);
+ resolve();
+ }).then(() => {
+ // Review: could make streamless tracks a configurable option?
+ // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/addTrack#Usage_notes
+ try {
+ pc.addTrack(newTrack, localStream);
+ }
+ catch (error) {
+ this.logger.error(`SessionDescriptionHandler.setLocalMediaStream - failed to add sender ${kind} track`);
+ throw error;
+ }
+ localStream.addTrack(newTrack);
+ SessionDescriptionHandler.dispatchAddTrackEvent(localStream, newTrack);
+ }));
+ }
+ };
+ // update peer connection audio tracks
+ const audioTracks = stream.getAudioTracks();
+ if (audioTracks.length) {
+ updateTrack(audioTracks[0]);
+ }
+ // update peer connection video tracks
+ const videoTracks = stream.getVideoTracks();
+ if (videoTracks.length) {
+ updateTrack(videoTracks[0]);
+ }
+ return trackUpdates.reduce((p, x) => p.then(() => x), Promise.resolve());
+ }
+ /**
+ * Gets the peer connection's local session description.
+ */
+ getLocalSessionDescription() {
+ this.logger.debug("SessionDescriptionHandler.getLocalSessionDescription");
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ const sdp = this._peerConnection.localDescription;
+ if (!sdp) {
+ return Promise.reject(new Error("Failed to get local session description"));
+ }
+ return Promise.resolve(sdp);
+ }
+ /**
+ * Sets the peer connection's local session description.
+ * @param sessionDescription - sessionDescription The session description.
+ */
+ setLocalSessionDescription(sessionDescription) {
+ this.logger.debug("SessionDescriptionHandler.setLocalSessionDescription");
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ return this._peerConnection.setLocalDescription(sessionDescription);
+ }
+ /**
+ * Sets the peer connection's remote session description.
+ * @param sessionDescription - The session description.
+ */
+ setRemoteSessionDescription(sessionDescription) {
+ this.logger.debug("SessionDescriptionHandler.setRemoteSessionDescription");
+ if (this._peerConnection === undefined) {
+ return Promise.reject(new Error("Peer connection closed."));
+ }
+ const sdp = sessionDescription.sdp;
+ let type;
+ switch (this._peerConnection.signalingState) {
+ case "stable":
+ // if we are stable assume this is a remote offer
+ type = "offer";
+ break;
+ case "have-local-offer":
+ // if we made an offer, assume this is a remote answer
+ type = "answer";
+ break;
+ case "have-local-pranswer":
+ case "have-remote-offer":
+ case "have-remote-pranswer":
+ case "closed":
+ default:
+ return Promise.reject(new Error("Invalid signaling state " + this._peerConnection.signalingState));
+ }
+ if (!sdp) {
+ this.logger.error("SessionDescriptionHandler.setRemoteSessionDescription failed - cannot set null sdp");
+ return Promise.reject(new Error("SDP is undefined"));
+ }
+ return this._peerConnection.setRemoteDescription({ sdp, type });
+ }
+ /**
+ * Sets a remote media stream track.
+ *
+ * @remarks
+ * Adds tracks if audio and/or video tracks are not already present, otherwise replaces tracks.
+ *
+ * @param track - Media stream track to be utilized.
+ */
+ setRemoteTrack(track) {
+ this.logger.debug("SessionDescriptionHandler.setRemoteTrack");
+ const remoteStream = this._remoteMediaStream;
+ if (remoteStream.getTrackById(track.id)) {
+ this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - have remote ${track.kind} track`);
+ }
+ else if (track.kind === "audio") {
+ this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - adding remote ${track.kind} track`);
+ remoteStream.getAudioTracks().forEach((track) => {
+ track.stop();
+ remoteStream.removeTrack(track);
+ SessionDescriptionHandler.dispatchRemoveTrackEvent(remoteStream, track);
+ });
+ remoteStream.addTrack(track);
+ SessionDescriptionHandler.dispatchAddTrackEvent(remoteStream, track);
+ }
+ else if (track.kind === "video") {
+ this.logger.debug(`SessionDescriptionHandler.setRemoteTrack - adding remote ${track.kind} track`);
+ remoteStream.getVideoTracks().forEach((track) => {
+ track.stop();
+ remoteStream.removeTrack(track);
+ SessionDescriptionHandler.dispatchRemoveTrackEvent(remoteStream, track);
+ });
+ remoteStream.addTrack(track);
+ SessionDescriptionHandler.dispatchAddTrackEvent(remoteStream, track);
+ }
+ }
+ /**
+ * Called when ICE gathering completes and resolves any waiting promise.
+ */
+ iceGatheringComplete() {
+ this.logger.debug("SessionDescriptionHandler.iceGatheringComplete");
+ // clear timer if need be
+ if (this.iceGatheringCompleteTimeoutId !== undefined) {
+ this.logger.debug("SessionDescriptionHandler.iceGatheringComplete - clearing timeout");
+ clearTimeout(this.iceGatheringCompleteTimeoutId);
+ this.iceGatheringCompleteTimeoutId = undefined;
+ }
+ // resolve and cleanup promise if need be
+ if (this.iceGatheringCompletePromise !== undefined) {
+ this.logger.debug("SessionDescriptionHandler.iceGatheringComplete - resolving promise");
+ this.iceGatheringCompleteResolve && this.iceGatheringCompleteResolve();
+ this.iceGatheringCompletePromise = undefined;
+ this.iceGatheringCompleteResolve = undefined;
+ this.iceGatheringCompleteReject = undefined;
+ }
+ }
+ /**
+ * Wait for ICE gathering to complete.
+ * @param restart - If true, waits if current state is "complete" (waits for transition to "complete").
+ * @param timeout - Milliseconds after which waiting times out. No timeout if 0.
+ */
+ waitForIceGatheringComplete(restart = false, timeout = 0) {
+ this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete");
+ if (this._peerConnection === undefined) {
+ return Promise.reject("Peer connection closed.");
+ }
+ // guard already complete
+ if (!restart && this._peerConnection.iceGatheringState === "complete") {
+ this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - already complete");
+ return Promise.resolve();
+ }
+ // only one may be waiting, reject any prior
+ if (this.iceGatheringCompletePromise !== undefined) {
+ this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - rejecting prior waiting promise");
+ this.iceGatheringCompleteReject && this.iceGatheringCompleteReject(new Error("Promise superseded."));
+ this.iceGatheringCompletePromise = undefined;
+ this.iceGatheringCompleteResolve = undefined;
+ this.iceGatheringCompleteReject = undefined;
+ }
+ this.iceGatheringCompletePromise = new Promise((resolve, reject) => {
+ this.iceGatheringCompleteResolve = resolve;
+ this.iceGatheringCompleteReject = reject;
+ if (timeout > 0) {
+ this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - timeout in " + timeout);
+ this.iceGatheringCompleteTimeoutId = setTimeout(() => {
+ this.logger.debug("SessionDescriptionHandler.waitForIceGatheringToComplete - timeout");
+ this.iceGatheringComplete();
+ }, timeout);
+ }
+ });
+ return this.iceGatheringCompletePromise;
+ }
+ /**
+ * Initializes the peer connection event handlers
+ */
+ initPeerConnectionEventHandlers() {
+ this.logger.debug("SessionDescriptionHandler.initPeerConnectionEventHandlers");
+ if (!this._peerConnection)
+ throw new Error("Peer connection undefined.");
+ const peerConnection = this._peerConnection;
+ peerConnection.onconnectionstatechange = (event) => {
+ var _a;
+ const newState = peerConnection.connectionState;
+ this.logger.debug(`SessionDescriptionHandler.onconnectionstatechange ${newState}`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onconnectionstatechange) {
+ this._peerConnectionDelegate.onconnectionstatechange(event);
+ }
+ };
+ peerConnection.ondatachannel = (event) => {
+ var _a;
+ this.logger.debug(`SessionDescriptionHandler.ondatachannel`);
+ this._dataChannel = event.channel;
+ if (this.onDataChannel) {
+ this.onDataChannel(this._dataChannel);
+ }
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.ondatachannel) {
+ this._peerConnectionDelegate.ondatachannel(event);
+ }
+ };
+ peerConnection.onicecandidate = (event) => {
+ var _a;
+ this.logger.debug(`SessionDescriptionHandler.onicecandidate`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onicecandidate) {
+ this._peerConnectionDelegate.onicecandidate(event);
+ }
+ };
+ peerConnection.onicecandidateerror = (event) => {
+ var _a;
+ this.logger.debug(`SessionDescriptionHandler.onicecandidateerror`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onicecandidateerror) {
+ this._peerConnectionDelegate.onicecandidateerror(event);
+ }
+ };
+ peerConnection.oniceconnectionstatechange = (event) => {
+ var _a;
+ const newState = peerConnection.iceConnectionState;
+ this.logger.debug(`SessionDescriptionHandler.oniceconnectionstatechange ${newState}`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.oniceconnectionstatechange) {
+ this._peerConnectionDelegate.oniceconnectionstatechange(event);
+ }
+ };
+ peerConnection.onicegatheringstatechange = (event) => {
+ var _a;
+ const newState = peerConnection.iceGatheringState;
+ this.logger.debug(`SessionDescriptionHandler.onicegatheringstatechange ${newState}`);
+ if (newState === "complete") {
+ this.iceGatheringComplete(); // complete waiting for ICE gathering to complete
+ }
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onicegatheringstatechange) {
+ this._peerConnectionDelegate.onicegatheringstatechange(event);
+ }
+ };
+ peerConnection.onnegotiationneeded = (event) => {
+ var _a;
+ this.logger.debug(`SessionDescriptionHandler.onnegotiationneeded`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onnegotiationneeded) {
+ this._peerConnectionDelegate.onnegotiationneeded(event);
+ }
+ };
+ peerConnection.onsignalingstatechange = (event) => {
+ var _a;
+ const newState = peerConnection.signalingState;
+ this.logger.debug(`SessionDescriptionHandler.onsignalingstatechange ${newState}`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onsignalingstatechange) {
+ this._peerConnectionDelegate.onsignalingstatechange(event);
+ }
+ };
+ peerConnection.onstatsended = (event) => {
+ var _a;
+ this.logger.debug(`SessionDescriptionHandler.onstatsended`);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.onstatsended) {
+ this._peerConnectionDelegate.onstatsended(event);
+ }
+ };
+ peerConnection.ontrack = (event) => {
+ var _a;
+ const kind = event.track.kind;
+ const enabled = event.track.enabled ? "enabled" : "disabled";
+ this.logger.debug(`SessionDescriptionHandler.ontrack ${kind} ${enabled}`);
+ this.setRemoteTrack(event.track);
+ if ((_a = this._peerConnectionDelegate) === null || _a === void 0 ? void 0 : _a.ontrack) {
+ this._peerConnectionDelegate.ontrack(event);
+ }
+ };
+ }
+}
+
+
+/***/ }),
+/* 168 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 169 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 170 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 171 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(172);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "Transport", function() { return _transport__WEBPACK_IMPORTED_MODULE_0__["Transport"]; });
+
+/* harmony import */ var _transport_options__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(173);
+/* harmony import */ var _transport_options__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_transport_options__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _transport_options__WEBPACK_IMPORTED_MODULE_1__) if(["Transport","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _transport_options__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/**
+ * A Transport implementation for web browsers.
+ * @packageDocumentation
+ */
+
+
+
+
+/***/ }),
+/* 172 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "Transport", function() { return Transport; });
+/* harmony import */ var _api_emitter__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(108);
+/* harmony import */ var _api_exceptions__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(3);
+/* harmony import */ var _api_transport_state__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(156);
+/* harmony import */ var _core__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(5);
+
+
+
+
+/**
+ * Transport for SIP over secure WebSocket (WSS).
+ * @public
+ */
+class Transport {
+ constructor(logger, options) {
+ this._state = _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected;
+ this.transitioningState = false;
+ // state emitter
+ this._stateEventEmitter = new _api_emitter__WEBPACK_IMPORTED_MODULE_0__["EmitterImpl"]();
+ // logger
+ this.logger = logger;
+ // guard deprecated options (remove this in version 16.x)
+ if (options) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const optionsDeprecated = options;
+ const wsServersDeprecated = optionsDeprecated === null || optionsDeprecated === void 0 ? void 0 : optionsDeprecated.wsServers;
+ const maxReconnectionAttemptsDeprecated = optionsDeprecated === null || optionsDeprecated === void 0 ? void 0 : optionsDeprecated.maxReconnectionAttempts;
+ if (wsServersDeprecated !== undefined) {
+ const deprecatedMessage = `The transport option "wsServers" as has apparently been specified and has been deprecated. ` +
+ "It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
+ this.logger.warn(deprecatedMessage);
+ }
+ if (maxReconnectionAttemptsDeprecated !== undefined) {
+ const deprecatedMessage = `The transport option "maxReconnectionAttempts" as has apparently been specified and has been deprecated. ` +
+ "It will no longer be available starting with SIP.js release 0.16.0. Please update accordingly.";
+ this.logger.warn(deprecatedMessage);
+ }
+ // hack
+ if (wsServersDeprecated && !options.server) {
+ if (typeof wsServersDeprecated === "string") {
+ options.server = wsServersDeprecated;
+ }
+ if (wsServersDeprecated instanceof Array) {
+ options.server = wsServersDeprecated[0];
+ }
+ }
+ }
+ // initialize configuration
+ this.configuration = Object.assign(Object.assign({}, Transport.defaultOptions), options);
+ // validate server URL
+ const url = this.configuration.server;
+ const parsed = _core__WEBPACK_IMPORTED_MODULE_3__["Grammar"].parse(url, "absoluteURI");
+ if (parsed === -1) {
+ this.logger.error(`Invalid WebSocket Server URL "${url}"`);
+ throw new Error("Invalid WebSocket Server URL");
+ }
+ if (!["wss", "ws", "udp"].includes(parsed.scheme)) {
+ this.logger.error(`Invalid scheme in WebSocket Server URL "${url}"`);
+ throw new Error("Invalid scheme in WebSocket Server URL");
+ }
+ this._protocol = parsed.scheme.toUpperCase();
+ }
+ dispose() {
+ return this.disconnect();
+ }
+ /**
+ * The protocol.
+ *
+ * @remarks
+ * Formatted as defined for the Via header sent-protocol transport.
+ * https://tools.ietf.org/html/rfc3261#section-20.42
+ */
+ get protocol() {
+ return this._protocol;
+ }
+ /**
+ * The URL of the WebSocket Server.
+ */
+ get server() {
+ return this.configuration.server;
+ }
+ /**
+ * Transport state.
+ */
+ get state() {
+ return this._state;
+ }
+ /**
+ * Transport state change emitter.
+ */
+ get stateChange() {
+ return this._stateEventEmitter;
+ }
+ /**
+ * The WebSocket.
+ */
+ get ws() {
+ return this._ws;
+ }
+ /**
+ * Connect to network.
+ * Resolves once connected. Otherwise rejects with an Error.
+ */
+ connect() {
+ return this._connect();
+ }
+ /**
+ * Disconnect from network.
+ * Resolves once disconnected. Otherwise rejects with an Error.
+ */
+ disconnect() {
+ return this._disconnect();
+ }
+ /**
+ * Returns true if the `state` equals "Connected".
+ * @remarks
+ * This is equivalent to `state === TransportState.Connected`.
+ */
+ isConnected() {
+ return this.state === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected;
+ }
+ /**
+ * Sends a message.
+ * Resolves once message is sent. Otherwise rejects with an Error.
+ * @param message - Message to send.
+ */
+ send(message) {
+ // Error handling is independent of whether the message was a request or
+ // response.
+ //
+ // If the transport user asks for a message to be sent over an
+ // unreliable transport, and the result is an ICMP error, the behavior
+ // depends on the type of ICMP error. Host, network, port or protocol
+ // unreachable errors, or parameter problem errors SHOULD cause the
+ // transport layer to inform the transport user of a failure in sending.
+ // Source quench and TTL exceeded ICMP errors SHOULD be ignored.
+ //
+ // If the transport user asks for a request to be sent over a reliable
+ // transport, and the result is a connection failure, the transport
+ // layer SHOULD inform the transport user of a failure in sending.
+ // https://tools.ietf.org/html/rfc3261#section-18.4
+ return this._send(message);
+ }
+ _connect() {
+ this.logger.log(`Connecting ${this.server}`);
+ switch (this.state) {
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting:
+ // If `state` is "Connecting", `state` MUST NOT transition before returning.
+ if (this.transitioningState) {
+ return Promise.reject(this.transitionLoopDetectedError(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting));
+ }
+ if (!this.connectPromise) {
+ throw new Error("Connect promise must be defined.");
+ }
+ return this.connectPromise; // Already connecting
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected:
+ // If `state` is "Connected", `state` MUST NOT transition before returning.
+ if (this.transitioningState) {
+ return Promise.reject(this.transitionLoopDetectedError(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting));
+ }
+ if (this.connectPromise) {
+ throw new Error("Connect promise must not be defined.");
+ }
+ return Promise.resolve(); // Already connected
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting:
+ // If `state` is "Disconnecting", `state` MUST transition to "Connecting" before returning
+ if (this.connectPromise) {
+ throw new Error("Connect promise must not be defined.");
+ }
+ try {
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting);
+ }
+ catch (e) {
+ if (e instanceof _api_exceptions__WEBPACK_IMPORTED_MODULE_1__["StateTransitionError"]) {
+ return Promise.reject(e); // Loop detected
+ }
+ throw e;
+ }
+ break;
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected:
+ // If `state` is "Disconnected" `state` MUST transition to "Connecting" before returning
+ if (this.connectPromise) {
+ throw new Error("Connect promise must not be defined.");
+ }
+ try {
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting);
+ }
+ catch (e) {
+ if (e instanceof _api_exceptions__WEBPACK_IMPORTED_MODULE_1__["StateTransitionError"]) {
+ return Promise.reject(e); // Loop detected
+ }
+ throw e;
+ }
+ break;
+ default:
+ throw new Error("Unknown state");
+ }
+ let ws;
+ try {
+ // WebSocket()
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket
+ ws = new WebSocket(this.server, "sip");
+ ws.binaryType = "arraybuffer"; // set data type of received binary messages
+ ws.addEventListener("close", (ev) => this.onWebSocketClose(ev, ws));
+ ws.addEventListener("error", (ev) => this.onWebSocketError(ev, ws));
+ ws.addEventListener("open", (ev) => this.onWebSocketOpen(ev, ws));
+ ws.addEventListener("message", (ev) => this.onWebSocketMessage(ev, ws));
+ this._ws = ws;
+ }
+ catch (error) {
+ this._ws = undefined;
+ this.logger.error("WebSocket construction failed.");
+ this.logger.error(error);
+ return new Promise((resolve, reject) => {
+ this.connectResolve = resolve;
+ this.connectReject = reject;
+ // The `state` MUST transition to "Disconnecting" or "Disconnected" before rejecting
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected, error);
+ });
+ }
+ this.connectPromise = new Promise((resolve, reject) => {
+ this.connectResolve = resolve;
+ this.connectReject = reject;
+ this.connectTimeout = setTimeout(() => {
+ this.logger.warn("Connect timed out. " +
+ "Exceeded time set in configuration.connectionTimeout: " +
+ this.configuration.connectionTimeout +
+ "s.");
+ ws.close(1000); // careful here to use a local reference instead of this._ws
+ }, this.configuration.connectionTimeout * 1000);
+ });
+ return this.connectPromise;
+ }
+ _disconnect() {
+ this.logger.log(`Disconnecting ${this.server}`);
+ switch (this.state) {
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting:
+ // If `state` is "Connecting", `state` MUST transition to "Disconnecting" before returning.
+ if (this.disconnectPromise) {
+ throw new Error("Disconnect promise must not be defined.");
+ }
+ try {
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting);
+ }
+ catch (e) {
+ if (e instanceof _api_exceptions__WEBPACK_IMPORTED_MODULE_1__["StateTransitionError"]) {
+ return Promise.reject(e); // Loop detected
+ }
+ throw e;
+ }
+ break;
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected:
+ // If `state` is "Connected", `state` MUST transition to "Disconnecting" before returning.
+ if (this.disconnectPromise) {
+ throw new Error("Disconnect promise must not be defined.");
+ }
+ try {
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting);
+ }
+ catch (e) {
+ if (e instanceof _api_exceptions__WEBPACK_IMPORTED_MODULE_1__["StateTransitionError"]) {
+ return Promise.reject(e); // Loop detected
+ }
+ throw e;
+ }
+ break;
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting:
+ // If `state` is "Disconnecting", `state` MUST NOT transition before returning.
+ if (this.transitioningState) {
+ return Promise.reject(this.transitionLoopDetectedError(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting));
+ }
+ if (!this.disconnectPromise) {
+ throw new Error("Disconnect promise must be defined.");
+ }
+ return this.disconnectPromise; // Already disconnecting
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected:
+ // If `state` is "Disconnected", `state` MUST NOT transition before returning.
+ if (this.transitioningState) {
+ return Promise.reject(this.transitionLoopDetectedError(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting));
+ }
+ if (this.disconnectPromise) {
+ throw new Error("Disconnect promise must not be defined.");
+ }
+ return Promise.resolve(); // Already disconnected
+ default:
+ throw new Error("Unknown state");
+ }
+ if (!this._ws) {
+ throw new Error("WebSocket must be defined.");
+ }
+ const ws = this._ws;
+ this.disconnectPromise = new Promise((resolve, reject) => {
+ this.disconnectResolve = resolve;
+ this.disconnectReject = reject;
+ try {
+ // WebSocket.close()
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close
+ ws.close(1000); // careful here to use a local reference instead of this._ws
+ }
+ catch (error) {
+ // Treating this as a coding error as it apparently can only happen
+ // if you pass close() invalid parameters (so it should never happen)
+ this.logger.error("WebSocket close failed.");
+ this.logger.error(error);
+ throw error;
+ }
+ });
+ return this.disconnectPromise;
+ }
+ _send(message) {
+ if (this.configuration.traceSip === true) {
+ this.logger.log("Sending WebSocket message:\n\n" + message + "\n");
+ }
+ if (this._state !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected) {
+ return Promise.reject(new Error("Not connected."));
+ }
+ if (!this._ws) {
+ throw new Error("WebSocket undefined.");
+ }
+ try {
+ // WebSocket.send()
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
+ this._ws.send(message);
+ }
+ catch (error) {
+ if (error instanceof Error) {
+ return Promise.reject(error);
+ }
+ return Promise.reject(new Error("WebSocket send failed."));
+ }
+ return Promise.resolve();
+ }
+ /**
+ * WebSocket "onclose" event handler.
+ * @param ev - Event.
+ */
+ onWebSocketClose(ev, ws) {
+ if (ws !== this._ws) {
+ return;
+ }
+ const message = `WebSocket closed ${this.server} (code: ${ev.code})`;
+ const error = !this.disconnectPromise ? new Error(message) : undefined;
+ if (error) {
+ this.logger.warn("WebSocket closed unexpectedly");
+ }
+ this.logger.log(message);
+ // We are about to transition to disconnected, so clear our web socket
+ this._ws = undefined;
+ // The `state` MUST transition to "Disconnected" before resolving (assuming `state` is not already "Disconnected").
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected, error);
+ }
+ /**
+ * WebSocket "onerror" event handler.
+ * @param ev - Event.
+ */
+ onWebSocketError(ev, ws) {
+ if (ws !== this._ws) {
+ return;
+ }
+ this.logger.error("WebSocket error occurred.");
+ }
+ /**
+ * WebSocket "onmessage" event handler.
+ * @param ev - Event.
+ */
+ onWebSocketMessage(ev, ws) {
+ if (ws !== this._ws) {
+ return;
+ }
+ const data = ev.data;
+ let finishedData;
+ // CRLF Keep Alive response from server. Clear our keep alive timeout.
+ if (/^(\r\n)+$/.test(data)) {
+ this.clearKeepAliveTimeout();
+ if (this.configuration.traceSip === true) {
+ this.logger.log("Received WebSocket message with CRLF Keep Alive response");
+ }
+ return;
+ }
+ if (!data) {
+ this.logger.warn("Received empty message, discarding...");
+ return;
+ }
+ if (typeof data !== "string") {
+ // WebSocket binary message.
+ try {
+ finishedData = new TextDecoder().decode(new Uint8Array(data));
+ // TextDecoder (above) is not supported by old browsers, but it correctly decodes UTF-8.
+ // The line below is an ISO 8859-1 (Latin 1) decoder, so just UTF-8 code points that are 1 byte.
+ // It's old code and works in old browsers (IE), so leaving it here in a comment in case someone needs it.
+ // finishedData = String.fromCharCode.apply(null, (new Uint8Array(data) as unknown as Array));
+ }
+ catch (err) {
+ this.logger.error(err);
+ this.logger.error("Received WebSocket binary message failed to be converted into string, message discarded");
+ return;
+ }
+ if (this.configuration.traceSip === true) {
+ this.logger.log("Received WebSocket binary message:\n\n" + finishedData + "\n");
+ }
+ }
+ else {
+ // WebSocket text message.
+ finishedData = data;
+ if (this.configuration.traceSip === true) {
+ this.logger.log("Received WebSocket text message:\n\n" + finishedData + "\n");
+ }
+ }
+ if (this.state !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected) {
+ this.logger.warn("Received message while not connected, discarding...");
+ return;
+ }
+ if (this.onMessage) {
+ try {
+ this.onMessage(finishedData);
+ }
+ catch (e) {
+ this.logger.error(e);
+ this.logger.error("Exception thrown by onMessage callback");
+ throw e; // rethrow unhandled exception
+ }
+ }
+ }
+ /**
+ * WebSocket "onopen" event handler.
+ * @param ev - Event.
+ */
+ onWebSocketOpen(ev, ws) {
+ if (ws !== this._ws) {
+ return;
+ }
+ if (this._state === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting) {
+ this.logger.log(`WebSocket opened ${this.server}`);
+ this.transitionState(_api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected);
+ }
+ }
+ /**
+ * Helper function to generate an Error.
+ * @param state - State transitioning to.
+ */
+ transitionLoopDetectedError(state) {
+ let message = `A state transition loop has been detected.`;
+ message += ` An attempt to transition from ${this._state} to ${state} before the prior transition completed.`;
+ message += ` Perhaps you are synchronously calling connect() or disconnect() from a callback or state change handler?`;
+ this.logger.error(message);
+ return new _api_exceptions__WEBPACK_IMPORTED_MODULE_1__["StateTransitionError"]("Loop detected.");
+ }
+ /**
+ * Transition transport state.
+ * @internal
+ */
+ transitionState(newState, error) {
+ const invalidTransition = () => {
+ throw new Error(`Invalid state transition from ${this._state} to ${newState}`);
+ };
+ if (this.transitioningState) {
+ throw this.transitionLoopDetectedError(newState);
+ }
+ this.transitioningState = true;
+ // Validate state transition
+ switch (this._state) {
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting:
+ if (newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected &&
+ newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting &&
+ newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected) {
+ invalidTransition();
+ }
+ break;
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected:
+ if (newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting && newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected) {
+ invalidTransition();
+ }
+ break;
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting:
+ if (newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting && newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected) {
+ invalidTransition();
+ }
+ break;
+ case _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected:
+ if (newState !== _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting) {
+ invalidTransition();
+ }
+ break;
+ default:
+ throw new Error("Unknown state.");
+ }
+ // Update state
+ const oldState = this._state;
+ this._state = newState;
+ // Local copies of connect promises (guarding against callbacks changing them indirectly)
+ // const connectPromise = this.connectPromise;
+ const connectResolve = this.connectResolve;
+ const connectReject = this.connectReject;
+ // Reset connect promises if no longer connecting
+ if (oldState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting) {
+ this.connectPromise = undefined;
+ this.connectResolve = undefined;
+ this.connectReject = undefined;
+ }
+ // Local copies of disconnect promises (guarding against callbacks changing them indirectly)
+ // const disconnectPromise = this.disconnectPromise;
+ const disconnectResolve = this.disconnectResolve;
+ const disconnectReject = this.disconnectReject;
+ // Reset disconnect promises if no longer disconnecting
+ if (oldState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting) {
+ this.disconnectPromise = undefined;
+ this.disconnectResolve = undefined;
+ this.disconnectReject = undefined;
+ }
+ // Clear any outstanding connect timeout
+ if (this.connectTimeout) {
+ clearTimeout(this.connectTimeout);
+ this.connectTimeout = undefined;
+ }
+ this.logger.log(`Transitioned from ${oldState} to ${this._state}`);
+ this._stateEventEmitter.emit(this._state);
+ // Transition to Connected
+ if (newState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected) {
+ this.startSendingKeepAlives();
+ if (this.onConnect) {
+ try {
+ this.onConnect();
+ }
+ catch (e) {
+ this.logger.error(e);
+ this.logger.error("Exception thrown by onConnect callback");
+ throw e; // rethrow unhandled exception
+ }
+ }
+ }
+ // Transition from Connected
+ if (oldState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected) {
+ this.stopSendingKeepAlives();
+ if (this.onDisconnect) {
+ try {
+ if (error) {
+ this.onDisconnect(error);
+ }
+ else {
+ this.onDisconnect();
+ }
+ }
+ catch (e) {
+ this.logger.error(e);
+ this.logger.error("Exception thrown by onDisconnect callback");
+ throw e; // rethrow unhandled exception
+ }
+ }
+ }
+ // Complete connect promise
+ if (oldState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connecting) {
+ if (!connectResolve) {
+ throw new Error("Connect resolve undefined.");
+ }
+ if (!connectReject) {
+ throw new Error("Connect reject undefined.");
+ }
+ newState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Connected ? connectResolve() : connectReject(error || new Error("Connect aborted."));
+ }
+ // Complete disconnect promise
+ if (oldState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnecting) {
+ if (!disconnectResolve) {
+ throw new Error("Disconnect resolve undefined.");
+ }
+ if (!disconnectReject) {
+ throw new Error("Disconnect reject undefined.");
+ }
+ newState === _api_transport_state__WEBPACK_IMPORTED_MODULE_2__["TransportState"].Disconnected
+ ? disconnectResolve()
+ : disconnectReject(error || new Error("Disconnect aborted."));
+ }
+ this.transitioningState = false;
+ }
+ // TODO: Review "KeepAlive Stuff".
+ // It is not clear if it works and there are no tests for it.
+ // It was blindly lifted the keep alive code unchanged from earlier transport code.
+ //
+ // From the RFC...
+ //
+ // SIP WebSocket Clients and Servers may keep their WebSocket
+ // connections open by sending periodic WebSocket "Ping" frames as
+ // described in [RFC6455], Section 5.5.2.
+ // ...
+ // The indication and use of the CRLF NAT keep-alive mechanism defined
+ // for SIP connection-oriented transports in [RFC5626], Section 3.5.1 or
+ // [RFC6223] are, of course, usable over the transport defined in this
+ // specification.
+ // https://tools.ietf.org/html/rfc7118#section-6
+ //
+ // and...
+ //
+ // The Ping frame contains an opcode of 0x9.
+ // https://tools.ietf.org/html/rfc6455#section-5.5.2
+ //
+ // ==============================
+ // KeepAlive Stuff
+ // ==============================
+ clearKeepAliveTimeout() {
+ if (this.keepAliveDebounceTimeout) {
+ clearTimeout(this.keepAliveDebounceTimeout);
+ }
+ this.keepAliveDebounceTimeout = undefined;
+ }
+ /**
+ * Send a keep-alive (a double-CRLF sequence).
+ */
+ sendKeepAlive() {
+ if (this.keepAliveDebounceTimeout) {
+ // We already have an outstanding keep alive, do not send another.
+ return Promise.resolve();
+ }
+ this.keepAliveDebounceTimeout = setTimeout(() => {
+ this.clearKeepAliveTimeout();
+ }, this.configuration.keepAliveDebounce * 1000);
+ return this.send("\r\n\r\n");
+ }
+ /**
+ * Start sending keep-alives.
+ */
+ startSendingKeepAlives() {
+ // Compute an amount of time in seconds to wait before sending another keep-alive.
+ const computeKeepAliveTimeout = (upperBound) => {
+ const lowerBound = upperBound * 0.8;
+ return 1000 * (Math.random() * (upperBound - lowerBound) + lowerBound);
+ };
+ if (this.configuration.keepAliveInterval && !this.keepAliveInterval) {
+ this.keepAliveInterval = setInterval(() => {
+ this.sendKeepAlive();
+ this.startSendingKeepAlives();
+ }, computeKeepAliveTimeout(this.configuration.keepAliveInterval));
+ }
+ }
+ /**
+ * Stop sending keep-alives.
+ */
+ stopSendingKeepAlives() {
+ if (this.keepAliveInterval) {
+ clearInterval(this.keepAliveInterval);
+ }
+ if (this.keepAliveDebounceTimeout) {
+ clearTimeout(this.keepAliveDebounceTimeout);
+ }
+ this.keepAliveInterval = undefined;
+ this.keepAliveDebounceTimeout = undefined;
+ }
+}
+Transport.defaultOptions = {
+ server: "",
+ connectionTimeout: 5,
+ keepAliveInterval: 0,
+ keepAliveDebounce: 10,
+ traceSip: true
+};
+
+
+/***/ }),
+/* 173 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 174 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _modifiers__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(175);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripTcpCandidates", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripTcpCandidates"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripTelephoneEvent", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripTelephoneEvent"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "cleanJitsiSdpImageattr", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["cleanJitsiSdpImageattr"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripG722", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripG722"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripRtpPayload", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripRtpPayload"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripVideo", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripVideo"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "addMidLines", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["addMidLines"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "holdModifier", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["holdModifier"]; });
+
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(160);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _session_description_handler__WEBPACK_IMPORTED_MODULE_1__) if(["stripTcpCandidates","stripTelephoneEvent","cleanJitsiSdpImageattr","stripG722","stripRtpPayload","stripVideo","addMidLines","holdModifier","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _session_description_handler__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _simple_user__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(177);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _simple_user__WEBPACK_IMPORTED_MODULE_2__) if(["stripTcpCandidates","stripTelephoneEvent","cleanJitsiSdpImageattr","stripG722","stripRtpPayload","stripVideo","addMidLines","holdModifier","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _simple_user__WEBPACK_IMPORTED_MODULE_2__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(171);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _transport__WEBPACK_IMPORTED_MODULE_3__) if(["stripTcpCandidates","stripTelephoneEvent","cleanJitsiSdpImageattr","stripG722","stripRtpPayload","stripVideo","addMidLines","holdModifier","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _transport__WEBPACK_IMPORTED_MODULE_3__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+
+
+
+
+
+
+/***/ }),
+/* 175 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _modifiers__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(176);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripTcpCandidates", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripTcpCandidates"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripTelephoneEvent", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripTelephoneEvent"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "cleanJitsiSdpImageattr", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["cleanJitsiSdpImageattr"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripG722", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripG722"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripRtpPayload", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripRtpPayload"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "stripVideo", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["stripVideo"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "addMidLines", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["addMidLines"]; });
+
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "holdModifier", function() { return _modifiers__WEBPACK_IMPORTED_MODULE_0__["holdModifier"]; });
+
+/**
+ * SessionDescriptionHandlerModifer functions for web browsers.
+ * @packageDocumentation
+ */
+
+
+
+/***/ }),
+/* 176 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "stripTcpCandidates", function() { return stripTcpCandidates; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "stripTelephoneEvent", function() { return stripTelephoneEvent; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "cleanJitsiSdpImageattr", function() { return cleanJitsiSdpImageattr; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "stripG722", function() { return stripG722; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "stripRtpPayload", function() { return stripRtpPayload; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "stripVideo", function() { return stripVideo; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "addMidLines", function() { return addMidLines; });
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "holdModifier", function() { return holdModifier; });
+const stripPayload = (sdp, payload) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const mediaDescs = [];
+ const lines = sdp.split(/\r\n/);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let currentMediaDesc;
+ for (let i = 0; i < lines.length;) {
+ const line = lines[i];
+ if (/^m=(?:audio|video)/.test(line)) {
+ currentMediaDesc = {
+ index: i,
+ stripped: []
+ };
+ mediaDescs.push(currentMediaDesc);
+ }
+ else if (currentMediaDesc) {
+ const rtpmap = /^a=rtpmap:(\d+) ([^/]+)\//.exec(line);
+ if (rtpmap && payload === rtpmap[2]) {
+ lines.splice(i, 1);
+ currentMediaDesc.stripped.push(rtpmap[1]);
+ continue; // Don't increment 'i'
+ }
+ }
+ i++;
+ }
+ for (const mediaDesc of mediaDescs) {
+ const mline = lines[mediaDesc.index].split(" ");
+ // Ignore the first 3 parameters of the mline. The codec information is after that
+ for (let j = 3; j < mline.length;) {
+ if (mediaDesc.stripped.indexOf(mline[j]) !== -1) {
+ mline.splice(j, 1);
+ continue;
+ }
+ j++;
+ }
+ lines[mediaDesc.index] = mline.join(" ");
+ }
+ return lines.join("\r\n");
+};
+const stripMediaDescription = (sdp, description) => {
+ const descriptionRegExp = new RegExp("m=" + description + ".*$", "gm");
+ const groupRegExp = new RegExp("^a=group:.*$", "gm");
+ if (descriptionRegExp.test(sdp)) {
+ let midLineToRemove;
+ sdp = sdp.split(/^m=/gm).filter((section) => {
+ if (section.substr(0, description.length) === description) {
+ midLineToRemove = section.match(/^a=mid:.*$/gm);
+ if (midLineToRemove) {
+ const step = midLineToRemove[0].match(/:.+$/g);
+ if (step) {
+ midLineToRemove = step[0].substr(1);
+ }
+ }
+ return false;
+ }
+ return true;
+ }).join("m=");
+ const groupLine = sdp.match(groupRegExp);
+ if (groupLine && groupLine.length === 1) {
+ let groupLinePortion = groupLine[0];
+ // eslint-disable-next-line no-useless-escape
+ const groupRegExpReplace = new RegExp("\ *" + midLineToRemove + "[^\ ]*", "g");
+ groupLinePortion = groupLinePortion.replace(groupRegExpReplace, "");
+ sdp = sdp.split(groupRegExp).join(groupLinePortion);
+ }
+ }
+ return sdp;
+};
+/**
+ * Modifier.
+ * @public
+ */
+function stripTcpCandidates(description) {
+ description.sdp = (description.sdp || "").replace(/^a=candidate:\d+ \d+ tcp .*?\r\n/img, "");
+ return Promise.resolve(description);
+}
+/**
+ * Modifier.
+ * @public
+ */
+function stripTelephoneEvent(description) {
+ description.sdp = stripPayload(description.sdp || "", "telephone-event");
+ return Promise.resolve(description);
+}
+/**
+ * Modifier.
+ * @public
+ */
+function cleanJitsiSdpImageattr(description) {
+ description.sdp = (description.sdp || "").replace(/^(a=imageattr:.*?)(x|y)=\[0-/gm, "$1$2=[1:");
+ return Promise.resolve(description);
+}
+/**
+ * Modifier.
+ * @public
+ */
+function stripG722(description) {
+ description.sdp = stripPayload(description.sdp || "", "G722");
+ return Promise.resolve(description);
+}
+/**
+ * Modifier.
+ * @public
+ */
+function stripRtpPayload(payload) {
+ return (description) => {
+ description.sdp = stripPayload(description.sdp || "", payload);
+ return Promise.resolve(description);
+ };
+}
+/**
+ * Modifier.
+ * @public
+ */
+function stripVideo(description) {
+ description.sdp = stripMediaDescription(description.sdp || "", "video");
+ return Promise.resolve(description);
+}
+/**
+ * Modifier.
+ * @public
+ */
+function addMidLines(description) {
+ let sdp = description.sdp || "";
+ if (sdp.search(/^a=mid.*$/gm) === -1) {
+ const mlines = sdp.match(/^m=.*$/gm);
+ const sdpArray = sdp.split(/^m=.*$/gm);
+ if (mlines) {
+ mlines.forEach((elem, idx) => {
+ mlines[idx] = elem + "\na=mid:" + idx;
+ });
+ }
+ sdpArray.forEach((elem, idx) => {
+ if (mlines && mlines[idx]) {
+ sdpArray[idx] = elem + mlines[idx];
+ }
+ });
+ sdp = sdpArray.join("");
+ description.sdp = sdp;
+ }
+ return Promise.resolve(description);
+}
+/**
+ * The modifier that should be used when the session would like to place the call on hold.
+ * @param description - The description that will be modified.
+ */
+function holdModifier(description) {
+ if (!description.sdp || !description.type) {
+ throw new Error("Invalid SDP");
+ }
+ let sdp = description.sdp;
+ const type = description.type;
+ if (sdp) {
+ if (!/a=(sendrecv|sendonly|recvonly|inactive)/.test(sdp)) {
+ sdp = sdp.replace(/(m=[^\r]*\r\n)/g, "$1a=sendonly\r\n");
+ }
+ else {
+ sdp = sdp.replace(/a=sendrecv\r\n/g, "a=sendonly\r\n");
+ sdp = sdp.replace(/a=recvonly\r\n/g, "a=inactive\r\n");
+ }
+ }
+ return Promise.resolve({ sdp, type });
+}
+
+
+/***/ }),
+/* 177 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony import */ var _simple_user__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(178);
+/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, "SimpleUser", function() { return _simple_user__WEBPACK_IMPORTED_MODULE_0__["SimpleUser"]; });
+
+/* harmony import */ var _simple_user_delegate__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(179);
+/* harmony import */ var _simple_user_delegate__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(_simple_user_delegate__WEBPACK_IMPORTED_MODULE_1__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _simple_user_delegate__WEBPACK_IMPORTED_MODULE_1__) if(["SimpleUser","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _simple_user_delegate__WEBPACK_IMPORTED_MODULE_1__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/* harmony import */ var _simple_user_options__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(180);
+/* harmony import */ var _simple_user_options__WEBPACK_IMPORTED_MODULE_2___default = /*#__PURE__*/__webpack_require__.n(_simple_user_options__WEBPACK_IMPORTED_MODULE_2__);
+/* harmony reexport (unknown) */ for(var __WEBPACK_IMPORT_KEY__ in _simple_user_options__WEBPACK_IMPORTED_MODULE_2__) if(["SimpleUser","default"].indexOf(__WEBPACK_IMPORT_KEY__) < 0) (function(key) { __webpack_require__.d(__webpack_exports__, key, function() { return _simple_user_options__WEBPACK_IMPORTED_MODULE_2__[key]; }) }(__WEBPACK_IMPORT_KEY__));
+/**
+ * A simple SIP user implementation for web browsers.
+ * @packageDocumentation
+ */
+
+
+
+
+
+/***/ }),
+/* 178 */
+/***/ (function(module, __webpack_exports__, __webpack_require__) {
+
+"use strict";
+__webpack_require__.r(__webpack_exports__);
+/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SimpleUser", function() { return SimpleUser; });
+/* harmony import */ var _api__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2);
+/* harmony import */ var _modifiers__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(175);
+/* harmony import */ var _session_description_handler__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(160);
+/* harmony import */ var _transport__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(171);
+
+
+
+
+/**
+ * A simple SIP user class.
+ * @remarks
+ * While this class is completely functional for simple use cases, it is not intended
+ * to provide an interface which is suitable for most (must less all) applications.
+ * While this class has many limitations (for example, it only handles a single concurrent session),
+ * it is, however, intended to serve as a simple example of using the SIP.js API.
+ * @public
+ */
+class SimpleUser {
+ /**
+ * Constructs a new instance of the `SimpleUser` class.
+ * @param server - SIP WebSocket Server URL.
+ * @param options - Options bucket. See {@link SimpleUserOptions} for details.
+ */
+ constructor(server, options = {}) {
+ this.attemptingReconnection = false;
+ this.connectRequested = false;
+ this.held = false;
+ this.registerer = undefined;
+ this.registerRequested = false;
+ this.session = undefined;
+ // Delegate
+ this.delegate = options.delegate;
+ // Copy options
+ this.options = Object.assign({}, options);
+ // UserAgentOptions
+ const userAgentOptions = Object.assign({}, options.userAgentOptions);
+ // Transport
+ if (!userAgentOptions.transportConstructor) {
+ userAgentOptions.transportConstructor = _transport__WEBPACK_IMPORTED_MODULE_3__["Transport"];
+ }
+ // TransportOptions
+ if (!userAgentOptions.transportOptions) {
+ userAgentOptions.transportOptions = {
+ server
+ };
+ }
+ // URI
+ if (!userAgentOptions.uri) {
+ // If an AOR was provided, convert it to a URI
+ if (options.aor) {
+ const uri = _api__WEBPACK_IMPORTED_MODULE_0__["UserAgent"].makeURI(options.aor);
+ if (!uri) {
+ throw new Error(`Failed to create valid URI from ${options.aor}`);
+ }
+ userAgentOptions.uri = uri;
+ }
+ }
+ // UserAgent
+ this.userAgent = new _api__WEBPACK_IMPORTED_MODULE_0__["UserAgent"](userAgentOptions);
+ // UserAgent's delegate
+ this.userAgent.delegate = {
+ // Handle connection with server established
+ onConnect: () => {
+ this.logger.log(`[${this.id}] Connected`);
+ if (this.delegate && this.delegate.onServerConnect) {
+ this.delegate.onServerConnect();
+ }
+ if (this.registerer && this.registerRequested) {
+ this.logger.log(`[${this.id}] Registering...`);
+ this.registerer.register().catch((e) => {
+ this.logger.error(`[${this.id}] Error occurred registering after connection with server was obtained.`);
+ this.logger.error(e.toString());
+ });
+ }
+ },
+ // Handle connection with server lost
+ onDisconnect: (error) => {
+ this.logger.log(`[${this.id}] Disconnected`);
+ if (this.delegate && this.delegate.onServerDisconnect) {
+ this.delegate.onServerDisconnect(error);
+ }
+ if (this.session) {
+ this.logger.log(`[${this.id}] Hanging up...`);
+ this.hangup() // cleanup hung calls
+ .catch((e) => {
+ this.logger.error(`[${this.id}] Error occurred hanging up call after connection with server was lost.`);
+ this.logger.error(e.toString());
+ });
+ }
+ if (this.registerer) {
+ this.logger.log(`[${this.id}] Unregistering...`);
+ this.registerer
+ .unregister() // cleanup invalid registrations
+ .catch((e) => {
+ this.logger.error(`[${this.id}] Error occurred unregistering after connection with server was lost.`);
+ this.logger.error(e.toString());
+ });
+ }
+ // Only attempt to reconnect if network/server dropped the connection.
+ if (error) {
+ this.attemptReconnection();
+ }
+ },
+ // Handle incoming invitations
+ onInvite: (invitation) => {
+ this.logger.log(`[${this.id}] Received INVITE`);
+ // Guard against a pre-existing session. This implementation only supports one session at a time.
+ // However an incoming INVITE request may be received at any time and/or while in the process
+ // of sending an outgoing INVITE request. So we reject any incoming INVITE in those cases.
+ if (this.session) {
+ this.logger.warn(`[${this.id}] Session already in progress, rejecting INVITE...`);
+ invitation
+ .reject()
+ .then(() => {
+ this.logger.log(`[${this.id}] Rejected INVITE`);
+ })
+ .catch((error) => {
+ this.logger.error(`[${this.id}] Failed to reject INVITE`);
+ this.logger.error(error.toString());
+ });
+ return;
+ }
+ // Use our configured constraints as options for any Inviter created as result of a REFER
+ const referralInviterOptions = {
+ sessionDescriptionHandlerOptions: { constraints: this.constraints }
+ };
+ // Initialize our session
+ this.initSession(invitation, referralInviterOptions);
+ // Delegate
+ if (this.delegate && this.delegate.onCallReceived) {
+ this.delegate.onCallReceived();
+ }
+ else {
+ this.logger.warn(`[${this.id}] No handler available, rejecting INVITE...`);
+ invitation
+ .reject()
+ .then(() => {
+ this.logger.log(`[${this.id}] Rejected INVITE`);
+ })
+ .catch((error) => {
+ this.logger.error(`[${this.id}] Failed to reject INVITE`);
+ this.logger.error(error.toString());
+ });
+ }
+ },
+ // Handle incoming messages
+ onMessage: (message) => {
+ message.accept().then(() => {
+ if (this.delegate && this.delegate.onMessageReceived) {
+ this.delegate.onMessageReceived(message.request.body);
+ }
+ });
+ }
+ };
+ // Use the SIP.js logger
+ this.logger = this.userAgent.getLogger("sip.SimpleUser");
+ // Monitor network connectivity and attempt reconnection when we come online
+ window.addEventListener("online", () => {
+ this.logger.log(`[${this.id}] Online`);
+ this.attemptReconnection();
+ });
+ }
+ /**
+ * Instance identifier.
+ * @internal
+ */
+ get id() {
+ return (this.options.userAgentOptions && this.options.userAgentOptions.displayName) || "Anonymous";
+ }
+ /** The local media stream. Undefined if call not answered. */
+ get localMediaStream() {
+ var _a;
+ const sdh = (_a = this.session) === null || _a === void 0 ? void 0 : _a.sessionDescriptionHandler;
+ if (!sdh) {
+ return undefined;
+ }
+ if (!(sdh instanceof _session_description_handler__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandler"])) {
+ throw new Error("Session description handler not instance of web SessionDescriptionHandler");
+ }
+ return sdh.localMediaStream;
+ }
+ /** The remote media stream. Undefined if call not answered. */
+ get remoteMediaStream() {
+ var _a;
+ const sdh = (_a = this.session) === null || _a === void 0 ? void 0 : _a.sessionDescriptionHandler;
+ if (!sdh) {
+ return undefined;
+ }
+ if (!(sdh instanceof _session_description_handler__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandler"])) {
+ throw new Error("Session description handler not instance of web SessionDescriptionHandler");
+ }
+ return sdh.remoteMediaStream;
+ }
+ /**
+ * The local audio track, if available.
+ * @deprecated Use localMediaStream and get track from the stream.
+ */
+ get localAudioTrack() {
+ var _a;
+ return (_a = this.localMediaStream) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "audio");
+ }
+ /**
+ * The local video track, if available.
+ * @deprecated Use localMediaStream and get track from the stream.
+ */
+ get localVideoTrack() {
+ var _a;
+ return (_a = this.localMediaStream) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "video");
+ }
+ /**
+ * The remote audio track, if available.
+ * @deprecated Use remoteMediaStream and get track from the stream.
+ */
+ get remoteAudioTrack() {
+ var _a;
+ return (_a = this.remoteMediaStream) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "audio");
+ }
+ /**
+ * The remote video track, if available.
+ * @deprecated Use remoteMediaStream and get track from the stream.
+ */
+ get remoteVideoTrack() {
+ var _a;
+ return (_a = this.remoteMediaStream) === null || _a === void 0 ? void 0 : _a.getTracks().find((track) => track.kind === "video");
+ }
+ /**
+ * Connect.
+ * @remarks
+ * Start the UserAgent's WebSocket Transport.
+ */
+ connect() {
+ this.logger.log(`[${this.id}] Connecting UserAgent...`);
+ this.connectRequested = true;
+ if (this.userAgent.state !== _api__WEBPACK_IMPORTED_MODULE_0__["UserAgentState"].Started) {
+ return this.userAgent.start();
+ }
+ return this.userAgent.reconnect();
+ }
+ /**
+ * Disconnect.
+ * @remarks
+ * Stop the UserAgent's WebSocket Transport.
+ */
+ disconnect() {
+ this.logger.log(`[${this.id}] Disconnecting UserAgent...`);
+ this.connectRequested = false;
+ return this.userAgent.stop();
+ }
+ /**
+ * Return true if connected.
+ */
+ isConnected() {
+ return this.userAgent.isConnected();
+ }
+ /**
+ * Start receiving incoming calls.
+ * @remarks
+ * Send a REGISTER request for the UserAgent's AOR.
+ * Resolves when the REGISTER request is sent, otherwise rejects.
+ */
+ register(registererOptions, registererRegisterOptions) {
+ this.logger.log(`[${this.id}] Registering UserAgent...`);
+ this.registerRequested = true;
+ if (!this.registerer) {
+ this.registerer = new _api__WEBPACK_IMPORTED_MODULE_0__["Registerer"](this.userAgent, registererOptions);
+ this.registerer.stateChange.addListener((state) => {
+ switch (state) {
+ case _api__WEBPACK_IMPORTED_MODULE_0__["RegistererState"].Initial:
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["RegistererState"].Registered:
+ if (this.delegate && this.delegate.onRegistered) {
+ this.delegate.onRegistered();
+ }
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["RegistererState"].Unregistered:
+ if (this.delegate && this.delegate.onUnregistered) {
+ this.delegate.onUnregistered();
+ }
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["RegistererState"].Terminated:
+ this.registerer = undefined;
+ break;
+ default:
+ throw new Error("Unknown registerer state.");
+ }
+ });
+ }
+ return this.registerer.register(registererRegisterOptions).then(() => {
+ return;
+ });
+ }
+ /**
+ * Stop receiving incoming calls.
+ * @remarks
+ * Send an un-REGISTER request for the UserAgent's AOR.
+ * Resolves when the un-REGISTER request is sent, otherwise rejects.
+ */
+ unregister(registererUnregisterOptions) {
+ this.logger.log(`[${this.id}] Unregistering UserAgent...`);
+ this.registerRequested = false;
+ if (!this.registerer) {
+ return Promise.resolve();
+ }
+ return this.registerer.unregister(registererUnregisterOptions).then(() => {
+ return;
+ });
+ }
+ /**
+ * Make an outgoing call.
+ * @remarks
+ * Send an INVITE request to create a new Session.
+ * Resolves when the INVITE request is sent, otherwise rejects.
+ * Use `onCallAnswered` delegate method to determine if Session is established.
+ * @param destination - The target destination to call. A SIP address to send the INVITE to.
+ * @param inviterOptions - Optional options for Inviter constructor.
+ * @param inviterInviteOptions - Optional options for Inviter.invite().
+ */
+ call(destination, inviterOptions, inviterInviteOptions) {
+ this.logger.log(`[${this.id}] Beginning Session...`);
+ if (this.session) {
+ return Promise.reject(new Error("Session already exists."));
+ }
+ const target = _api__WEBPACK_IMPORTED_MODULE_0__["UserAgent"].makeURI(destination);
+ if (!target) {
+ return Promise.reject(new Error(`Failed to create a valid URI from "${destination}"`));
+ }
+ // Use our configured constraints as InviterOptions if none provided
+ if (!inviterOptions) {
+ inviterOptions = {};
+ }
+ if (!inviterOptions.sessionDescriptionHandlerOptions) {
+ inviterOptions.sessionDescriptionHandlerOptions = {};
+ }
+ if (!inviterOptions.sessionDescriptionHandlerOptions.constraints) {
+ inviterOptions.sessionDescriptionHandlerOptions.constraints = this.constraints;
+ }
+ // Create a new Inviter for the outgoing Session
+ const inviter = new _api__WEBPACK_IMPORTED_MODULE_0__["Inviter"](this.userAgent, target, inviterOptions);
+ // Send INVITE
+ return this.sendInvite(inviter, inviterOptions, inviterInviteOptions).then(() => {
+ return;
+ });
+ }
+ /**
+ * Hangup a call.
+ * @remarks
+ * Send a BYE request, CANCEL request or reject response to end the current Session.
+ * Resolves when the request/response is sent, otherwise rejects.
+ * Use `onCallTerminated` delegate method to determine if and when call is ended.
+ */
+ hangup() {
+ this.logger.log(`[${this.id}] Hangup...`);
+ return this.terminate();
+ }
+ /**
+ * Answer an incoming call.
+ * @remarks
+ * Accept an incoming INVITE request creating a new Session.
+ * Resolves with the response is sent, otherwise rejects.
+ * Use `onCallAnswered` delegate method to determine if and when call is established.
+ * @param invitationAcceptOptions - Optional options for Inviter.accept().
+ */
+ answer(invitationAcceptOptions) {
+ this.logger.log(`[${this.id}] Accepting Invitation...`);
+ if (!this.session) {
+ return Promise.reject(new Error("Session does not exist."));
+ }
+ if (!(this.session instanceof _api__WEBPACK_IMPORTED_MODULE_0__["Invitation"])) {
+ return Promise.reject(new Error("Session not instance of Invitation."));
+ }
+ // Use our configured constraints as InvitationAcceptOptions if none provided
+ if (!invitationAcceptOptions) {
+ invitationAcceptOptions = {};
+ }
+ if (!invitationAcceptOptions.sessionDescriptionHandlerOptions) {
+ invitationAcceptOptions.sessionDescriptionHandlerOptions = {};
+ }
+ if (!invitationAcceptOptions.sessionDescriptionHandlerOptions.constraints) {
+ invitationAcceptOptions.sessionDescriptionHandlerOptions.constraints = this.constraints;
+ }
+ return this.session.accept(invitationAcceptOptions);
+ }
+ /**
+ * Decline an incoming call.
+ * @remarks
+ * Reject an incoming INVITE request.
+ * Resolves with the response is sent, otherwise rejects.
+ * Use `onCallTerminated` delegate method to determine if and when call is ended.
+ */
+ decline() {
+ this.logger.log(`[${this.id}] rejecting Invitation...`);
+ if (!this.session) {
+ return Promise.reject(new Error("Session does not exist."));
+ }
+ if (!(this.session instanceof _api__WEBPACK_IMPORTED_MODULE_0__["Invitation"])) {
+ return Promise.reject(new Error("Session not instance of Invitation."));
+ }
+ return this.session.reject();
+ }
+ /**
+ * Hold call
+ * @remarks
+ * Send a re-INVITE with new offer indicating "hold".
+ * Resolves when the re-INVITE request is sent, otherwise rejects.
+ * Use `onCallHold` delegate method to determine if request is accepted or rejected.
+ * See: https://tools.ietf.org/html/rfc6337
+ */
+ hold() {
+ this.logger.log(`[${this.id}] holding session...`);
+ return this.setHold(true);
+ }
+ /**
+ * Unhold call.
+ * @remarks
+ * Send a re-INVITE with new offer indicating "unhold".
+ * Resolves when the re-INVITE request is sent, otherwise rejects.
+ * Use `onCallHold` delegate method to determine if request is accepted or rejected.
+ * See: https://tools.ietf.org/html/rfc6337
+ */
+ unhold() {
+ this.logger.log(`[${this.id}] unholding session...`);
+ return this.setHold(false);
+ }
+ /**
+ * Hold state.
+ * @remarks
+ * True if session media is on hold.
+ */
+ isHeld() {
+ return this.held;
+ }
+ /**
+ * Mute call.
+ * @remarks
+ * Disable sender's media tracks.
+ */
+ mute() {
+ this.logger.log(`[${this.id}] disabling media tracks...`);
+ this.setMute(true);
+ }
+ /**
+ * Unmute call.
+ * @remarks
+ * Enable sender's media tracks.
+ */
+ unmute() {
+ this.logger.log(`[${this.id}] enabling media tracks...`);
+ this.setMute(false);
+ }
+ /**
+ * Mute state.
+ * @remarks
+ * True if sender's media track is disabled.
+ */
+ isMuted() {
+ const track = this.localAudioTrack || this.localVideoTrack;
+ return track ? !track.enabled : false;
+ }
+ /**
+ * Send DTMF.
+ * @remarks
+ * Send an INFO request with content type application/dtmf-relay.
+ * @param tone - Tone to send.
+ */
+ sendDTMF(tone) {
+ this.logger.log(`[${this.id}] sending DTMF...`);
+ // As RFC 6086 states, sending DTMF via INFO is not standardized...
+ //
+ // Companies have been using INFO messages in order to transport
+ // Dual-Tone Multi-Frequency (DTMF) tones. All mechanisms are
+ // proprietary and have not been standardized.
+ // https://tools.ietf.org/html/rfc6086#section-2
+ //
+ // It is however widely supported based on this draft:
+ // https://tools.ietf.org/html/draft-kaplan-dispatch-info-dtmf-package-00
+ // Validate tone
+ if (!/^[0-9A-D#*,]$/.exec(tone)) {
+ return Promise.reject(new Error("Invalid DTMF tone."));
+ }
+ if (!this.session) {
+ return Promise.reject(new Error("Session does not exist."));
+ }
+ // The UA MUST populate the "application/dtmf-relay" body, as defined
+ // earlier, with the button pressed and the duration it was pressed
+ // for. Technically, this actually requires the INFO to be generated
+ // when the user *releases* the button, however if the user has still
+ // not released a button after 5 seconds, which is the maximum duration
+ // supported by this mechanism, the UA should generate the INFO at that
+ // time.
+ // https://tools.ietf.org/html/draft-kaplan-dispatch-info-dtmf-package-00#section-5.3
+ this.logger.log(`[${this.id}] Sending DTMF tone: ${tone}`);
+ const dtmf = tone;
+ const duration = 2000;
+ const body = {
+ contentDisposition: "render",
+ contentType: "application/dtmf-relay",
+ content: "Signal=" + dtmf + "\r\nDuration=" + duration
+ };
+ const requestOptions = { body };
+ return this.session.info({ requestOptions }).then(() => {
+ return;
+ });
+ }
+ /**
+ * Send a message.
+ * @remarks
+ * Send a MESSAGE request.
+ * @param destination - The target destination for the message. A SIP address to send the MESSAGE to.
+ */
+ message(destination, message) {
+ this.logger.log(`[${this.id}] sending message...`);
+ const target = _api__WEBPACK_IMPORTED_MODULE_0__["UserAgent"].makeURI(destination);
+ if (!target) {
+ return Promise.reject(new Error(`Failed to create a valid URI from "${destination}"`));
+ }
+ return new _api__WEBPACK_IMPORTED_MODULE_0__["Messager"](this.userAgent, target, message).message();
+ }
+ /** Media constraints. */
+ get constraints() {
+ var _a;
+ let constraints = { audio: true, video: false }; // default to audio only calls
+ if ((_a = this.options.media) === null || _a === void 0 ? void 0 : _a.constraints) {
+ constraints = Object.assign({}, this.options.media.constraints);
+ }
+ return constraints;
+ }
+ /**
+ * Attempt reconnection up to `maxReconnectionAttempts` times.
+ * @param reconnectionAttempt - Current attempt number.
+ */
+ attemptReconnection(reconnectionAttempt = 1) {
+ const reconnectionAttempts = this.options.reconnectionAttempts || 3;
+ const reconnectionDelay = this.options.reconnectionDelay || 4;
+ if (!this.connectRequested) {
+ this.logger.log(`[${this.id}] Reconnection not currently desired`);
+ return; // If intentionally disconnected, don't reconnect.
+ }
+ if (this.attemptingReconnection) {
+ this.logger.log(`[${this.id}] Reconnection attempt already in progress`);
+ }
+ if (reconnectionAttempt > reconnectionAttempts) {
+ this.logger.log(`[${this.id}] Reconnection maximum attempts reached`);
+ return;
+ }
+ if (reconnectionAttempt === 1) {
+ this.logger.log(`[${this.id}] Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - trying`);
+ }
+ else {
+ this.logger.log(`[${this.id}] Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - trying in ${reconnectionDelay} seconds`);
+ }
+ this.attemptingReconnection = true;
+ setTimeout(() => {
+ if (!this.connectRequested) {
+ this.logger.log(`[${this.id}] Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - aborted`);
+ this.attemptingReconnection = false;
+ return; // If intentionally disconnected, don't reconnect.
+ }
+ this.userAgent
+ .reconnect()
+ .then(() => {
+ this.logger.log(`[${this.id}] Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - succeeded`);
+ this.attemptingReconnection = false;
+ })
+ .catch((error) => {
+ this.logger.log(`[${this.id}] Reconnection attempt ${reconnectionAttempt} of ${reconnectionAttempts} - failed`);
+ this.logger.error(error.message);
+ this.attemptingReconnection = false;
+ this.attemptReconnection(++reconnectionAttempt);
+ });
+ }, reconnectionAttempt === 1 ? 0 : reconnectionDelay * 1000);
+ }
+ /** Helper function to remove media from html elements. */
+ cleanupMedia() {
+ if (this.options.media) {
+ if (this.options.media.local) {
+ if (this.options.media.local.video) {
+ this.options.media.local.video.srcObject = null;
+ this.options.media.local.video.pause();
+ }
+ }
+ if (this.options.media.remote) {
+ if (this.options.media.remote.audio) {
+ this.options.media.remote.audio.srcObject = null;
+ this.options.media.remote.audio.pause();
+ }
+ if (this.options.media.remote.video) {
+ this.options.media.remote.video.srcObject = null;
+ this.options.media.remote.video.pause();
+ }
+ }
+ }
+ }
+ /** Helper function to enable/disable media tracks. */
+ enableSenderTracks(enable) {
+ if (!this.session) {
+ throw new Error("Session does not exist.");
+ }
+ const sessionDescriptionHandler = this.session.sessionDescriptionHandler;
+ if (!(sessionDescriptionHandler instanceof _session_description_handler__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandler"])) {
+ throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
+ }
+ const peerConnection = sessionDescriptionHandler.peerConnection;
+ if (!peerConnection) {
+ throw new Error("Peer connection closed.");
+ }
+ peerConnection.getSenders().forEach((sender) => {
+ if (sender.track) {
+ sender.track.enabled = enable;
+ }
+ });
+ }
+ /**
+ * Setup session delegate and state change handler.
+ * @param session - Session to setup
+ * @param referralInviterOptions - Options for any Inviter created as result of a REFER.
+ */
+ initSession(session, referralInviterOptions) {
+ // Set session
+ this.session = session;
+ // Call session created callback
+ if (this.delegate && this.delegate.onCallCreated) {
+ this.delegate.onCallCreated();
+ }
+ // Setup session state change handler
+ this.session.stateChange.addListener((state) => {
+ if (this.session !== session) {
+ return; // if our session has changed, just return
+ }
+ this.logger.log(`[${this.id}] session state changed to ${state}`);
+ switch (state) {
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Initial:
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Establishing:
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Established:
+ this.setupLocalMedia();
+ this.setupRemoteMedia();
+ if (this.delegate && this.delegate.onCallAnswered) {
+ this.delegate.onCallAnswered();
+ }
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Terminating:
+ // fall through
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Terminated:
+ this.session = undefined;
+ this.cleanupMedia();
+ if (this.delegate && this.delegate.onCallHangup) {
+ this.delegate.onCallHangup();
+ }
+ break;
+ default:
+ throw new Error("Unknown session state.");
+ }
+ });
+ // Setup delegate
+ this.session.delegate = {
+ onInfo: (info) => {
+ // As RFC 6086 states, sending DTMF via INFO is not standardized...
+ //
+ // Companies have been using INFO messages in order to transport
+ // Dual-Tone Multi-Frequency (DTMF) tones. All mechanisms are
+ // proprietary and have not been standardized.
+ // https://tools.ietf.org/html/rfc6086#section-2
+ //
+ // It is however widely supported based on this draft:
+ // https://tools.ietf.org/html/draft-kaplan-dispatch-info-dtmf-package-00
+ var _a;
+ // FIXME: TODO: We should reject correctly...
+ //
+ // If a UA receives an INFO request associated with an Info Package that
+ // the UA has not indicated willingness to receive, the UA MUST send a
+ // 469 (Bad Info Package) response (see Section 11.6), which contains a
+ // Recv-Info header field with Info Packages for which the UA is willing
+ // to receive INFO requests.
+ // https://tools.ietf.org/html/rfc6086#section-4.2.2
+ // No delegate
+ if (((_a = this.delegate) === null || _a === void 0 ? void 0 : _a.onCallDTMFReceived) === undefined) {
+ info.reject();
+ return;
+ }
+ // Invalid content type
+ const contentType = info.request.getHeader("content-type");
+ if (!contentType || !/^application\/dtmf-relay/i.exec(contentType)) {
+ info.reject();
+ return;
+ }
+ // Invalid body
+ const body = info.request.body.split("\r\n", 2);
+ if (body.length !== 2) {
+ info.reject();
+ return;
+ }
+ // Invalid tone
+ let tone;
+ const toneRegExp = /^(Signal\s*?=\s*?)([0-9A-D#*]{1})(\s)?.*/;
+ if (toneRegExp.test(body[0])) {
+ tone = body[0].replace(toneRegExp, "$2");
+ }
+ if (!tone) {
+ info.reject();
+ return;
+ }
+ // Invalid duration
+ let duration;
+ const durationRegExp = /^(Duration\s?=\s?)([0-9]{1,4})(\s)?.*/;
+ if (durationRegExp.test(body[1])) {
+ duration = parseInt(body[1].replace(durationRegExp, "$2"), 10);
+ }
+ if (!duration) {
+ info.reject();
+ return;
+ }
+ info
+ .accept()
+ .then(() => {
+ if (this.delegate && this.delegate.onCallDTMFReceived) {
+ if (!tone || !duration) {
+ throw new Error("Tone or duration undefined.");
+ }
+ this.delegate.onCallDTMFReceived(tone, duration);
+ }
+ })
+ .catch((error) => {
+ this.logger.error(error.message);
+ });
+ },
+ onRefer: (referral) => {
+ referral
+ .accept()
+ .then(() => this.sendInvite(referral.makeInviter(referralInviterOptions), referralInviterOptions))
+ .catch((error) => {
+ this.logger.error(error.message);
+ });
+ }
+ };
+ }
+ /** Helper function to init send then send invite. */
+ sendInvite(inviter, inviterOptions, inviterInviteOptions) {
+ // Initialize our session
+ this.initSession(inviter, inviterOptions);
+ // Send the INVITE
+ return inviter.invite(inviterInviteOptions).then(() => {
+ this.logger.log(`[${this.id}] sent INVITE`);
+ });
+ }
+ /**
+ * Puts Session on hold.
+ * @param hold - Hold on if true, off if false.
+ */
+ setHold(hold) {
+ if (!this.session) {
+ return Promise.reject(new Error("Session does not exist."));
+ }
+ // Just resolve if we are already in correct state
+ if (this.held === hold) {
+ return Promise.resolve();
+ }
+ const sessionDescriptionHandler = this.session.sessionDescriptionHandler;
+ if (!(sessionDescriptionHandler instanceof _session_description_handler__WEBPACK_IMPORTED_MODULE_2__["SessionDescriptionHandler"])) {
+ throw new Error("Session's session description handler not instance of SessionDescriptionHandler.");
+ }
+ const options = {
+ requestDelegate: {
+ onAccept: () => {
+ this.held = hold;
+ if (this.delegate && this.delegate.onCallHold) {
+ this.delegate.onCallHold(this.held);
+ }
+ },
+ onReject: () => {
+ this.logger.warn(`[${this.id}] re-invite request was rejected`);
+ if (this.delegate && this.delegate.onCallHold) {
+ this.delegate.onCallHold(this.held);
+ }
+ }
+ }
+ };
+ // Use hold modifier to produce the appropriate SDP offer to place call on hold
+ options.sessionDescriptionHandlerModifiers = hold ? [_modifiers__WEBPACK_IMPORTED_MODULE_1__["holdModifier"]] : [];
+ // Send re-INVITE
+ return this.session
+ .invite(options)
+ .then(() => {
+ this.enableSenderTracks(!hold); // mute/unmute
+ })
+ .catch((error) => {
+ if (error instanceof _api__WEBPACK_IMPORTED_MODULE_0__["RequestPendingError"]) {
+ this.logger.error(`[${this.id}] A hold request is already in progress.`);
+ }
+ throw error;
+ });
+ }
+ /**
+ * Puts Session on mute.
+ * @param mute - Mute on if true, off if false.
+ */
+ setMute(mute) {
+ if (!this.session) {
+ this.logger.warn(`[${this.id}] A session is required to enabled/disable media tracks`);
+ return;
+ }
+ if (this.session.state !== _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Established) {
+ this.logger.warn(`[${this.id}] An established session is required to enable/disable media tracks`);
+ return;
+ }
+ this.enableSenderTracks(!mute);
+ }
+ /** Helper function to attach local media to html elements. */
+ setupLocalMedia() {
+ var _a, _b;
+ if (!this.session) {
+ throw new Error("Session does not exist.");
+ }
+ const mediaElement = (_b = (_a = this.options.media) === null || _a === void 0 ? void 0 : _a.local) === null || _b === void 0 ? void 0 : _b.video;
+ if (mediaElement) {
+ const localStream = this.localMediaStream;
+ if (!localStream) {
+ throw new Error("Local media stream undefiend.");
+ }
+ mediaElement.srcObject = localStream;
+ mediaElement.volume = 0;
+ mediaElement.play().catch((error) => {
+ this.logger.error(`[${this.id}] Failed to play local media`);
+ this.logger.error(error.message);
+ });
+ }
+ }
+ /** Helper function to attach remote media to html elements. */
+ setupRemoteMedia() {
+ var _a, _b, _c, _d;
+ if (!this.session) {
+ throw new Error("Session does not exist.");
+ }
+ const mediaElement = ((_b = (_a = this.options.media) === null || _a === void 0 ? void 0 : _a.remote) === null || _b === void 0 ? void 0 : _b.video) || ((_d = (_c = this.options.media) === null || _c === void 0 ? void 0 : _c.remote) === null || _d === void 0 ? void 0 : _d.audio);
+ if (mediaElement) {
+ const remoteStream = this.remoteMediaStream;
+ if (!remoteStream) {
+ throw new Error("Remote media stream undefiend.");
+ }
+ mediaElement.autoplay = true; // Safari hack, because you cannot call .play() from a non user action
+ mediaElement.srcObject = remoteStream;
+ mediaElement.play().catch((error) => {
+ this.logger.error(`[${this.id}] Failed to play remote media`);
+ this.logger.error(error.message);
+ });
+ remoteStream.onaddtrack = () => {
+ this.logger.log(`[${this.id}] Remote media onaddtrack`);
+ mediaElement.load(); // Safari hack, as it doesn't work otheriwse
+ mediaElement.play().catch((error) => {
+ this.logger.error(`[${this.id}] Failed to play remote media`);
+ this.logger.error(error.message);
+ });
+ };
+ }
+ }
+ /**
+ * End a session.
+ * @remarks
+ * Send a BYE request, CANCEL request or reject response to end the current Session.
+ * Resolves when the request/response is sent, otherwise rejects.
+ * Use `onCallTerminated` delegate method to determine if and when Session is terminated.
+ */
+ terminate() {
+ this.logger.log(`[${this.id}] Terminating...`);
+ if (!this.session) {
+ return Promise.reject(new Error("Session does not exist."));
+ }
+ switch (this.session.state) {
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Initial:
+ if (this.session instanceof _api__WEBPACK_IMPORTED_MODULE_0__["Inviter"]) {
+ return this.session.cancel().then(() => {
+ this.logger.log(`[${this.id}] Inviter never sent INVITE (canceled)`);
+ });
+ }
+ else if (this.session instanceof _api__WEBPACK_IMPORTED_MODULE_0__["Invitation"]) {
+ return this.session.reject().then(() => {
+ this.logger.log(`[${this.id}] Invitation rejected (sent 480)`);
+ });
+ }
+ else {
+ throw new Error("Unknown session type.");
+ }
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Establishing:
+ if (this.session instanceof _api__WEBPACK_IMPORTED_MODULE_0__["Inviter"]) {
+ return this.session.cancel().then(() => {
+ this.logger.log(`[${this.id}] Inviter canceled (sent CANCEL)`);
+ });
+ }
+ else if (this.session instanceof _api__WEBPACK_IMPORTED_MODULE_0__["Invitation"]) {
+ return this.session.reject().then(() => {
+ this.logger.log(`[${this.id}] Invitation rejected (sent 480)`);
+ });
+ }
+ else {
+ throw new Error("Unknown session type.");
+ }
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Established:
+ return this.session.bye().then(() => {
+ this.logger.log(`[${this.id}] Session ended (sent BYE)`);
+ });
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Terminating:
+ break;
+ case _api__WEBPACK_IMPORTED_MODULE_0__["SessionState"].Terminated:
+ break;
+ default:
+ throw new Error("Unknown state");
+ }
+ this.logger.log(`[${this.id}] Terminating in state ${this.session.state}, no action taken`);
+ return Promise.resolve();
+ }
+}
+
+
+/***/ }),
+/* 179 */
+/***/ (function(module, exports) {
+
+
+
+/***/ }),
+/* 180 */
+/***/ (function(module, exports) {
+
+
+
+/***/ })
+/******/ ]);
+});
+
diff --git a/src/2.7.12/public/compatibility/tflite-simd.js b/src/2.7.12/public/compatibility/tflite-simd.js
new file mode 100644
index 00000000..fc32f907
--- /dev/null
+++ b/src/2.7.12/public/compatibility/tflite-simd.js
@@ -0,0 +1,32 @@
+/*
+ * NOTICE: This file is a Derivative Work of the original component in volcomix/virtual-background
+ * See: https://github.com/Volcomix/virtual-background
+ * It is copied under the Apache Public License 2.0 (see https://www.apache.org/licenses/LICENSE-2.0).
+ */
+ /*
+ * Changes in this file
+ * - prlanzarin: manually correct the wasm directory patch fetch; need to find a better way to do this
+ * scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("/")+1
+ *
+ * scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("html5client/")+"html5client/".length)+"wasm/"
+ */
+var createTFLiteSIMDModule = (function() {
+ var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
+
+ return (
+function(createTFLiteSIMDModule) {
+ createTFLiteSIMDModule = createTFLiteSIMDModule || {};
+
+var Module=typeof createTFLiteSIMDModule!=="undefined"?createTFLiteSIMDModule:{};var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise(function(resolve,reject){readyPromiseResolve=resolve;readyPromiseReject=reject});var moduleOverrides={};var key;for(key in Module){if(Module.hasOwnProperty(key)){moduleOverrides[key]=Module[key]}}var arguments_=[];var thisProgram="./this.program";var quit_=function(status,toThrow){throw toThrow};var ENVIRONMENT_IS_WEB=true;var ENVIRONMENT_IS_WORKER=false;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!=="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("html5client/")+"html5client/".length)+"wasm/"}else{scriptDirectory=""}{read_=function(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=function(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=function(url,onload,onerror){var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=function(){if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=function(title){document.title=title}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.warn.bind(console);for(key in moduleOverrides){if(moduleOverrides.hasOwnProperty(key)){Module[key]=moduleOverrides[key]}}moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!=="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;var UTF8Decoder=typeof TextDecoder!=="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(heap,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heap[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heap.subarray&&UTF8Decoder){return UTF8Decoder.decode(heap.subarray(idx,endPtr))}else{var str="";while(idx>10,56320|ch&1023)}}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""}function writeAsciiToMemory(str,buffer,dontAddNull){for(var i=0;i>0]=str.charCodeAt(i)}if(!dontAddNull)HEAP8[buffer>>0]=0}function alignUp(x,multiple){if(x%multiple>0){x+=multiple-x%multiple}return x}var buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBufferAndViews(buf){buffer=buf;Module["HEAP8"]=HEAP8=new Int8Array(buf);Module["HEAP16"]=HEAP16=new Int16Array(buf);Module["HEAP32"]=HEAP32=new Int32Array(buf);Module["HEAPU8"]=HEAPU8=new Uint8Array(buf);Module["HEAPU16"]=HEAPU16=new Uint16Array(buf);Module["HEAPU32"]=HEAPU32=new Uint32Array(buf);Module["HEAPF32"]=HEAPF32=new Float32Array(buf);Module["HEAPF64"]=HEAPF64=new Float64Array(buf)}var INITIAL_MEMORY=Module["INITIAL_MEMORY"]||16777216;var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeExited=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function exitRuntime(){runtimeExited=true}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}Module["preloadedImages"]={};Module["preloadedAudios"]={};function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what+="";err(what);ABORT=true;EXITSTATUS=1;what="abort("+what+"). Build with -s ASSERTIONS=1 for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile="tflite-simd.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}else{throw"both async and sync fetching of the wasm failed"}}catch(err){abort(err)}}function getBinaryPromise(){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch==="function"){return fetch(wasmBinaryFile,{credentials:"same-origin"}).then(function(response){if(!response["ok"]){throw"failed to load wasm binary file at '"+wasmBinaryFile+"'"}return response["arrayBuffer"]()}).catch(function(){return getBinary(wasmBinaryFile)})}}return Promise.resolve().then(function(){return getBinary(wasmBinaryFile)})}function createWasm(){var info={"a":asmLibraryArg};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["o"];updateGlobalBufferAndViews(wasmMemory.buffer);wasmTable=Module["asm"]["B"];addOnInit(Module["asm"]["p"]);removeRunDependency("wasm-instantiate")}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}function instantiateArrayBuffer(receiver){return getBinaryPromise().then(function(binary){return WebAssembly.instantiate(binary,info)}).then(function(instance){return instance}).then(receiver,function(reason){err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(){if(!wasmBinary&&typeof WebAssembly.instantiateStreaming==="function"&&!isDataURI(wasmBinaryFile)&&typeof fetch==="function"){return fetch(wasmBinaryFile,{credentials:"same-origin"}).then(function(response){var result=WebAssembly.instantiateStreaming(response,info);return result.then(receiveInstantiationResult,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(receiveInstantiationResult)})})}else{return instantiateArrayBuffer(receiveInstantiationResult)}}if(Module["instantiateWasm"]){try{var exports=Module["instantiateWasm"](info,receiveInstance);return exports}catch(e){err("Module.instantiateWasm callback failed with error: "+e);return false}}instantiateAsync().catch(readyPromiseReject);return{}}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){var callback=callbacks.shift();if(typeof callback=="function"){callback(Module);continue}var func=callback.func;if(typeof func==="number"){if(callback.arg===undefined){wasmTable.get(func)()}else{wasmTable.get(func)(callback.arg)}}else{func(callback.arg===undefined?null:callback.arg)}}}function _abort(){abort()}var _emscripten_get_now;_emscripten_get_now=function(){return performance.now()};var _emscripten_get_now_is_monotonic=true;function setErrNo(value){HEAP32[___errno_location()>>2]=value;return value}function _clock_gettime(clk_id,tp){var now;if(clk_id===0){now=Date.now()}else if((clk_id===1||clk_id===4)&&_emscripten_get_now_is_monotonic){now=_emscripten_get_now()}else{setErrNo(28);return-1}HEAP32[tp>>2]=now/1e3|0;HEAP32[tp+4>>2]=now%1e3*1e3*1e3|0;return 0}function _dlopen(filename,flag){abort("To use dlopen, you need to use Emscripten's linking support, see https://github.com/emscripten-core/emscripten/wiki/Linking")}function _dlsym(handle,symbol){abort("To use dlopen, you need to use Emscripten's linking support, see https://github.com/emscripten-core/emscripten/wiki/Linking")}function _emscripten_get_heap_max(){return 2147483648}function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){try{wasmMemory.grow(size-buffer.byteLength+65535>>>16);updateGlobalBufferAndViews(wasmMemory.buffer);return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=2147483648;if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}function _emscripten_thread_sleep(msecs){var start=_emscripten_get_now();while(_emscripten_get_now()-start>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},get64:function(low,high){return low}};function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAP32[__environ+i*4>>2]=ptr;writeAsciiToMemory(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAP32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAP32[penviron_buf_size>>2]=bufSize;return 0}function _exit(status){exit(status)}function _fd_close(fd){return 0}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){}function _fd_write(fd,iov,iovcnt,pnum){var num=0;for(var i=0;i>2];var len=HEAP32[iov+(i*8+4)>>2];for(var j=0;j>2]=num;return 0}var asmLibraryArg={"a":_abort,"m":_clock_gettime,"i":_dlopen,"c":_dlsym,"g":_emscripten_get_heap_max,"k":_emscripten_memcpy_big,"l":_emscripten_resize_heap,"d":_emscripten_thread_sleep,"e":_environ_get,"f":_environ_sizes_get,"n":_exit,"h":_fd_close,"j":_fd_seek,"b":_fd_write};var asm=createWasm();var ___wasm_call_ctors=Module["___wasm_call_ctors"]=function(){return(___wasm_call_ctors=Module["___wasm_call_ctors"]=Module["asm"]["p"]).apply(null,arguments)};var _getModelBufferMemoryOffset=Module["_getModelBufferMemoryOffset"]=function(){return(_getModelBufferMemoryOffset=Module["_getModelBufferMemoryOffset"]=Module["asm"]["q"]).apply(null,arguments)};var _getInputMemoryOffset=Module["_getInputMemoryOffset"]=function(){return(_getInputMemoryOffset=Module["_getInputMemoryOffset"]=Module["asm"]["r"]).apply(null,arguments)};var _getInputHeight=Module["_getInputHeight"]=function(){return(_getInputHeight=Module["_getInputHeight"]=Module["asm"]["s"]).apply(null,arguments)};var _getInputWidth=Module["_getInputWidth"]=function(){return(_getInputWidth=Module["_getInputWidth"]=Module["asm"]["t"]).apply(null,arguments)};var _getInputChannelCount=Module["_getInputChannelCount"]=function(){return(_getInputChannelCount=Module["_getInputChannelCount"]=Module["asm"]["u"]).apply(null,arguments)};var _getOutputMemoryOffset=Module["_getOutputMemoryOffset"]=function(){return(_getOutputMemoryOffset=Module["_getOutputMemoryOffset"]=Module["asm"]["v"]).apply(null,arguments)};var _getOutputHeight=Module["_getOutputHeight"]=function(){return(_getOutputHeight=Module["_getOutputHeight"]=Module["asm"]["w"]).apply(null,arguments)};var _getOutputWidth=Module["_getOutputWidth"]=function(){return(_getOutputWidth=Module["_getOutputWidth"]=Module["asm"]["x"]).apply(null,arguments)};var _getOutputChannelCount=Module["_getOutputChannelCount"]=function(){return(_getOutputChannelCount=Module["_getOutputChannelCount"]=Module["asm"]["y"]).apply(null,arguments)};var _loadModel=Module["_loadModel"]=function(){return(_loadModel=Module["_loadModel"]=Module["asm"]["z"]).apply(null,arguments)};var _runInference=Module["_runInference"]=function(){return(_runInference=Module["_runInference"]=Module["asm"]["A"]).apply(null,arguments)};var ___errno_location=Module["___errno_location"]=function(){return(___errno_location=Module["___errno_location"]=Module["asm"]["C"]).apply(null,arguments)};var calledRun;function ExitStatus(status){this.name="ExitStatus";this.message="Program terminated with exit("+status+")";this.status=status}dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(args){args=args||arguments_;if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}Module["run"]=run;function exit(status,implicit){EXITSTATUS=status;if(keepRuntimeAlive()){}else{exitRuntime()}procExit(status)}function procExit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module["onExit"])Module["onExit"](code);ABORT=true}quit_(code,new ExitStatus(code))}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();
+
+
+ return createTFLiteSIMDModule.ready
+}
+);
+})();
+if (typeof exports === 'object' && typeof module === 'object')
+ module.exports = createTFLiteSIMDModule;
+else if (typeof define === 'function' && define['amd'])
+ define([], function() { return createTFLiteSIMDModule; });
+else if (typeof exports === 'object')
+ exports["createTFLiteSIMDModule"] = createTFLiteSIMDModule;
diff --git a/src/2.7.12/public/compatibility/tflite.js b/src/2.7.12/public/compatibility/tflite.js
new file mode 100644
index 00000000..2fa75daa
--- /dev/null
+++ b/src/2.7.12/public/compatibility/tflite.js
@@ -0,0 +1,32 @@
+/*
+ * NOTICE: This file is a Derivative Work of the original component in volcomix/virtual-background
+ * See: https://github.com/Volcomix/virtual-background
+ * It is copied under the Apache Public License 2.0 (see https://www.apache.org/licenses/LICENSE-2.0).
+ */
+ /*
+ * Changes in this file
+ * - prlanzarin: manually correct the wasm directory patch fetch; need to find a better way to do this
+ * scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("/")+1
+ *
+ * scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("html5client/")+"html5client/".length)+"wasm/"
+ */
+var createTFLiteModule = (function() {
+ var _scriptDir = typeof document !== 'undefined' && document.currentScript ? document.currentScript.src : undefined;
+
+ return (
+function(createTFLiteModule) {
+ createTFLiteModule = createTFLiteModule || {};
+
+var Module=typeof createTFLiteModule!=="undefined"?createTFLiteModule:{};var readyPromiseResolve,readyPromiseReject;Module["ready"]=new Promise(function(resolve,reject){readyPromiseResolve=resolve;readyPromiseReject=reject});var moduleOverrides={};var key;for(key in Module){if(Module.hasOwnProperty(key)){moduleOverrides[key]=Module[key]}}var arguments_=[];var thisProgram="./this.program";var quit_=function(status,toThrow){throw toThrow};var ENVIRONMENT_IS_WEB=true;var ENVIRONMENT_IS_WORKER=false;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var read_,readAsync,readBinary,setWindowTitle;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!=="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptDir){scriptDirectory=_scriptDir}if(scriptDirectory.indexOf("blob:")!==0){scriptDirectory=scriptDirectory.substr(0,scriptDirectory.lastIndexOf("html5client/")+"html5client/".length)+"wasm/"}else{scriptDirectory=""}{read_=function(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.send(null);return xhr.responseText};if(ENVIRONMENT_IS_WORKER){readBinary=function(url){var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=function(url,onload,onerror){var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=function(){if(xhr.status==200||xhr.status==0&&xhr.response){onload(xhr.response);return}onerror()};xhr.onerror=onerror;xhr.send(null)}}setWindowTitle=function(title){document.title=title}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.warn.bind(console);for(key in moduleOverrides){if(moduleOverrides.hasOwnProperty(key)){Module[key]=moduleOverrides[key]}}moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["quit"])quit_=Module["quit"];var wasmBinary;if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];var noExitRuntime=Module["noExitRuntime"]||true;if(typeof WebAssembly!=="object"){abort("no native wasm support detected")}var wasmMemory;var ABORT=false;var EXITSTATUS;var UTF8Decoder=typeof TextDecoder!=="undefined"?new TextDecoder("utf8"):undefined;function UTF8ArrayToString(heap,idx,maxBytesToRead){var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heap[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heap.subarray&&UTF8Decoder){return UTF8Decoder.decode(heap.subarray(idx,endPtr))}else{var str="";while(idx>10,56320|ch&1023)}}}return str}function UTF8ToString(ptr,maxBytesToRead){return ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):""}function writeAsciiToMemory(str,buffer,dontAddNull){for(var i=0;i>0]=str.charCodeAt(i)}if(!dontAddNull)HEAP8[buffer>>0]=0}function alignUp(x,multiple){if(x%multiple>0){x+=multiple-x%multiple}return x}var buffer,HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateGlobalBufferAndViews(buf){buffer=buf;Module["HEAP8"]=HEAP8=new Int8Array(buf);Module["HEAP16"]=HEAP16=new Int16Array(buf);Module["HEAP32"]=HEAP32=new Int32Array(buf);Module["HEAPU8"]=HEAPU8=new Uint8Array(buf);Module["HEAPU16"]=HEAPU16=new Uint16Array(buf);Module["HEAPU32"]=HEAPU32=new Uint32Array(buf);Module["HEAPF32"]=HEAPF32=new Float32Array(buf);Module["HEAPF64"]=HEAPF64=new Float64Array(buf)}var INITIAL_MEMORY=Module["INITIAL_MEMORY"]||16777216;var wasmTable;var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;var runtimeExited=false;var runtimeKeepaliveCounter=0;function keepRuntimeAlive(){return noExitRuntime||runtimeKeepaliveCounter>0}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function exitRuntime(){runtimeExited=true}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}}function removeRunDependency(id){runDependencies--;if(Module["monitorRunDependencies"]){Module["monitorRunDependencies"](runDependencies)}if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}Module["preloadedImages"]={};Module["preloadedAudios"]={};function abort(what){if(Module["onAbort"]){Module["onAbort"](what)}what+="";err(what);ABORT=true;EXITSTATUS=1;what="abort("+what+"). Build with -s ASSERTIONS=1 for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";function isDataURI(filename){return filename.startsWith(dataURIPrefix)}var wasmBinaryFile;wasmBinaryFile="tflite.wasm";if(!isDataURI(wasmBinaryFile)){wasmBinaryFile=locateFile(wasmBinaryFile)}function getBinary(file){try{if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}else{throw"both async and sync fetching of the wasm failed"}}catch(err){abort(err)}}function getBinaryPromise(){if(!wasmBinary&&(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER)){if(typeof fetch==="function"){return fetch(wasmBinaryFile,{credentials:"same-origin"}).then(function(response){if(!response["ok"]){throw"failed to load wasm binary file at '"+wasmBinaryFile+"'"}return response["arrayBuffer"]()}).catch(function(){return getBinary(wasmBinaryFile)})}}return Promise.resolve().then(function(){return getBinary(wasmBinaryFile)})}function createWasm(){var info={"a":asmLibraryArg};function receiveInstance(instance,module){var exports=instance.exports;Module["asm"]=exports;wasmMemory=Module["asm"]["o"];updateGlobalBufferAndViews(wasmMemory.buffer);wasmTable=Module["asm"]["B"];addOnInit(Module["asm"]["p"]);removeRunDependency("wasm-instantiate")}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}function instantiateArrayBuffer(receiver){return getBinaryPromise().then(function(binary){return WebAssembly.instantiate(binary,info)}).then(function(instance){return instance}).then(receiver,function(reason){err("failed to asynchronously prepare wasm: "+reason);abort(reason)})}function instantiateAsync(){if(!wasmBinary&&typeof WebAssembly.instantiateStreaming==="function"&&!isDataURI(wasmBinaryFile)&&typeof fetch==="function"){return fetch(wasmBinaryFile,{credentials:"same-origin"}).then(function(response){var result=WebAssembly.instantiateStreaming(response,info);return result.then(receiveInstantiationResult,function(reason){err("wasm streaming compile failed: "+reason);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(receiveInstantiationResult)})})}else{return instantiateArrayBuffer(receiveInstantiationResult)}}if(Module["instantiateWasm"]){try{var exports=Module["instantiateWasm"](info,receiveInstance);return exports}catch(e){err("Module.instantiateWasm callback failed with error: "+e);return false}}instantiateAsync().catch(readyPromiseReject);return{}}function callRuntimeCallbacks(callbacks){while(callbacks.length>0){var callback=callbacks.shift();if(typeof callback=="function"){callback(Module);continue}var func=callback.func;if(typeof func==="number"){if(callback.arg===undefined){wasmTable.get(func)()}else{wasmTable.get(func)(callback.arg)}}else{func(callback.arg===undefined?null:callback.arg)}}}function _abort(){abort()}var _emscripten_get_now;_emscripten_get_now=function(){return performance.now()};var _emscripten_get_now_is_monotonic=true;function setErrNo(value){HEAP32[___errno_location()>>2]=value;return value}function _clock_gettime(clk_id,tp){var now;if(clk_id===0){now=Date.now()}else if((clk_id===1||clk_id===4)&&_emscripten_get_now_is_monotonic){now=_emscripten_get_now()}else{setErrNo(28);return-1}HEAP32[tp>>2]=now/1e3|0;HEAP32[tp+4>>2]=now%1e3*1e3*1e3|0;return 0}function _dlopen(filename,flag){abort("To use dlopen, you need to use Emscripten's linking support, see https://github.com/emscripten-core/emscripten/wiki/Linking")}function _dlsym(handle,symbol){abort("To use dlopen, you need to use Emscripten's linking support, see https://github.com/emscripten-core/emscripten/wiki/Linking")}function _emscripten_get_heap_max(){return 2147483648}function _emscripten_memcpy_big(dest,src,num){HEAPU8.copyWithin(dest,src,src+num)}function emscripten_realloc_buffer(size){try{wasmMemory.grow(size-buffer.byteLength+65535>>>16);updateGlobalBufferAndViews(wasmMemory.buffer);return 1}catch(e){}}function _emscripten_resize_heap(requestedSize){var oldSize=HEAPU8.length;requestedSize=requestedSize>>>0;var maxHeapSize=2147483648;if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignUp(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=emscripten_realloc_buffer(newSize);if(replacement){return true}}return false}function _emscripten_thread_sleep(msecs){var start=_emscripten_get_now();while(_emscripten_get_now()-start>2];return ret},getStr:function(ptr){var ret=UTF8ToString(ptr);return ret},get64:function(low,high){return low}};function _environ_get(__environ,environ_buf){var bufSize=0;getEnvStrings().forEach(function(string,i){var ptr=environ_buf+bufSize;HEAP32[__environ+i*4>>2]=ptr;writeAsciiToMemory(string,ptr);bufSize+=string.length+1});return 0}function _environ_sizes_get(penviron_count,penviron_buf_size){var strings=getEnvStrings();HEAP32[penviron_count>>2]=strings.length;var bufSize=0;strings.forEach(function(string){bufSize+=string.length+1});HEAP32[penviron_buf_size>>2]=bufSize;return 0}function _exit(status){exit(status)}function _fd_close(fd){return 0}function _fd_seek(fd,offset_low,offset_high,whence,newOffset){}function _fd_write(fd,iov,iovcnt,pnum){var num=0;for(var i=0;i>2];var len=HEAP32[iov+(i*8+4)>>2];for(var j=0;j>2]=num;return 0}var asmLibraryArg={"a":_abort,"m":_clock_gettime,"i":_dlopen,"c":_dlsym,"g":_emscripten_get_heap_max,"k":_emscripten_memcpy_big,"l":_emscripten_resize_heap,"d":_emscripten_thread_sleep,"e":_environ_get,"f":_environ_sizes_get,"n":_exit,"h":_fd_close,"j":_fd_seek,"b":_fd_write};var asm=createWasm();var ___wasm_call_ctors=Module["___wasm_call_ctors"]=function(){return(___wasm_call_ctors=Module["___wasm_call_ctors"]=Module["asm"]["p"]).apply(null,arguments)};var _getModelBufferMemoryOffset=Module["_getModelBufferMemoryOffset"]=function(){return(_getModelBufferMemoryOffset=Module["_getModelBufferMemoryOffset"]=Module["asm"]["q"]).apply(null,arguments)};var _getInputMemoryOffset=Module["_getInputMemoryOffset"]=function(){return(_getInputMemoryOffset=Module["_getInputMemoryOffset"]=Module["asm"]["r"]).apply(null,arguments)};var _getInputHeight=Module["_getInputHeight"]=function(){return(_getInputHeight=Module["_getInputHeight"]=Module["asm"]["s"]).apply(null,arguments)};var _getInputWidth=Module["_getInputWidth"]=function(){return(_getInputWidth=Module["_getInputWidth"]=Module["asm"]["t"]).apply(null,arguments)};var _getInputChannelCount=Module["_getInputChannelCount"]=function(){return(_getInputChannelCount=Module["_getInputChannelCount"]=Module["asm"]["u"]).apply(null,arguments)};var _getOutputMemoryOffset=Module["_getOutputMemoryOffset"]=function(){return(_getOutputMemoryOffset=Module["_getOutputMemoryOffset"]=Module["asm"]["v"]).apply(null,arguments)};var _getOutputHeight=Module["_getOutputHeight"]=function(){return(_getOutputHeight=Module["_getOutputHeight"]=Module["asm"]["w"]).apply(null,arguments)};var _getOutputWidth=Module["_getOutputWidth"]=function(){return(_getOutputWidth=Module["_getOutputWidth"]=Module["asm"]["x"]).apply(null,arguments)};var _getOutputChannelCount=Module["_getOutputChannelCount"]=function(){return(_getOutputChannelCount=Module["_getOutputChannelCount"]=Module["asm"]["y"]).apply(null,arguments)};var _loadModel=Module["_loadModel"]=function(){return(_loadModel=Module["_loadModel"]=Module["asm"]["z"]).apply(null,arguments)};var _runInference=Module["_runInference"]=function(){return(_runInference=Module["_runInference"]=Module["asm"]["A"]).apply(null,arguments)};var ___errno_location=Module["___errno_location"]=function(){return(___errno_location=Module["___errno_location"]=Module["asm"]["C"]).apply(null,arguments)};var calledRun;function ExitStatus(status){this.name="ExitStatus";this.message="Program terminated with exit("+status+")";this.status=status}dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(args){args=args||arguments_;if(runDependencies>0){return}preRun();if(runDependencies>0){return}function doRun(){if(calledRun)return;calledRun=true;Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve(Module);if(Module["onRuntimeInitialized"])Module["onRuntimeInitialized"]();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(function(){setTimeout(function(){Module["setStatus"]("")},1);doRun()},1)}else{doRun()}}Module["run"]=run;function exit(status,implicit){EXITSTATUS=status;if(keepRuntimeAlive()){}else{exitRuntime()}procExit(status)}function procExit(code){EXITSTATUS=code;if(!keepRuntimeAlive()){if(Module["onExit"])Module["onExit"](code);ABORT=true}quit_(code,new ExitStatus(code))}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();
+
+
+ return createTFLiteModule.ready
+}
+);
+})();
+if (typeof exports === 'object' && typeof module === 'object')
+ module.exports = createTFLiteModule;
+else if (typeof define === 'function' && define['amd'])
+ define([], function() { return createTFLiteModule; });
+else if (typeof exports === 'object')
+ exports["createTFLiteModule"] = createTFLiteModule;
diff --git a/src/2.7.12/public/fonts/BbbIcons/bbb-icons.woff b/src/2.7.12/public/fonts/BbbIcons/bbb-icons.woff
new file mode 100644
index 0000000000000000000000000000000000000000..b491aaa7b4d334abb0a6243800a2e5337fc33d9d
GIT binary patch
literal 21276
zcmZTvbBrf2w;kKu*|BZg){br4wr$(yZ)}@8Hg;^AZ@%xn|6Y^Rn{#s8rcKl2-nMS?
zVq)^j%JM)!N;p8UKtIK{3kdi>_Wu`QF);!lAkbAHAUPu-ATyO7`omT+F=bgGAVvKj
zZ=C-iIY+5eTtrmtN8|b7L_Z({A_q#4SE6SE0s=Pw;Zi@)S8dT!G_oPKV!0o$*L$uILC@kbN*;RHV*fszKPFt>4b{~5#khv)v_fRG&)
z-p0WFr$1o*9}e+fSp5OAHLx-H(VBk1{xgPaN)sxAy`7Wu&pd{Hd{BQt@ZAc;?4E6;
zZ>+BmwD<607q|av)5B!n$l%|X(n1VQ@D}iiJpS$Xja^S;_5|d}`72$Z5Xh<=9{i{A
z|FwI1vFk$`B)a{+{+38RgcCqU>q7uam;!$X0{R04Wc9y(24B-00Rbri0TyHnSP&4Q
zzEIOInf>Vrs1y@@6H`E-(I9Y8NbyIR1AqJSZ8A%&>jnc%(|mo#W1(<8QcKm^%Gm%d}U#%u_de9>K6(UU+*@IUOsRzVL`U&hfI_p48(4`FHYO`&vQu8XRqO};Z2Lkfi^-FX
zVzQ6fgzie@q{z)j+HLkE%gmNZC@FHOGUAq&k5DqG8{c
z3Xuw?Ln`G^`X$T7N~Jo%BC=`CCd-AZw4JY)D&aD^EzgIu1-rg{!a=d<-^{iH#gq%C
zQ<=*4rjg0$8_2qj+Y}f+|PJCo-jv-_z?VY
zbyqvQy1)-2_z?fygQh@1-&((-vFd?Ngu{Pm;TG0kcAiRRd&XO{CgR9ZqxYL|n~#PljN!-F(3
zgLHTp{1tjLAB^rFr-ON*`L4)5!CW%M7ny~O;~wCEWNkNCODauf;N?xWVNtB{N@gA9
zGr2b@kKth^I9TgC8Sd>d2fBe9Jq|%yeex`k3{Lu2g1y=RFZ4c2DG5(51FwPUKz6Vi
z$~>wg>K$q%3Kgmh^b?q$knlihFfa5w3IZvPBuC1P+`w=!J*uxnxC76#)s^cNoh{t6
zmo3vX!!sOvn%01cy;Zw+Z94UFB?qzDzGWMPEpcm>R}%NM&Th{?-`!d`p&(yY3YWi^
zL1GBMdCkxAk0GY9li4VI{ygP32PFqB23ZGLLcBt-!bQTZ^PR*9l0)X;Kb+>%Lw@oy
z5<0l=oeWV%%#zH=YA3X@0caG`%w?KL*+|4t+{k>B-6?NJy8>CkNr$mQLOj|tu!h^g
zU(aD9(LzMQe++s_yGCT-L*T^)N=adPF#~T=dAWc}VqVEuFs8qi9Tfbiypz_)zHi4zhM_&7=J_@?kG9
zuqy!NqNR<3o!D=>PdI_7@nuJTII>~yMb?$Bv$V>wF;av5o+)2Vz*Mk+i2sYPj=)KW
zP}}i-#h5brJQ5ZLQi_5(3Jr#Uh`#Pyu7ZLtRAyo?JMpn9|nZ(aW5)k-lQVjtNw9|lQ^5?vldQ$O#G7|msb-utkm*_ln!}kk;M?mzimHWz0
z-=p|4aH{=fM-NFI2fI#6l2*04PP{_$<8^0YVBq6KqPG|&Lq$FXwVpyX6-8iJbs%txAw=#zkw%_ElblBRn0i{S+MmP{iV1~-03qWbxdUSiv{Y&*n8-+Jrl^$4=t6IO
zlyEk^j2a55xTv`1yt+StH?$D&_SfM^qQ4<8I|A6C*=CMCVhEk|G!$S@0T%+gm2Hv6
zC1$2*Oz2Z%N@(bqrUy0h^orHsH3x&1nGvBf(T)pVzf~)|7G@*G~3r~lG!q^
zjMzv@9JM%Zv|Vl@*pj!xMlV@y;@Q$abG628%y#$V?jksb_4(r-t+x|zDqQ_?%jWLS
zIpAt*ZBx=F|JA~*jp>%^mBl^k;&g3)+>UppbH#h*a|L=O%;1UH9@iGvE|0R_#&7R;
zz<0>E|25<@u=B=JQ?D^#~N&+PuGpU6Y&A=z-Q=JruZbJ$8b8tVG(IZ)RM
z_fEpfvB~toZIF0#c=^25^W7@#x8XBN@3uUCl}eAN^3lm3m&^IKnqGI6!QeX9efn(s
z`f0O2wU2#emBC;(P=s+juFSH;^%x|M!ISxBzGI}v
z^L5BFh}xL9`h0LNkC&y(FZ{KbUnVxI&8_79R%XWMG4nbwkNI17^HFi&qVC9frg_$B
z_WQ_W2}M|QF!EWIv{v)AE_qd$@}b0SXk#YaEocA3jyw_aly)F;$|vk_PI8|8`I4LF
zU3>n*%Dj9fnEC)w8qecji1>kzQZZtl->v*j>F~np-*VWL@nJKGK!3(HVOQ&o(36b=
zo9zoWuISa^7`Db?n9b4BP{zrOZu?
z)?urAeflS^O}b>JDlP35Tw6pphnTIcOYx13jbT)si0;OsI9N3l5)bu?ro)0BGz;A-
z$Fyp8!G`W;mrkBcqODr%>7)9Qb}#hPtF#(b#}cjf@qwNcVuG7BvN}R
z!bwR!Ofv8TheSj_@Q6X!!Gmaq>#;j#9r4&~&c=_iO^?a6*`x?!vHB+FPmBAv1+dg*
z7g`%SY3Wx>c}Ps(EVyej?=*A1q*X1VFIfh6XI3)q4yN?lK?A;O=wz$K-!aiMsg=Si
z|Na>1bi=)m-SA}3%$}()ZY_C~YmwQOf?Suo8uqSSI<`ANMIci?sl7kyF0#`dVcfw4
zD0ju&gnTsMBuSBpJrabkf)ka!Z;QHnJ>(g~=b>DnpzCNCOJ7_#<`Of{l0DIViu__t
zKRAZqP>YhNV-h!R`di?lF7P(gii@Iby&TBF;lPP(QGhr#{}IG>cWGj%wzQX4Xf;
z4Lcp*i4}<=8ZxhB=3unW+1(2B=4z%bC`dyj2>v2=2qeLQ2|3&ovDnF9tqCkg;>MIP
z+Be+yaM*MHyL7dAj34$f
zCq8_CND!nwi)4^7K&BX{JxQ*3-vT9N&;v$LS!!g$0%DCSzSzzm3|jK*z?Lf=!Aqa&
zkdpr}&2K_+tb(6JY&3ESWJ_H4I7YWyIxYYj!|3jpr@v6-+yu&l9B8q-tZx36QG$e$NN7>n7=-4KR}76B(7TRq-RS;}0X~BIrx?9d!F{KsD&jaH
zad=4me~n
zINi8$vmim}Vt1Z!;{>?veFx8aeB>hMqEb29D7U9(Tc$ftGpBHY(tbT;s75*>u8llGAA#D9AdrcRN8@JCOEQD$
zH!oa^@9`jy9z}R=?=Ey?JTk6Fiv{&p58v!Ec*aQJV0lrpF}0-M28=6XskRy>&o27@j*V+{-qtA;N8#3+hIqI6#D+|WlGVU?GUht8bmu)m(
zv);jFpjik#m{e325N+Z>v5zx&WGE|wU$ZW0ocpWwXr^r(#>UO&6PiB|Xo{#t3NzqC
z2hbGl7q^6y6%Ux(?1&UxLppdqKuYc)@($3?;fqNs74FMB6d*XxGAg)5Z#wY+o^9`1
z{3R)UQoAIga9DQ4@`{*coAhTYn`d{;ZYKUl+EhK$pNw~hD~w$ujt>Lo^ZD`;tU
zEi4J><0B3b?r=xEE$7!Ps-;hd%|o6^7SV-IxhS!N#mgBNtcU
z(VH7n^`+o!#=hnjWEcyi#UE(>LSGbaG0`ofbcOq2enA*;^D=rhgO8K%K;Z9RGPB#V
ztZ7A~9<(G@Md-6mJV|E20?zMA^ND!mzpN8NyQ*meP}87U_f1&Qk5NAzw+!X$?EjZ++2ALtBum}JL`B6G<@K_r4|foR_%tjNpLO3)g{4pKT`+z%@sDO
zOd4ypwx~u$)hp`h)+(Q^?DGVlhBV>zcq@$`@o0xf$ylnWq}1YI?mDVD=n#(Z&zI59
z6TX}I+&!2?j|o9z-A>LL2Wxv*^oCplx(8m4%i_}JLK2T}H)pz@ER4sxjk!if;#r}x
z;XS(^^Q##s9#5EX$2(nIQn}!2+m|mNym)+%5=JB)#;~3SjruMJOz`C5$+TqE-ifR|`GBTA1*@bbCHcP}yuRB1N!NsxWyfRw7gm#`K4Z
zVR_K$lAwe>B+udMi?ECisqhZ=*_$}W19Xh7#m{cHUmxiDo0jT_+nRmepZ42O^)g>?
z)1ev+hMB%*Q?ASQPdU1g1bViO=Z)a+sOR18#nJ%F!BzGt%_v1)c^qB}vBXgjwC#$5#lan@mG
zoL&+xbYynsPaqGCRdXxAow$EZli@jk+_2*A;A4|z4?q&t;W~TZIBeKHX7FI7l`O+8
z%b77{D2JRcw*Nxk?aHTWvOK27#{4Jhu5oJ_GHl4sH2cTOK0_O$ZYv5It?sdHUGtgc
zdf29IxI@y1;=1NhT<~%D8Au_5csQYJ<-8I!1x6i2U$0w*4i~!-qX;=5BLvj(*Cs?*
z0Q6|#N>**BqWtEsQA7x?bit)|=RYBU85Di=z+;hW@Ko3j3xZrt$O_#fXZ0wTxb
z=lJN{rLA}FVTY|ji7)Ph=~{bo%1bF)j@wHdLZK^i#MY!?cg$^*jo7(r%f5n~uUQ>F$%rP3_y+#vcyogPaUTh75e%vuOSv_lPs$z2Bg{ESCC{j4O-C|${
z&BsIhS+@oZdt5Pe$eOF!+R_D0L+Hm>EMKq%`L&FgoPd3IS*?66@r(_h1>K%GwQb=c
zD(Ny5=r&@Ph{^FtmBE7P^vECy?)R337rtK=NyyDT?-$q=vqP(^Ubg1d@$>-khv4~K
zy^7=e^c*+NdV2DKnq)1Ye4=%76+|tdt&N{^(G&}OQWb-
zL`Qc)uW`IZmbB7oD-c}yLsWKdNXwquRWO5EtOXS9ip}pYJ9st%V?A=jL4_;jC+sI`
z3%;C=Ys#j-%fG7mM_44ZZ-elGO+PxPU)`mgXbMghr{MzzV(`)#?3&R7STEQ?7&M#5
zRe&v=4;$MV6&WaIWJK`Zu5;MIfvX1v$aE^9sr;OZimEcw#2LXyTXWz9M!9KheBZgW
z7;MZE3X*L?AB`@V?KMEoM;NQ9@n7h~tk}0hYE-?G;e!T1Bp;(Ah3vBrn^)$^vU+Y8
z#@hb$69VjpB_qrpssom4&lwaM*>R6cX=30|2G`mY6$uE)zlk9j`WQn1TC@o~mgsly
z0BZ4M0j!94>R(c2$k9h?4UmfXncBPL`KY;5UYx5_*;wpYlmKPW!fb)#K>*IKWAi4M
z!OEKe-h;}pHM*#hCjH&7l{YJ%RfYXNgf5ll_F`X|=j`j-xLtRGp-hQN6JE&&R!upx
z1(d!tV!l!orWeVwX*Cn({9_Bgy4zD>CizW6-}zUI;5k2sthkYyE-$>m+yY8Vysy8w!Os1y`R
zpGfnHR36ci=@c*LEQUxEk*HuO+bS5#zen)i5~!>Gll4++`M}4B02=eWg+iVoK%IpwR`6Y96Qi
zBnhb?rr08Q?8_O%9Kl&2S;B;92>1Q^TJ_LV=R^0#I&mBqI>7)>lV(ils74qi}q76`Aq{LQgcZpj+kVNU3*%FRJsYMeT|
z!^^`X`T3(Z9d|}2Gg+e@I&TxcNeX+!`-Rg%j!bce5;|J`Fv#hJ-$JKXgOZKDqESRM
zdjyF&5huABUrzYaV)K6D@OOGK{nKj?FHBSq0LorOuy0tbxu4;XdbKm{j@kCh+ezvq
zcf00l9mdQvgW}M+$`Vc7*-4I!5Gjh;AC=zPL*{TGDLdb=y_0xu)h`(d1#3{)?AE8=
z`n55gv`v9=L@S@g-Rk3^ruLptTYGqUf8cDGsJOJb{yUK{~T-DZ(vJ%Hg=AhCr`&gEr}P8|-!nQvZk^mE2%MRG`qWl2|@d}c4vWvF*F>^{UDFnOl*
z`+bC4gjNW+1vLhZx%~MBkN1o7apWNEn-kmf$}a`Vs7Yz5p9uE}Hpk);7NfXim^qJc
zbDOegUtGINPNPzBA~|-R!8T@cVh8tkylUGy{no+lRo&XDq8rh;tqIBodfF=87x!B_
zf$deSO=`HEACa?diR@k~_^bDo7$IFx(^ES^v4>2yBNJnUi+DxgvT}YOfm}OBpisq0KEiRpd}pChXl{Dc4}jJo5e!uQR=cREh%;~Y2{v9pYSn3
z{pJGh1wx}bDIEI^8XsmL!CM~h04LypRPy3e56r>W&AKO;?gE?AIf}HA#MoemH{Ly#
zl<(K4W_qXd>1O+Anu`lAHw26;&bn7TrMeb2g}PR8Q~jS3s$_8;0DegOWa@N|m$il3
z%I&_6j&$7Pu`)oLrnR!ZzNzWY_AwEnP)__k#{mxnXBzwgO-S~1JTnNVN=fz1_IrVx
z`RA`DUmQ(6uD>0ThlcI@HJCunPV1iH&JKB(r1+pXJ%3YI-`yYJ3$CfH3v0X65q*{&
zt-a2lmX*F{8QvP|qyo({n3jGwYbtQU775+9vz=OYapgl*3BT#Ip^EC{N%r=YHQP+E
znVdJ=6qU;+3T1xvMQ~!GdZoqpLIWU&-${{O)Jv$s%86Y4E(9woyR=IzX&`0X{4RVW
zsH$U!K+zovU&2}D(PEp*GnMbq8k(cdX$kXSe=Qi0tQ|3M13NHSTQXwTm~!i*R=b^c
zn=alP=E3TDr*T@?o5JY<1>gV9h8Qzw$f^m4ai*|qP~ZPEW5Gfi*PjkoG>)M4Jp>81
zLdVYW-WK<)^&!UY*~5J{z?^#w?3@L(l>Te^_BU|#XxYZ5oCp|G@^f1@+_y)KuKZm(
z+S^~~yT$Q1xt6?-1NCyH`_$U~y;BqN&?PtH-1T)pYfw5Djrb>S@t*Qp#T040wCtww$y*ciaYxma_HN
zfI{T4(LJnsM6}{H(+X1;n%P=|3=BoPl9|#*feX*mrqNE%UQ=gfP8;wFtH6dp?>6h>
zwA$=0UNE7uV=#f=KJ6C~z0zx1`q`Uf>j5>|!UcOLMuHA|{BHP?*%@;G38R5~L0u
zKheTC@HQ$;$9ev?CvKB*1)J8iIb6=n2F;S{O?(QW4Fy8=7O;p|A{Ae=+YEH;&h?$J
z@ky=g`p1zwfl+PC>QuQtmK|$Awg9OmVe-5eT(c(|J^NErP^G
zIS3>c3^*?`5Ro2Cg<&KelB*~~o4i*}^9;uP>my#-);L)l0I{!^nlB<)pyWcvWlt$p
zAbTq^d97<&bNWq9q0T}d**m5YVyiP66B(C~!!ifxpLNe+lgE=<
z_XsID1LO`~p9Nc;bUqHTo7WF719a>U4HzP-R&MK-wD}h*sW4V5qGHQD(U+=7Vd!b>
zsq8{X@Qm%KnmmGJAa;p5c;wMwo8iL0|ELCO%zD0FB!90;!eWhd`ZJL4e*Xa%-oW5J
zFF=PlYX0b-@Z|Xm);Keei%37ZiR6h?k6oY4#kiAeiKa@Eg(w+yo{!xg7Yyj{lr;1)t^ueZ>1@ACzLPxQ%
zm_7lsLNOJEmVfY(ZzE;3lSh*pt>q95odcepc6>|#&^z3-XT#RHWGl3PD%Z0fx>n$p
z*(HY9hok5Hi^CGhf(QqZ$YcR!Z)eEvg?)R~9S48JtEm%a<4<0lT{r<-gbPUuLX@bX
zU@qTFfIypq^V_|56Sb#QdEG$ExK$>MhK32l!Hbc?H}!pnvesqtFc
zEZ6a2(s^F+8jprmH{)P|P~f#@4^B652>QB$5@xZj8CfP(D~F
zUNOxhp$Zx%q8c7PqDh;;Ow~1lb8wP2&5gg6+bens02L?$OxP`|8&$}x^SDCQ5{9EI
zFsW%OoU262C&kRxfx8iJl4UJ
z7}sg^@@a{&lbbx3UAE_vVJOi22?G0F2N1SG|NDE_#DlhJWXQ;W`E0L71+Z)=Y8B$Q
z0zf|-n@tt%4Wk>PAVwec($-K_)#LnY6fHs(u>Pwe2L4f27X9%sS{bgmR9kjzW~L|8
z{+kz94b^w=Nmn-AKDjeiG&J@V7*uuOCA>qWK~7EJ}&7{B(
z%VyQOR0GYNQ&3IIg51&e<27
zf!ve*z!li1jZwJ5rI?YAAq-l`)Sf=lxWb_H)=Rf@t~{(e<<1OR7tikUZrrzls-QP}
zjswKW{Ip98hQ*Bgr}{&BOd=0J3P@InA>O*qxiis8%un|5Aq)0qHu*iI6(}@ZzDZ*m
z!phGG>Deph#Qt})k#5aGj#6KIWjnC7cJ!7qe!Axq9Cx3lt`bKsxF8RUz3y$_MySvkn!ygG&<#O+Cvbw~pMVcXu^mO)Lds9h88kLNJxOHjP>7S{z(s
zGI|9H?^MK&1Qa1XtAe_`(tOus)ZFc38VwCu-oB|_o?HMmabFC}5{~lw)0}Vw?sC3W
z$;4O9mipbZ(QtEBb~~30L2+7{c0v;T27MKdoZ>sS-kG{#^F)!LN6#v}l7&TQR$R~2
z*5ypADH+lWRoKi&9=qoCbl8SPbxYdvB(kc7(|TuJyCp!zHDs~+{t9GuIGi+M8GZhE
z7p^}7%W~bHtww<$o!x4nxy26LaJW;RVm=8ak1)P`L_=pulmXw~!8nA3#UKMFW8mus
z0O7xYi~O78jtq9=i9yw8ZF68r1bmV~ggaj|VlDFY9jdQ@OoZ%%dDLPh|*~b6Aw$cT^&0O?bT&Lq-z-E_Zr{g
zi@h4)sL>Mv=M%xMJUEk@0sZ@;Wx8#wa$vX^4Ak11nEc@`{7~Z@SeNEI-LMVRGP4st
zZz!H#(7&Ctkuy}U{!40?VS?V1<1GWCXo7lxnF{e5Cy%mi8$%iy$z!A0M4Wt!2Xn4y
zgm(*;dwKrPp_wAT!odSTf@D%|&&y>iCjZM5{q<}*lf_81u|2jBS{%2#N@
z2DcM;TJHD>k#y(Su}%eG;Do1=kriYzXw|18k|KvxsY^|AlvHpdbvu
zqILn%4V5XWJ7yht`V#0>Y(uqU>l5_Qw+Hj6%Ivte->1H7g$@51_0=y6KT{4Uh{VIIzR+i9)nhN5e#
zwc29%OM#zP9tmof7n)_w;l(}-M;xvu6UovF-fpzBkLK?yh>soNwb!%oY8PZ6?Z-~AV|Q`s!h3sidJD<6S_R#@w2+#sDN5#gZ|qWMPu(@`7wH~&Kx+Kns`FGCb;H7;&vD7A7doWL
z9(!Vt*Arf{xCiB*Ex6567$#)`;uD+X!LAXso5}H)9yr3M7?Lj}UiwGK58n)q=-8y8
zDuR8(Zj_p}@`Ms?OLA@mABUSpV(l0I7ZL6X8}im``Dg@E$-ogisPQr4#7RAG&)3ni
zZzi!=zjQx~v;K=x89j_#e{xzKPSHzB$dT!Rs0G%>(nH$ZfHI;9zfYilCdSny2hSlw
zZ~kI(qf+y?XZG8*12rk{H&(lxI;^(eYwYXX#1wlDV(zp3yq$szNflf;#;b#s=w4@(9K7jqRhrlf>2^~%M$Rxw53NA9J;0r97nb~
zmLT%=ad6$>UDGo(O!EhD!;tii{9eIF#qO*fa%g8J>pAZ7irmdM`A3{Mcx^GAx{w|<
zawtgMRj0TnpD40_uRbr}p%sE^nGU7;LN2E*UVGGCo~^m4A~g;v0ca@OciVa-DMCyi
zE-t^GK2P%TVxuo!Tf*SZ2(@SRJW+-t<_-r^X}toy7;x5U!EHhcSUFJVq!p|GR#OK1
zt^Ed{8CmNOqz$o2iHAmZZ*BFp)50dIbtQD#DKu>)9t~BZ;l^`0
zqOet;V(aen`2d5H3>+yV*_w+HmM?h~V%l#wO^LxT*?LDo9W(f8+
zF|j&2x;l~6ov?66>Xu7hupEf@9YpWO^bpz`WF_sL8fahxP@`$KR6l-imYL__g9oPS
z3|6-^!j7CApe<1D3&At4bAW)C#8}jm%IG-Je5NR?qY;X5s3b>(l{^F76v4ar>hmpmY8Q?4k
zq#ZjWN!ORTK6D7M6%_;y9_5EVu%h(kZStBdmDSQk?2gqN?PS%0X+aA?CEmE
zGg>Me6J2H(#mk?#=QMc3>984xN*CdlBYla!5;-g+D1DnsOG~TOg4T*Q1``kXZDAE8jep=Ez66w3A2p9Vv4PZL8>NF6*eZ9@YL
z8o_{rOlzEN`x;~t=R{9+&Y)%-L<$gNJ8aBh2d}aW@Q9e8%7VZNNAoUe9|<+&_0b1u
zO{hsnI3^SdqJRhdiESnrq=~EwaV@D9!uQ!0a5y_b1;M48Q?H@|giZ$rTSucT`)3e3tn65)?a8^T%ANf&F;;u#(vIViS|=!d
zvhi;Y_%oBc8uH{JRx{JxP`EpugHEK$(lvFqfnF2_S55A;XrI;Riusj{!Zrdm7nUT;0sPMJ+&i=F2;WREj9
z9gjJco4XFnp_awrPFi9&kj8*Bs<%pI5g*9lRXoWverM016?*y4C>t*pVYm_Mt?jh_
zb9k}tXpN1PS*L?5H%aCXNMdXbS|sKBPq%aeu%bL)5&pf_RNT1>khO05=EBwabir_CwsL=h6u3G|_Z|mU
zF5hhSCM?8%HQ_7^e1uU)Us<~KNh`w=PW|;$&AtArN@#}I#XcTiN_TDDblXir)$@40j^_1$I6r>%aG0`fRVY&uX;E|nij4IOYT|9
zh1UmWS$FiP7ShCSUwJuMW
zS~kfnJEP=)#oX4I$V!=LWGF&x<^RRrKA7ux_!}JAT;yiOLxx5hUW25W??r1OJA6ky
zD;YUC5m_)vJ2jjg6U!@}%R8BH(0DMEv0(OdKBIb}4$lTd2U*}@(aGS*kUd*~hyH&2
z%IA5@VL1TZC5bIgBBg$!LYUlrthte^OPOOnPy@+w3}ILvf10YV2hq>yw*(MnN}w{n
zh9g=D|3s*RrGTw=+Xrr5Mv|kHDezoQnIQIa?Gp}zPgps$X8ymplr1HRx&AE+@9Qlg`ru&vZ#Ad~uX>(y=;lstpeA8yLW((x-UOm9ZmRyRhcRe`>Qv-v>
zBT?#-trL0=K{mfK7yh`|*0Ez_TXtPP+W8sbT#i>L6^Z0!O%8ac%=m=+7=fGpU(T5)
zy3}WrLib!f7JeUY&GClbj^oj4txmJ`bI$#GDR|PBrTFeebQ6&_UL}|xv!b_fxG!x4tpY1cdr~?
zx4h=@^U8SPjx%h0YHCW#$X#(af+^laQ}352_vAwEqvvn+lLxswv19TaHPldqy0%OP
z-&w{suL~LS49ChZeV=27O^-K${)$l07yapgDrjh>ns_t2fu-R0d9~-`BbdL`Wpuex
z-JVBa?WCEsGQYDuo3Dmeh{@o;JVjFE^+r1s%bwL#u=1nkEEY+5#9}2{l7S4*HV7kv
zO4Z|%2`M$smT1ZOO2kQS!%keGgH#;>;=jYx)J9MU+HWg*#3W@>Ncga9ik05PB=^z(
zKF*(v=0O3--!g~Di*qC~3G!GTEAK^i&Ul5EmV*0d&uVo)SM0Zzf)BLna%`ai3nn^U
zPD~>LuS>xuN`(7My`)fTbzdLPST{Y?z%YDF`=x>LsrHi@s28^)e!q>eB!Ub(0#K!+
z(oEu*%HLJD!mXAXvO$+}+f-?*ZhKX@_m3>i;1=DxHS1T7hdj0p^o}{04zk@JF4<>D
z6YKgCI`%ss_xqEL)Xp=GK3R<^zTQ`VbD_;`7~;;mgoK=9tPF`6dOTgh(^~UfZgzQ;
zX=~GM0SatttidHy_3x0-$1WcPj}<=-LYL0rtcemY|1x(>NjN`s(>`nw&qQq#8{`nw
z90<>Q#=`1#O5@;lwx~Htp|JiPHfCh(bNH0P6s9U}tjgx2;G^Za>0i+E`|OAOsU9E^
zd2SvS5XTg+NM%Ld{vl#Y?1{?B5XM9pPS6<~oqFKwa`+q#!v4g(tWIAaWya268bodI
zgi7(q+WU^Vn=T$3?U_zXY4`F+9o4m#>>C2mc-X_@y@)FsfxmYyRxI{r$}CNlmH3`Z
zWOByg1k!56xn(3iVm~x*R=aMy-~B^A>D;_04RSck-Q+o>%W#W$Hm>E%XtNj~Pp8Rj
zx+rdji_A7y_mgU7W_O785k5h`L6aP+zM7B{2oF^Ijh@!MZ(SVXR<^!9f_G3DJk9SA
zn+}w?u!8stL>J8Ot`ib`#D`k0u{X)$=Il&7d=TQI?9;|3p4Zq3dk0B;$o`dT>n*<*
zP%BiMRC!9hq4*?F9b!<@k~W@NE3RYQinATQEm^z7%0&C=G9<_lz?vMul?tCA`RZfR
z6b%e4zwN<#BeVe32h*5X`GdZ*zw?&^>OZr{*eH4HRTAW1^-MqB%--M3-rme!Z8QI!
z=WV}#^E9(FG^P!h_Pv$$+GfAKeVeVHhjP{hP}U`HZxk^RwlF@KorTnE9&hO77(3Y>fJZ8
zjJM}{o0E#4L;&)eH0d>_zXDe^t!kTXq&Gh7FNNNP4vAkaT(!LaZ5%MQ)r=Xq343F@
zMdjv@w|J|)w|s8CT|3EZ@5;n8
ze=VMw`UBlDJsY!Tu7R{H8B{joX1sEJ!utZrj~BO=Ob1KQ^4(uCwN;;#%>^!;Rha7i
za>tch!Q}6j#xQW?ChPUcrSU!xubMnD&WK^lg4d{4EfvHF8#j1=>1jP}OfS{mWEORJZ^F
zqq$4gkv=JxE)f;+E$r)Yu919)_&Qhmav|b=49Q?{ByrjXo85L>y8-a0rJ&e2=tJl2
z_OUJR3}e(4#!MJR`xvSd^sB>OIENl>byXSj;pO8|u9sRFdj#IyY1mffS4yy27>qrF
zu7$Q-u|m6BS;
zIx1FW0;3Yqh)IQsF1gzY%GhXb(yw#nLUqECVEY>O@SwJBB&yLds)9zUlkSHC%Quvv
zi7d+JIu8D~5Tg)3Hnuf=SB9@7EN6%~#snniGv%71W%wIPb(ONy6?p95(5dFkdZ0%K
zD+hE;jN!2(wNNE&Q4m%NqV<))`6nDqc|4>+*1<*4UCeY@ZIL8#z<{A+nrdBlb^A!E
zaI`@v6C03|pR;UPCBz@mmJRx*&QH+p)sidWD|1_vyx}t%Yjo@{0^=zM*0Szj!B9Nx
zr+tS+$7~+x<5(UEbNSECs^?nsM1i!#5=Ml37zjo2Pr2W{Pc@0AcC#DQs5(HZz=dCT
z@)jRdR|$LJti|w!%6C^zrUr>3c8-t-JJL$uJz=Q}2_Wb|heCoxLu}G}0&GW#CsTO6
zJRRC+ZeAcv4iva#yLOT&vYEjiF>BXpNpVJTu(wl1Lxdk1k
zR@KGdQgtO+%g~c)jUVD~b>r-MMx>u=p0r!Z&weR>+=t)_UL>U{BIFAh0|`Fwu@l1;
z^F<9VNm~a$
z4{;_=t!``9FFLSu7Y|rHt_MgIWmV@=&;|h@wX7FXstp!GD7_Ok0MmZ*h+TVOSyUJl
z$lAlKrGF?IwZ+OcbSXw&@ymCtmG+`wARtU%2lxnnj)|~t;itF}hLE4#fB#@sr-^@4
z)znp1#b*HeO`8ck!|{rlrs%c0Ju|Wq(pN-_oP|+WeijEp9co^Z^)AxFA?+)1Ccm
z-2Lwz5wOpPf7)(T(||9?3-?K=ShaB9VpvdY!^b?uqmfT2n0|-WBg<&=R7&C*89kxr
zW{YXvrPJdVB1~Dic-FAx!%j%j`kGVm#|xwW4ZHCVkjZ$8E%zYzZo0)q=v{DnH~|=m
z54-J1UPSj$RBzN9By1Ek|fHL(i92Nj$s28Q7Ug3l-+jy@4-MoNP_{
ztvS4+%54w-bLp$~Pua|p;q6cLPvnK&Whsv?R>2HY->|CdujsN)jbeQasO%{ohdQ-;
zibWb)0Kh&B_@b(VXq&Q}5MWN{%khP%pAsW+LRfVZSTkdZMLYkx20#G&Uk=&W*gN_Q
z*N9S&i!~&7O}+c*wmA7(IcW8O&ZYXWWURUiCpGk-r5T!p6yVJ1DR6J44F=y+KT5Z6%sS-OTPoYQ4`Ph6ckaW(4mWLpCC0QR>;Y>a5#sehr
zTmR4}#dy8c7qxFM^!4xo67&>cuPe-Jp+Mu!HDuG~!Mk+rjIkGORuzqe!}4jxHui;L
z!2BBr_w^S`P?%+L^XdH0b@<%I@K>RtO?N`M{YOk2qG`^`7{gnsHej+rSv0?8jY_8-qLt^Q0A)MzyNeBwnDxzD3*Pe4Rwz>}7tXQ!pmDthF=$!|j3
z{f@ouB46E|e?+9am6K#Zf`V`XuJL}eC`|5BZM4yvwk7#pykzhxW`n!l5#ol>#+!vn
z^vUOikMhQjj8*JgM^6w%w!50Js+#+O%QL-KmcaL$sU*aLzmd5#ob?DWV7Oo(9!&NJue
z_FxvRs*d+Y-Ap3ShzYd%swXa()VPsc_O8{!b1i3h!YVaqm)o_0$AjxlX$PAu5~bES
z?g7H~p(FUJt`NnM&_mopaPLl5);kqLLd?86bi1tn2H>r%wchY2SxpQQ_kI3?`9bIs
z5-O`L3y%AOZdV0tGiX_Da<6&|DI%_bB`+7R0#e%R_2#4N9qMqjGXKKRYYKCtWlV=t
zbZXv5P7kPPc)`P}io5h%99?0AS8e%bOh6bgMqd+PyU}mqi?D6GnF+)BqNw~hxrB|F
z+h@@LYBElFG?A)S4O7C?p#@*#GTA$gN>n#<;U9gQFLAI!^-*zraoG)9<r#E@8Mk&T6gE^^u)-Gk?KG6U$aXsss8Xc
zMx`8o$r#j&---8G}k5tAyvaW5qo*9zn9OiAa(eUzW(DyH#_cx
z=M-mNG_Jb&t^7~K9>F}`r}!k2a-n-}Wsv3mCkzpfL!mZRjG78J6K!UJ^toxh0mNRL
z;z+wURGu5r7DWP#y1yN29#*~yc&N7)^xjr(=dpmsSA!#{-Q?E_f6(>ZuZGu`t9qDY)#>8{VXJP)cbDs<({X{)H|>8lhc&=k2^
z1Gj~*o8~RdW6}7PCDjw{84^0i$^JoeKZ(UlQFWB#C?x
zyJ?#f$xqZB$#WL`8}g9H2Gc)M?+~cOol4xrP-Sg>Aewo%)j~JE##*(_i6~jG3mO$6
z+moP3B4k|hK>b-e+-|6aL9d^uz_#?V825mvQ>
zJ#!$fEPd|rfVw^$d<^i+KzygU$ePsgHt~t*O}5E}7_{4Hwbe59{!}v)z*V$wpZ4_g
zP(rS)3;>v@SS@K95z+~^tqb~vn&&$fSxTR>b<^YjPXLY*aqUK3rBSCf7>ru5&02@r
zr^0yOi*G!OcJAOayK_!sZ1&sP*z&vGevo{YctKczXRI0dl*w#md%P+`+WP(-3C{1{
zd3Walyc@R^c#BYd-p-0+EQrOv8uZ?~e5NkPQi2Py1_o*6>ua~J4uapR)d6HGyE0$!
zxm{S^o7y{^$8MJoWM}i~0=j^~VZz8@F=P3a;a5_5Y%v=uHx<$tT|fTSL5kleW1mgr1rytXzpJ
zd7V!mkk#PY>Kcp!zb&w<+ICbRYp)-fh
zw5MLtitmwcgaMx$k?zafohkcY1X0$ctYP-8>i<>{AJO3*51?&nr`b*{c%TPdri4`SXj?~%FwS_W{U;PH{e1|xPMolqhGTS
zs8*qOL%(LB)T`P*4@10muhFabSGn0f2cwmJ^F2E@GPXz@$$mry(3BEaRAA+Q_<0SIPv;B&uYWWn
zjDvpM@IU-q23wl0cKH1@z)s|rMl`MEvTBJ1IJ-Ixgea!5Ql?7jR|DA-uvP>;y!n||d(9W*pRuyJSfjPOgeO}b{?t67;jh%EeEczA+z
z-)r03TAESc5A;4kyd@%&x+Fp@3|_Ym5`f-4ZREgqJ
zIGN;0i4KzrOd#5iO@^3%pp)V}ai8!oR3;@A4?|0LNJF^J>@)Zct}3iSxzgBbU^+A*
zT`}Tex_Flqhun2O_!o2sK~X1PXKBODEbT3cE)guXR-oH}?bgh7Mu)M+a07ejW0?P6
zSdOb}K$~*bTP)QKQM2hdqu1cG#=q98L9Uue!<&0iH1a=4q{-Z*uiv!M;jCekMm_+}
zKBL#T`k228KvT1>7Y-$gQeazrkqMR2fXjEFmwv
zB{)pA;5<<7Gtx~PH$@sk=*r+&-M^U(SaR8`OVZ~oMH9~dN73IeeR|%Di(XDc;s{bH
zeDcmG-JJ|$FtJD&bDqQe)=gVmceFVz9;*lcKd4CT6x{?J#dbTNomW^=QdV7Sud&w-
zr~onT6#gS+bJj|3R;fEAG5_1b>>1KJ{;;}u@
zaDrQ_3r+s!+i&=RP4#S!6}m!VAt4%&26c6nvTHwAP!C56=2vECuaZ+FU9~6s5c~Bk
z(m?B3tu{ACp+K-s9-AQ+2;{dC_6a|bW9!`wz5}O1Fs;cBHo~*U*SQeREJIg++Y~H%1c3b@GDJ`Q6EVs+)L9cZ-
z_tbu9|FGoUHJw(E$;H%sVjnNm`|BX?kJF)%nQoxt)|=>f6O0ok^#gQx094#7+5~b(
zH4m~Mph1QE?gAQ02Bu2?M;bnB7pE{1zHLv8W&;sbx+>j&BVz5+>H#8rdbYVBPQ>oc
zjW-ZsWJHibu@=G0!cF(7uXg^@Zr#RZ~ha+d50JlD)l<8oORYQzzN$+s=4cy-6&2U_FNa|2h|^zzPsi$
z{dG?vnrsg!~I-Br@q?xGw=vFwf3i%zp}df
z*`xisQVkjon%{0CSWpt36W1B9OMMej5D-~eedY7qCeepgpR;;B&MTY
zp(7q1V`HtE`&&cw(k3D5W4|g@$rQ`p&(siO>ZgdxX^E9`sLo9rYJb8j}ClaGI(T9W^zSc_~
zGj1FvPxH9W5
z`zR$Y=L|Sb(hlAYy5NmZ203a2PLChgAskI6cab}Jx7)#f&4YdO;YP8aV{3&X2%CAe
zLsMVfh{)~In*Y7)osFw#qJl>mL0FHJG2nr^YNG@6zSr|Vg-2CV
z3spUDUiUk?`@J_azq?=ArcGsF!~M-}t|e>})g$y%dbPw(>HoCj@0u*1DKBr`-jiGFa{iNjap8rS
zlg3fdbUX^0g=<@eY`A(6QwSho64t^ax#440Zw`a
zwQw>*I^@R*z1t6}`)Hgu{c%4hqgAL5`nC#j$(%%GJ}`*Mi}CXj8p_GU>#;zbJq&T<
zI2qGFiSx$dR)Mo#D{FD+@7p=)7EU-JVG1X{TLCiH17yimp|EK(CtYIz>}nwrAWEU$
zavAD9duxDHGz#R6DwJfX!r@QrfD{4A+!R&-L_h3MJps{EbvN?IdZGv%1u4NgNa;D)
zT?Fe&Aa+9a1m2K4Q8Ek%*7tPJlIamV@bB~V;D1hd4CaNxlYhH5ws{ee^cpFzC;
zeSm)O0vW-d0SjJ(f-V&k-hMt#Geb)=)ZYpOMLfrYu;yyCieu37-Xt94ZnFV-TNkGB
z9f-O&5C?rc)VJ&bs9P%zcN-ePE3YWrblm
zdvR0j=PGY+XwW)rz-K2iJO~J{p`b*sUTCiGLh+joQ1~SPZt!R*dK9Bg4n0Z?8-U&h
ze+TTZ+y=^?zMyXXAQB+N!Hhj{;Lx=E1r53!>7Jjf7^OfMSnp77x;hW5(FALe
zkh+;XaHs|+fdv|u2oQ(TRYL_{AFc(8S;UTSAAE$9Y1>D0{?m)wI2qV|Kj-I{pLrq_
zOy7zDU^Vu6o(KvU3Pu(hjt%a7)*TA`2e_Pl!ugY&L`HaU66kxw6$;)cQC(1wzid$O
zCN>EeCYjI=JT{&o{8!BjRhXd%r3AENiJ)|AI4J$_3>FP&K+}dm58qpZM*8}3O?ViU
zo_4W2ZWnvpzufNjv;KAWm-v_O&-m9%T8DqB&+@M>PxzOGJnmmNf6`q3RU5B$@~_$i
z;_|N(E7fQE*8pfx-w!zV48hrd2!ete(7s4dQTn5=wF}9b2!kMg#RW7bX(?2h6BP*t
z{NKR*VKqo@4F-tCluPUBNS?g9T^-&jfGbm4-Je=
zB4mkZWfbBTDrB^2#dtdU
zG%&){i}4~%k&ZB>VVKe|OsP+WDa8mB5T=xlFf~Sbc9>G18K!>p1wZ*eBb3%DLP6_A
z`yc>c#VGva*P6sl=_f$5$)9L;cyHjO%Q<9o5BlZZinh4}H1Y0^n-G_PzZxOF&R^>Y
z@ps-(p9=AD9{}#rK%C;Q<^c2k`#}!zx`EFv3@zb*0O;~q%@{j=yTM=b_=r3;yio5h>sI{z|Kk|?4xKlJzD(aP
zc(;6^%yzIT(m$AQ?b#c2E8UV+mM$}&%hK4*`Tz%R9C9sfsF{h$sVSY8HUbLA?R-yu
zG@_--uFJ1VJL5{HOrE_kk4k_X4#>GQK7V1MJaD}9{Yut1jYWLeX}P2@uehkd(Xm|8C+*w^U(4;U
zw{^AWrxay7^5sXP9`=D0k-{
zb|jak$e%C$D4I^DajRkz5@bD6kCYPT$0o(nSUR(Ko^#%|qzYPzC)k`$xw=|%?Q+D^
z?wAB5N9^#!pBcuEm13r^h^BATpbKHl2j1v^%ku4;==$8;5|Jb?mu^Viu(DveBWe#l
zPj_z;N8(1ZNXhA}EZIeO(aJ<;f-|9H7D$dwoCV4N%k=c@G-Jo1SV07_C_E?6JG7=+
z+P$~*AaqY#c5ud?8M~uPqv=~Tdil&5MlWJ!EsdtpRJSvELNS$nhOtKdub0>y*67D<
ztmz_a(&t>ZMzkDYckRFPY8|FG!o^zI=6DV+dmq}m4>DU9>o!m8a@{rg{#h>&xP`-rh$oU&2S`+C1+r{v^Ok{4lA$m5d@$WC2-6
z%1ITeCD%<|O#Mw0O^Zw$P1{XhnNFI{;di)uRTx)_11v`VA9-WRhRuO3KAwM1EM6Iq
zDH7Spx*8-UrKhLSBv~JVXWf(6XUG;kvJU)&Mb=6dy>~nNU&TbXHU(IOF0H65;|Zx+-Wee`3cK+Ny}htDyPRO*322
z#BBpE+W`{~T;?H$ZD9*snHv|d1+L7E3p!%t-X$Om{CP5dQz}%Y5mj3||J*5Qzc${NkWME+60b(%*
zIR+*MMj*@rViuqX6A&{(FaQuJ0J#7F0000100000-`2}900000)P}@x00000;6KC}
zc$`I!F$%&^5JVs03lbzI8X>iv0Sg5$AUT1p76BoULa;Ms8mnlbl?U@WqO)JY9cI}-
zGk;l^K`;OZp*Ru~=R`=EF(mPb#fVJ3^zU5mA8_QrQv2FB-meQUdlqpZ~PVchm3ObWO~bxprF>Ooc#Nq>Tj+UC>vyV=cF){Q#uj
H6kY%TJzsGD
literal 0
HcmV?d00001
diff --git a/src/2.7.12/public/fonts/BbbIcons/bbb-icons.woff2 b/src/2.7.12/public/fonts/BbbIcons/bbb-icons.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..2050c01050041ab690731134be0154b6d55d7c3d
GIT binary patch
literal 13264
zcmV;>GcU|{Pew8T0RR9105i}44FCWD0AkPp05f#}0RR9100000000000000000000
z0000#Mn+Uk92zDDU;u<95eN!_s#t-B2Md8*00A}vBm;qL1Rw>3b_a?M8@^6eMuv?8
z095d)Mj~tnxnt3i9ZyUiy0LYg6A;
ze^*yO*p&?P1XciTM~;JFD8ub-2n0=?uv^QypIhf|H~l-Xqn0gMP$F{??D}r6fc$tq
zxxK%;ML|HRg=t2C1b{7$Z&9b+ro^%$*AO5lWw>@RUVG2N|G&3;-u@W?4HhNyIJ=1r
zER?|WeRWolDZtaxyK(p-JBP9ZdPRV_F)BTNhw0sS#=_+PU#DNK6!fq&vq(c!bb#do
z_1o_0pYD_F_wJQ!NsbfQa`GKJWP7s5_9r_T0w+V1odqV_$?hmH3|Rv1kjtsIXt%C_
zin<{iqGrG#jC1C!36p2s>;l|w4e$Uxm
z0RXfjcy#NPmF2tLSoLknKMws?M3o2^1nQv7K221iF$Fa0rd68OzfLd;sV7D;2g_Bo
zj}i|1Fr&c51JO2DDEYb|3A$LnWhTmay4KPZ-+qua#xl7kOXvf%ooH(pi8YeFLK%U)
z24lL82r}BukMhO**9vnN+f5%aLoT
zW%A@JP^d_;5|mP9%2lXT1<)3^agV_DegV-O_-D;wh&I#v&T8deh<2+Bwy`<)Z(X2c
zGNA&4DVhg0qG17J(bvR``lUsuq!1h=T7VYP^pI>M)2MS2J;I{Wi7Az6bTVBC=Cn*D
z5zP{nPG%e?Qx?cSrbQORz9ASI0B_O5^&k(m(f}YLAq_;}U>00hVs4q1SB%y(62)79
zjg(0)HLKTMQJdaQVvYvL`brrpzEx(htmf;hB8RO`gwH;im?R-Vhungp3u~lrj;Fd$
zckJUr77`L0z2_ZeebBwO?fb_2zZ-Oix9y7w?Q?CwL03)xWr;|TiNVnU1};9vGJa6
zP3Us5@T)xms-dVa^0I1*G8{RgkJ=?_|K7LHp$bBuAb=o15SIb~h07wBbu+fJ6r3zk
zkp^EtOLI~9yVXx$O;>8U006e05G?wE2yqFkn2)h20s9c*lc0>LBPQZe8?qR!<_yy%obIl?kbE>i^8>|HjE#gs9L_@`fp5Dl!W
zTjFxj!-i|fB-DU05GaLqtb#=zpgUcHFm33Nk_CV3g_>SO9b>~WidCATg6(Q`IOv9L
zZgCyrWmv^3xQ3AyD@H6}7H44fF(s+L=7A1e-ZEs=_pvw%>ZB$SSKSPkkY%-|dN*x&
zSon|?9rwg5%EBg(YX91-je&ow+At&k=KVkMyk6HmE#)cy@5DbPPrJSEvI#L}jPg&*
zNUVGvd6`vkNR%33#@rDjteTBpjKXHr_2APM#8zlT-@NC8Sipa@BF>1iQ4{0+Nb*40js`MX>pW<>PTYnDr}rqe0i_Ps>Rw@d1#PG>2aB~*YBu7o`ZyLA
zh^%*nT=q0%euwW<&+Ag2b?2Et@6ZeWLI*N=;a(n1XNDpjPNqty+4Nlf7R04yL$+wY
z8-Hp{lIP_lO?O>gOo15g1QzFZD))t2Dur{1!a96voMbL|PfR!7sjxCHg6?VwLK$Wo
zfEwjJff=@|CL8gvNewPq=?vn)xJT~wFcyA~`8MK>zHBllt$PueD`N00M%w1EMRO*n
zorgV_l{Q!po)o@g>RyPZO%YNSm9wm7r|_9P?E?JzbUUB!|d&lEiT0MmaH7w
z^tz=Ew4kkXpQ~-SbWNt8?$}1X+2hzF-FU7{sw5Msj~~N;*8H%hde)ToU)p(zyt^*F
z$%v@jYSKpO`gsCqIjt7V^@n_)UgEmyO}@IazD7!IwdaweC|AVP*-#*j!3=Q9J1O|B
zS~!FV)|2^zQd_8@*%%k5mv&yV1J(d~!(|7)5u)uB@)1C_COmM4RxwYqc{oK4=iK$W
zf&(|f5a{w%a^!!6XSiH4PxoBE;|}PD?*Dg_8&=W*lI`ONonoMdY^`<`O!H#cOelwcVF78lvSb6Ys-I$jAGEdqtR47Rel(
z*1ae5%-o#N-`#rFch3V4JeimT5)X^b`&ZTv`EKEXxO5SajbP!Qk|?M>)D*`xyZ-+@
zUfpH3#Bpq`wmY%zY1~D+wJw!*mk~dkOhBfKF%V-ERr7JEunCL@lPSPq5W>TpQXSBp
zieoHRpEz!8nzH(hzHo3_fQ7e-G2kd4hiqaC5Pk}z@!qg3_U@8E_k-f@{Aeo^Hmc^A
zNj*On547YO5WtMeMnS4#4#p4(tmLW=KmW2
zHpu7(&eS@wH1hzwMxhlK{Vp9}$2|jE?@3RlAWLV2&j4IENTQXL{l)3PXi%4s9=jV<
z7Mq!&ZJNaI)-v*FEO5C3*0uP_v_}>=<3lUel$v3gm~w7Z2<+
zL9F@}W>^K`_)2S^&D3eU#EK9cj$P(VwrKXu)u;6ZJkZ;5(dVwgpcZHin`W`T=&rbh
z?b?jaP}vCf>Yb`XvJT6`!p7jN2g+1;Zw0#+O=_F2)Lo7f+P>4
z$Md&d!eXYQ3>&ede-r+jy@5jn7#7Am1*}5p
z3K--j=z>RV859NBsXN>1E0zo+{_3pyhDY34Ca~?P?h#U3oqyTgJ{FC>Gk%HZ0fH5$
zzMl>lCQp^Br`vRFeKKHVEzkRKjZ)o{NI0FNakI8D;X{QJxtxP?k|j9hEK1VZnF3cGF^i)%ap=OGp2
z+S3UL)yNnY+O067E{H1*`2TG%f~))f=`T)wdGqJJ-<RT-%yhT4zaG~Rq`3T75TAOG{VgYb$#JU({0|FwrBP0#(Qx1gN(r+tOA2(05
z-M17wV3(tPJ_e-45k1vh`SDHLb(flk7k4V1>D-n%jNP{H?=&VY$=odqhJ1w6`piCn
zt)6W_QUPTve8*;;vK9Rx$Qk_be^
zt}5nGD#}Q+Ln9y3s!3@;D0-H#aIzc_CaHKSZgHNz(cz%dYNMCCh%dH>(X*;a?ebz<
zdCk^!?ItJHhvL$%R#FzO`%lBehhnNvYOPu|*N3AEk>6v7MkPRpkr4p3t~w_{9n4}S
zecjZ++Me1#-N`yw`&w1hNs-ad;m!=^1^A8@s%xN>Xu##lPqRm<4&-e9r_9Iy*vBaq
zaxXm6q7nb1=e&)1q^cN}>mG48L{#m)yO4GAszGM})(#Gd@lvC8FQt9U^~JM+-
z4rR<*4RNnjdiLWXE^$=oNM^r@ZL?eq$D407Z1o#v7y+}G!8|fcIkC$S>bbv6
z#q>$HdLsmg|Hs2&wfR2$`kbqiN~%o;rAsdd4~qpGBhy(O+SdG*Nh3TQ4T1k_=Kg
zh}29^Du#Qiu&UK6`v{2iXwa{-z%{{qn4}K;8m!;z^}0*?8eR>ePudI7`=qYq`^$5S
z-U!`%h1Df|iV`@*_s3@A5IF|)YUa%;3msx)1s!-E$qcU~R*(Wib07BWCmMlMDspw<
zJKPm&jrNd)P|CN2gzlCoP?5IeVw3|TPmv80o*6qV<>ODmZGVnA$6=-g?
z5g!1wQr5)#z%b18!rC^7xa8JWJF3~8qS0GvVR4bF-m){pM^L
zR55tW%5d2*ibx^z^AI8x|M&r53AL&m1ub_uf&hkr1{SM9(iHIOux>@Hu$ky@OSmc4
z$LujNr-Q>*5r5rLL>wVv%GPiuh<{$9Q;AhNh&dX@kY({3G<$wE6~6@`bSNUYA9cf$
zrHk&-6i&e1^G9N_+IVlggT?UbV_mEWMsFa94z5um;8W-Nxn??0#@;~Yxz>3TT|_+M
zete(GT4=}0J+E^_KRYvBPPuz^bLW(;Ad|Q+1iWz7FsnW7>)JpoYCzQ+EzLf4nzgUj
zr|OvlSDt^C(XhOB$$}W+OI^=1H4b>o+tuu2h}Qq)BZK^vn*?2MAm%zj(3
z!k5!5x0f$&qM%ritm^j4;G)6Gm^3pS541x#B%&=MygS>nLZ#=1nqkbPqjzBpQj3@Rx3
z@1$oCF~o&Js)uOP`we#8b8#1v3sfA4#FP*$P!-ZUDruq?5}dQ!GkkaTdwHA@267r5
z!T>+E+4C^a?_>Nf$Y#=^`B&!J>)164%YRH;yr?U;q)p5X0NM
z)aswB_8hgHY8|(Uc3*eet_J@X41H3gE?l)Rw{Ue+rxV1^O~IOxuf96Auyx@vyFBGt
zMF;y`qFQfne=iW&Gj2X_uEAYYiEwj`U`3z&;Jnm4?-#92BsvvIE9U*)MRM22fnDZ(;O6dp)y
z9XxRT$5E6-b=B3<@PXR;&HI`g
zMz{O%QkuKdG8&dI&8g|hOmFE2L$3`at}|L29!$I8{Sr0qHa)E;@GAzKFh~~|G295u
zcgv8Ol`CfuU~Xi)5!@)GK70uITU;MyN-}IK-zT5H!WHNXlqVt4HwaJ6)JC(y_wVEF
z8vS{cB<4{4k4BxPr={-lcMowHaC74qvCTRhmq&*ynxg)!5Icnn*|_#st#+9s7#
zyf=#!9Wk-c{kDdDa)SGD#bX{g0
zz6H0z)zd#I-n)4vRQ01v?rAc>LHdGL5AvV*_s@=>_&KMQCY+E{xV`Q^L)X^8i6
zE1ELh{l_o6X?tnE&7J_N(&VI4S3D%8;4_P{#H8{V^Z`6J5Yzf!4m=v;aVIYGH|S{B
zm+gl~3GQXx7%A6NQ9dP4(!{p%5!jq(nm(j*jQ3>=n1w7s7u*;X5xG1HuCrjJ-r;k6
zoZ>gAfagz1jt?A96s*=3o{gwawvTTvImnRMP!+DSh((HUPtss7VAZFTsiL@Rx`LMi;>eSmg8Mf4n9jpI|Hmc_}h@Ffrq_#TFji
zhnNlK4gAazm=Q+{^-0YW%Fsn;V{!{Y;JMYI9IPpwjYuwsyCE0N3Rcx7GpqUj)RK61
zFB92&cry?s#UVMq|XG4
zQrM;RCG`Dn_`*kf@w5M*AL5@IDh&*$a#woT)hO$P@XUlg+bP1QYi<%0q6+%i+0%K8
zB~@-og$Jc9j`yi>@P?-`4yDHsO(+QR7#J{eNgbCo4h)3%C%HP3`)c=N`2JZQdWZCB
z0CIw_$Q69t15b%+X^Y+_&0EGfjYrVQWy@BUZroB{JGis5Y+zSS*}7rLd`3m>SY>TS
zQbqTY$JwGG0p2itaa{r8Z}
z%(m;IQU&`cQWic1^Cy?%iM_pv$BX)UpFHh_p7gq5sP}0Z{o-ihe;+CQU*X4A;8r?8
z7u%3~NZEPbe?;2_Ir*;a(ITj)Si7>jrCS#%ydN8ydzv*Aj7FjybxW(JSIt49P>9;y
zXvnYlMD&IDC3^NC;U=~w4vrICF^?FZBxGU_V$z%`=cXNcyc)%dQHX+^)?jFJzxp2x
zAyI6iws3dfB@ma0xhy=^)?;o=c!Txv>N5tMcwA?4c`zYEo8UI)NN3Qjfq8tRs?Gwx
zhqF$a{2-2o1%K6P&pyd&?Oam&hu0rcgKWLnwQo?<`qtM?l=8XTy~rP+U#F!ypp}Vkq_o-Fx1}bEY_z1>
z279S93`=FhIt)6oV`Kzsg)rU73@+2^RsgRJH%`TmlJ{cQ*uIfg>!(i-5LhIJUzvFd
zT1Q(qFA}fg60$$M{*W2S?H10>YqONfNEGwL(5UqEPs?Im#qXD!(z5&OnqngN?^bYy
zv^MfphB8}u85ov}I%ikOZC9N(VyP7bGMpEuh>mWm>(6$v=xI5Gjv=Bk^nLcH+`08K
zRb}(8$S3(+oqV*i%sDK~#dS9KbC)?69ar%5Bb0wiJ_#Bv;zhMp!np6PGkQBSB<6fc
zMt6Ujb$gseb%Q8=w=l7xVXV%hvnkh{`4e|
z6{>P_M9RGx1%qqmV~B9<_nqNnxO_c_JErY@;oLb_m$}^8-0UoQA1dXea^<+oeDd&5
zx$_pf?S1s}Tb-`*liBmmkC@=Bk;mN+!Iyy#hg&>gvl6-A@P8vi
z&etu!bHU#C9;CJPzOC5Z>K{}c$_q*@qawnsV;by*^(oiFIK&SWH~X+JhxfYzBG(W6
z*@43VX8Dxpqanl<5p2{;x0_>0Xe$A8Xk0U<309_vJ{-+0?~J@fATJPBYK&c4a~yy1
z=8za9mCY^u#ljAdj&3U--+)E7gWWCy&gwO~!aJfRxo{FLs
zdeoaBQiSc*WXr;h6rVDp%+z4F6rMEo^w&A5OqdSi?_Ox$5r_@KEPUQx$U*COO^yA&Z|CN^$Bne;q!M^9DaeA3qK?H!OwN4t}M@BzI-`Hia#RZYO__8oX^qt5}?Ag4`DiuUem{rPq(U=hH(Vb=}{`pc3!
z^e7|_y2>{~J&=)q6
zDbXOPzPyTp$vb@<%rO6kF|h=BlYWarU@8-qh_ScoyrG8E?{FciyjmyJl~5`+cI74o
zBh=h$H=Wse~
ztWc7?jFRXTBm{w75!WM3h8Bj_oW|girwX-3VJVUs9{DMZzCbRGiIZ@_FA?|!+DZ^3
zD1NdTMh)0p4D}diGFlf{BhOL3cY_2&tdI^SQ;l#3)<7@_IiIH@PqjP2{%U^)tPTyM
zV5q3s|B!P>npu`vnp4L)Dr{nL)L})NW7jC)_EHRA#gA##+>VVV%~lo?9lG$v|`yW-~aNu(dW~&sVcvH>CFwcr&slT1F)_AoUx{$
zAWE?;U*87H)&TDdO$%a_$)4-)QL;zhJeT>cLt?``7uq>RI%h1}eC;{vd#28$)g%vi6qoM9i)+~Xp=$mKz8STpP_dUMrax~7j9HGHY!77%Z_h|3v{eDAcw~2XU``G5Q;#(RO@vbxMn^@&
zxu#`(_$n*&^N^?{(UGImB<6t;vrnGfSGBgP(kJVvuuX5?ccQxys~ima_Z5FFP)rndGy8xCUf$XYUjt;sY&g4mZu8XujlU96L8G+4L`NUAS6X{
z8Umh-YP>W7&vhGVFa6J{5|D%oH9p~L&~UZ5mlp{K&jjf&1W`gBG_mn0i;_4DcufO)&>uVtU
zn{}Ei-4-`H`KS11_dQ<*8~NP7ACbIi&zayeVACs|O}cGbW~(Lxtc(`POq0tqwEK>7
zuJ^&56|kQD0QBwkwE%kN;hF==lz|URps4q&O&X}(G@%9xN3v-%uG^NBew7eLzId6O)S~vx~G|@UXY@F_;(9fRRg%kX*y))=ONiT+6)_
zxc3Qcknz^{hk=Sqls-EQGG%4`2bM50df)o~kCAujFqvZ@Nmr2drGU?ixK*}&Hlz(D
zr^%Imi<4w|4P=Ds_&*J&Z7f?DTWc;<>Sgh0IdNPoCQx{|rbdC%#57b9c*8KtEf6gT
zyztmkZ3&@0Jj`cVFovj@lCX5FF`ka#@Gg?Pe-Ap>lH~9;%YMtSVg3mB45Q##nC)Hj
zMG{l2d!dA+;jS45O7IYdD1#x{&tpN_0EsWp8>|558_)4jw(r2T5=a(p6|2OYQv?3F
z-^j*1F_!Oiv`0})xZUKzFL*JG6(_g1S#yk>5w=pxg>bn{K0;P1RDz}-xJK^jXD}YA
zXhRygV(Twv(`JFD3X%>fh_JcY)mC0oX*?(1ZOA3=Zo28Z?Hs>rxzGgUwsYo8a1KZ?
zjZ%+YxxE4sCreS{hu?~%_GM5JhpS1bktK0=w$wHM^{^yE$@i9c)N
zkXANMh9Scn6J@=k;*bAQ0NW|7ZspRY@NNPt_%#WMzJ3V_e!hu`HUE!Z%;7`-ZFH*3
z|7_vkP6CXYCZP1yZSrT&+H6;+!-BZAcP|Evd;c}w3@zQx
zgDSdGA~?$`%9*)(bH_1!-x`d2B`a1|+O*uZJS*-BiLKdBVBlS|XS3$HsW`tl7t}mS
zp%~R%`-y7s@(+^Bmj_9blq{NfHm7Hd&;I=&e)2r({>1@)scWus`6w6DxiviE@+Pc5
zDfrwGF8`lTPe2(F!Nw{_>{@Y&ji$d6<9O^v**0mCDiKFoUF6L|J1m9~cC?S@zLg&P
zqhgpQwwd%sCR*O+cPcK{&(K{^;PRC{9|hQ>(>`-Elia<_lcJ(($r-6JD1soA1X9HVa4oPT*SLygGREVqL>AJg4o{r{b1idN-)i*swzmJJ^xcbY>G}HB4HIbo3-@~
zBW2t&3WmOyyOPXM?cG|Lh2V%7_zl$oiKS(~WqE}oKT7NhqFHtO4^_2!B>CDRhQ+0!
zd|Z<3UZ7s!!RP1J)+*>GM0cm`X;`v)>imTysk(v$_b<7*yXmoeLOrC$c-BkYr_@vT
zgnCGgIbp<>OLn^d>Hc+FlC78RE;7oMz8NJ{25Zj
zqPrl^+rPyL@M5q0eQ_?HwGs=SMs&SYUQ9+j6^N~hwJtE7LAUDuz`N~d9qWy_iGl$7
zMGA)0|4Wn1a;P1T0u!KV{xqa5t$9*VS5Lc|KhIa^sQ8x6(5#rU+~%g{|J@pCPkV
z*|Ti@-U@gq=x`;n$Bwkh27rKqeC~wZ1UG@J=ko411A-MYPfOjZRcfa$Bb9*2Nd(gk
zTMri%|Lfj;@7%Sje+A*Hs<})~WTg7^+SO(Z6rhZhmKG&SA`pZM+uDxRS&prRj86Z?
zL`$2}y-OZExKdNCMR;noCyoyePEPs_2cmE(;BlzMJ;B{ZYGbq^ZT^R!m2P0M2b=wW
z7W+pwnsyij5$Te2!U}X+bIENyJPIeTaJi>3r6dvu!QAvC+*Uew#gQkh(tJU$$xDa1
z4ZNBPJK9P@y>ZHy>S(6kY+jsPTFWlzgp~ftt7GlrswgK3VOqHa{+nFsi{Sd7r#8kMA`4K;JbG~q^e=x1|
ztBvVKiBj6&L?nTm&AX!G@Q>19lthKZMJSwt_OsFYdBn)3$SPa1gjC{GhTI6{eHo{53&!jVogBnm%J3#ur>`LeU
z^Q#<~#*~!AtW6AFjAQ<5y<%g%O_*G!lFeWn*?^$4PqT3llyXhKfca%`OAQ$5xo!Rf
z3FV&E5=#mH+aJ5TyK!ure`d(6O!|w1=c70bqPB~fg3n=V7_mY?QwPrtIM&~aF?c?R
zwOT;MqtwlzrYM1+!`l@FvSo?u-J2lfnVI5`8w6_P|N9YT*)rVZyaF>Zr$zn~+LIdd5!pp*R=UwpCxS`Wo0)vP_K*-14dnEC
zZf#N}DY@P|1gCzpUgrKYiQLz8Uk?frB9j*#>)i)xcJ2N9x9tu_gG0a-fBQ2=2Vm~F
z|H?xDD*=vI_S;o?*m$wg&AaQ1+0a$!7mims%2s&a>gUh?Z7!X3O-nzS?wWokeNp;(
zso6B&z30x~zfFxz{r2~rp`MfJCl^i11b@%+`_0azpZP0okU!0rod+S@3zwGW1n|e-
zF3@@jaF}=U`8_vl;zTC(bh-iPXxxg}?(~x-%^n_7YdW3jvtfyxvh+piXVP7Zntv6U
z9)%uzkHlCgZ{ClQLB3zV-7~nlVVkXA*3yW=`n>Orf8Vh^vZXH-a9KvI?C^EzolQfU
z0VyY-s}LtMCwnQ+Q
z&`Q4?dL_NeH>(nb9zaaFdon$bp68Kl%j8TC)VI1{ooSna2`({;E0qVKWL%LmaQv))5^#a%SQ?MYFTDU+l?VQ~n_i
zniXW~3S|mL_S<2KsVEZcRYn)o5Lm^MP%h}Lyqia|$+93X7Brn74_$?j@+|G1y|`pK
z%9-XceN$V9x1_eqTMlPx+0lKw_Vn(EW{=+r2vMy&@Z^^b2UIyiXcmGq8K!Nrd0C_E
znXHXrnjx5m_StsyU-OfvtDiikAnOy>m!>|wCf$W}s$_aXfAtfScbj~^%E6%gJQR!(oDYr@Lg
z2#(?;3Y-FR>9zE#fIK-1?eJpwlRHxTcOS}I@6W)-i`nQv+4eJM%eHGUW-5&w2lGY?
z8RlVO|9FQ|9L|6IHrMY{4|<(6q02r+ev;8mem%TxiR|q;^>Jx
zb{_v=5Q)|rLBA%I+{JYPozv!j6QD3YE!*XU!N4q?pDVQsb^#mS7DadQYRVt%LUcxe
zQ;B$m@^`!ViZVP~a1p&&`^B`2#rB=7!m_o@Y~0Y&I>|#P5dJGC$Sy
zfrFD#9~o!z9L>Y!%G(DHjAke&4EnK11tq4M#cI3UEt#1uhJyzTK^}Rvio1Z(-HlG7
z5r7UU>tyrRkPOncv%x{0qt*kX)bdir<5Az3@%JoW)tURZp9Ajx7@2}Uc&VHJZ^RyN
zu_W1Kf>tcYDkH94>`+dCJ@$p*RP_*|9U=NUVhTX0a{I_Ooe2Qn2#;K(Kq-#U=!u&@
zmW`DFs8j6*0ljaHlzjJ0o+}8&4D0<2Am{S`ybY*k@E}R5WAp#X+SjK>Qc)XgA)Y7z
zT4Bg%h_!U#f(offGLld}#WBrPj!4BuzCn%f%XN9+{?$Q%nWQt(@bNI8Okob9g&`-$
zWk(v+jveV(zS+@)W5hd}LX*EE1D^GgS(B%_cS&mw03gE%JJN`KiLP6R9ZjUVw4*6;
zB6nnvq((Ap3vWlER-F|Zlutoq>YG>_`f&6~Mr?fDYy-?jPAe3rX5Ox=ELS2zHm#{z
zHFde-XDi|g_#?60PxIwAa5nb_5f?yq(!s^&cXfFGG9cI4;E)0UsY<%lS3fGHLE
z07Sp3XMUBASXppnu~Ih<({B>Jj(f_JjJS2xBp{K5G7erHQg{R+i^Xg
zl(dYjoVlf&F*lz+#avb52gR&7UjzQDkS2{GGrJz)2fIV0ts1V
zRuZP8Joa|NdKwaXReGM&nxD6Nl34}7W$Js7zvG_|q;xQ(i8gXb3xS@^wzq;pO4Fi3
z-!j2!S$Yz-4emaoN2lc~A_>V$&uLwi@kNvw$HT@D*#*)eII{}@mznv8hpD`7dXl&G
z*{Z`Y*Md&>;-PdAzF!1W3~hHUJjv-vc#SrBPKN}cp#kH@zIii7nN=RF-zq#9VtO!U
zO)<(LHTMJ^X1OITiR?2|+hn9hf!$U7FPEnsQW6;wpoX=G*MPiDZK|2&5het>#yo76
z<6&&E+AlLvUcB8R3|h|b}coEk2eiA
zBFT**Xlqo6cpm=ZO#6ehHQyYbltXgk$dPPQvRtR};$oE$M;LRYDhYexJNKkFMsK+n-dK`Jv-?jYD*adqx&@OmD=$LOc!*ddkA^vz1xYwR0Sh>39$
O60K*Jwx9{=4gdhEci)o$
literal 0
HcmV?d00001
diff --git a/src/2.7.12/public/fonts/SourceSansPro/OFL.txt b/src/2.7.12/public/fonts/SourceSansPro/OFL.txt
new file mode 100644
index 00000000..3079b6db
--- /dev/null
+++ b/src/2.7.12/public/fonts/SourceSansPro/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2010-2018 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Bold.woff b/src/2.7.12/public/fonts/SourceSansPro/SourceSansPro-Bold.woff
new file mode 100644
index 0000000000000000000000000000000000000000..b6a721bd4e0a9ff81ea6d65e825d5529fd220c32
GIT binary patch
literal 151868
zcmafa19T&53Q>#>BSmOl(YSPHdbc6FZsMdHKHkzi-{Q-g@ixI@MiW
zy=(8n{_WbeyN`#WgoL81sv-bXnGb*n006*abOB%h$gkZ0b%+S7hyy^C&c7Z={FBTG
z^tweQB!~b23N8RhSSkRNtXFRuB2!F7TJnn@+ZPS=e-enKn79N0V#0j2r;~*sn6&U;u!ZB*~J4+0x9&^ovgHi{>BM99XG85iGw7zsfwn?uot<
z8Egi^kfoigCjg{+765>c1pr8-auZht?TkFX_<>}6(freQNa!RbfW48O*;m=qS2xN5
z032899Y}9S2N&0`{*ryspnWBx&p7~#Cy0rmsiC2v`MGW0r=1C3@013l`5`8}kYm%T
zBS_3g`Ey+$Y4|go5|c}p${zw833R|Tw%VTZW~*o&7v^pmgm0gUD-Fd|TzAry5B?e>Sk)+-))*M}+oWc`el`2A6VnW(kqzAEeGKBq(ZQSsGC2_M3%dU8trZhQn!;OG4+^
zT%pL$bjq(en&FUpU4wQ?!!^);dWHh7VAD<%PVACMyW){dTYN58(nf02t~pNm5@lTo
z^AXL?I5EAH-u0$+j>Fp%(-KOr%b_{i&Oh;isZa2$;RgXlU@@>^v&4|#l*iBq{ZKp-
zZrAAwO1SFt6?YlJ9OshhQoW_~4w-k^ss1kW4*D8
zYAN9>mI!Ot2sI|!Pq`eklsFREt0)(R^mkFD>mz}lSQm@0fD2=_x3Gs<2JA1n3Dw15
zbkccq#NH+EUvf`ycqBUSuyPAjUIG>7b_J7N?E;kmU`ohG_wB#O1kR=71KJtn-~4%N
zCDWHKa2-}XWZ&`cAJ2UZ^GILwGXC)WMz$ZcYe#7N!?78*;fVYHyYT}m)D=T*o6ecC
zBa8p(6JBZW{5m!uJ;J|z;A;N4v}bi7d1njm{2&*|Qcb2ws?adl=zh#eOW%qzvox4o
z@#&sN>Q216e}F`S)~sQ|DQXh4d717qZ=$gdsnq=I&V~(KF})Zk4gsJFX9i8Q@K=99N4kkRmKyj&R!0&F@Tm0C>t#@HV
z;2}2;@~g!lZjF-A6fJ%>n-wD-)j0Eyxo!(8pS4w
zJPKzSgy!+O*~{qx;fN9~o+UI=j
z$#xgV8HP6%?(%>Eh(#0HHRV+eP2Fip_bGRif#zdTgQT{>OKPHYc7qG;Mul1C)tVxP
z*6+8Zzi~C1d=aOx7-l#43eWM?^3}wMNQxN-S
zgYQ-6ccp2!Daqr!EXA8nHKz8N@f}O}4QXQ$Upty5V?E|ZCKczbx7Z}pjZiQ6z0YJ6
z?dVgn>-_+8AMcn)AUuMbsgI?Da%>ypS(forBiB%tkCL>zm@?nc--h6rG|H
z$?esYyA8=ECW7A5mR+9HB+QYjRg>7>$vxg;hZwe#Mm|J&%tzqxusv>?UhZ*8jHJFHonC@^_u7g
zgT~i4smoO-mp*J!eX1}qFK}6N*}`4=5USI7A3XWjQD>4
zc1`3~q4}~drI