From a45cbf41ef5e2ec66eeaedcf2fa94fd0c30cd54d Mon Sep 17 00:00:00 2001 From: Vlad Piersec Date: Thu, 16 Apr 2020 13:47:10 +0300 Subject: [PATCH] feat(prejoin_page): Add prejoin page --- conference.js | 310 ++++++++++------ config.js | 3 + css/_prejoin.scss | 255 +++++++++++++ css/_settings-button.scss | 1 + css/_video-preview.css | 8 +- css/main.scss | 1 + lang/main.json | 27 ++ react/features/base/conference/functions.js | 10 + react/features/base/config/configWhitelist.js | 1 + react/features/base/devices/middleware.js | 15 +- react/features/base/icons/svg/arrow-left.svg | 3 + react/features/base/icons/svg/arrow_down.svg | 2 +- react/features/base/icons/svg/close-x.svg | 3 + react/features/base/icons/svg/index.js | 2 + .../conference/components/web/Conference.js | 23 +- react/features/invite/functions.js | 13 +- react/features/prejoin/actionTypes.js | 65 ++++ react/features/prejoin/actions.js | 338 ++++++++++++++++++ react/features/prejoin/components/Prejoin.js | 197 ++++++++++ .../components/buttons/ActionButton.js | 51 +++ .../components/preview/CopyMeetingUrl.js | 197 ++++++++++ .../components/preview/DeviceStatus.js | 83 +++++ .../components/preview/ParticipantName.js | 80 +++++ .../prejoin/components/preview/Preview.js | 75 ++++ react/features/prejoin/functions.js | 228 ++++++++++++ react/features/prejoin/index.js | 7 + react/features/prejoin/logger.js | 5 + react/features/prejoin/middleware.js | 95 +++++ react/features/prejoin/reducer.js | 168 +++++++++ .../web/video/VideoSettingsContent.js | 6 +- .../toolbox/components/AudioMuteButton.js | 25 +- .../toolbox/components/VideoMuteButton.js | 31 +- .../components/web/AudioSettingsButton.js | 28 +- .../components/web/VideoSettingsButton.js | 27 +- .../features/toolbox/components/web/index.js | 2 + react/features/toolbox/functions.web.js | 36 ++ 36 files changed, 2274 insertions(+), 147 deletions(-) create mode 100644 css/_prejoin.scss create mode 100644 react/features/base/icons/svg/arrow-left.svg create mode 100644 react/features/base/icons/svg/close-x.svg create mode 100644 react/features/prejoin/actionTypes.js create mode 100644 react/features/prejoin/actions.js create mode 100644 react/features/prejoin/components/Prejoin.js create mode 100644 react/features/prejoin/components/buttons/ActionButton.js create mode 100644 react/features/prejoin/components/preview/CopyMeetingUrl.js create mode 100644 react/features/prejoin/components/preview/DeviceStatus.js create mode 100644 react/features/prejoin/components/preview/ParticipantName.js create mode 100644 react/features/prejoin/components/preview/Preview.js create mode 100644 react/features/prejoin/functions.js create mode 100644 react/features/prejoin/index.js create mode 100644 react/features/prejoin/logger.js create mode 100644 react/features/prejoin/middleware.js create mode 100644 react/features/prejoin/reducer.js diff --git a/conference.js b/conference.js index 37a3456d3e..2e50e68349 100644 --- a/conference.js +++ b/conference.js @@ -27,6 +27,13 @@ import { redirectToStaticPage, reloadWithStoredParams } from './react/features/app'; +import { + initPrejoin, + isPrejoinPageEnabled, + isPrejoinPageVisible, + replacePrejoinAudioTrack, + replacePrejoinVideoTrack +} from './react/features/prejoin'; import EventEmitter from 'events'; @@ -133,6 +140,15 @@ const eventEmitter = new EventEmitter(); let room; let connection; +/** + * The promise is used when the prejoin screen is shown. + * While the user configures the devices the connection can be made. + * + * @type {Promise} + * @private + */ +let _connectionPromise; + /** * This promise is used for chaining mutePresenterVideo calls in order to avoid calling GUM multiple times if it takes * a while to finish. @@ -471,28 +487,13 @@ export default { localVideo: null, /** - * Creates local media tracks and connects to a room. Will show error - * dialogs in case accessing the local microphone and/or camera failed. Will - * show guidance overlay for users on how to give access to camera and/or - * microphone. - * @param {string} roomName - * @param {object} options - * @param {boolean} options.startAudioOnly=false - if true then - * only audio track will be created and the audio only mode will be turned - * on. - * @param {boolean} options.startScreenSharing=false - if true - * should start with screensharing instead of camera video. - * @param {boolean} options.startWithAudioMuted - will start the conference - * without any audio tracks. - * @param {boolean} options.startWithVideoMuted - will start the conference - * without any video tracks. - * @returns {Promise.} + * Returns an object containing a promise which resolves with the created tracks & + * the errors resulting from that process. + * + * @returns {Promise, Object} */ - createInitialLocalTracksAndConnect(roomName, options = {}) { - let audioAndVideoError, - audioOnlyError, - screenSharingError, - videoOnlyError; + createInitialLocalTracks(options = {}) { + const errors = {}; const initialDevices = [ 'audio' ]; const requestedAudio = true; let requestedVideo = false; @@ -524,7 +525,7 @@ export default { // FIXME is there any simpler way to rewrite this spaghetti below ? if (options.startScreenSharing) { tryCreateLocalTracks = this._createDesktopTrack() - .then(desktopStream => { + .then(([ desktopStream ]) => { if (!requestedAudio) { return [ desktopStream ]; } @@ -533,21 +534,21 @@ export default { .then(([ audioStream ]) => [ desktopStream, audioStream ]) .catch(error => { - audioOnlyError = error; + errors.audioOnlyError = error; return [ desktopStream ]; }); }) .catch(error => { logger.error('Failed to obtain desktop stream', error); - screenSharingError = error; + errors.screenSharingError = error; return requestedAudio ? createLocalTracksF({ devices: [ 'audio' ] }, true) : []; }) .catch(error => { - audioOnlyError = error; + errors.audioOnlyError = error; return []; }); @@ -560,16 +561,16 @@ export default { if (requestedAudio && requestedVideo) { // Try audio only... - audioAndVideoError = err; + errors.audioAndVideoError = err; return ( createLocalTracksF({ devices: [ 'audio' ] }, true)); } else if (requestedAudio && !requestedVideo) { - audioOnlyError = err; + errors.audioOnlyError = err; return []; } else if (requestedVideo && !requestedAudio) { - videoOnlyError = err; + errors.videoOnlyError = err; return []; } @@ -580,7 +581,7 @@ export default { if (!requestedAudio) { logger.error('The impossible just happened', err); } - audioOnlyError = err; + errors.audioOnlyError = err; // Try video only... return requestedVideo @@ -592,7 +593,7 @@ export default { if (!requestedVideo) { logger.error('The impossible just happened', err); } - videoOnlyError = err; + errors.videoOnlyError = err; return []; }); @@ -603,8 +604,44 @@ export default { // cases, when auth is rquired, for instance, that won't happen until // the user inputs their credentials, but the dialog would be // overshadowed by the overlay. - tryCreateLocalTracks.then(() => - APP.store.dispatch(mediaPermissionPromptVisibilityChanged(false))); + tryCreateLocalTracks.then(tracks => { + APP.store.dispatch(mediaPermissionPromptVisibilityChanged(false)); + + return tracks; + }); + + return { + tryCreateLocalTracks, + errors + }; + }, + + /** + * Creates local media tracks and connects to a room. Will show error + * dialogs in case accessing the local microphone and/or camera failed. Will + * show guidance overlay for users on how to give access to camera and/or + * microphone. + * @param {string} roomName + * @param {object} options + * @param {boolean} options.startAudioOnly=false - if true then + * only audio track will be created and the audio only mode will be turned + * on. + * @param {boolean} options.startScreenSharing=false - if true + * should start with screensharing instead of camera video. + * @param {boolean} options.startWithAudioMuted - will start the conference + * without any audio tracks. + * @param {boolean} options.startWithVideoMuted - will start the conference + * without any video tracks. + * @returns {Promise.} + */ + createInitialLocalTracksAndConnect(roomName, options = {}) { + const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(options); + const { + audioAndVideoError, + audioOnlyError, + screenSharingError, + videoOnlyError + } = errors; return Promise.all([ tryCreateLocalTracks, connect(roomName) ]) .then(([ tracks, con ]) => { @@ -636,105 +673,132 @@ export default { }); }, + startConference(con, tracks) { + tracks.forEach(track => { + if ((track.isAudioTrack() && this.isLocalAudioMuted()) + || (track.isVideoTrack() && this.isLocalVideoMuted())) { + const mediaType = track.getType(); + + sendAnalytics( + createTrackMutedEvent(mediaType, 'initial mute')); + logger.log(`${mediaType} mute: initially muted.`); + track.mute(); + } + }); + logger.log(`Initialized with ${tracks.length} local tracks`); + + this._localTracksInitialized = true; + con.addEventListener(JitsiConnectionEvents.CONNECTION_FAILED, _connectionFailedHandler); + APP.connection = connection = con; + + // Desktop sharing related stuff: + this.isDesktopSharingEnabled + = JitsiMeetJS.isDesktopSharingEnabled(); + eventEmitter.emit(JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED, this.isDesktopSharingEnabled); + + APP.store.dispatch( + setDesktopSharingEnabled(this.isDesktopSharingEnabled)); + + this._createRoom(tracks); + APP.remoteControl.init(); + + // if user didn't give access to mic or camera or doesn't have + // them at all, we mark corresponding toolbar buttons as muted, + // so that the user can try unmute later on and add audio/video + // to the conference + if (!tracks.find(t => t.isAudioTrack())) { + this.setAudioMuteStatus(true); + } + + if (!tracks.find(t => t.isVideoTrack())) { + this.setVideoMuteStatus(true); + } + + if (config.iAmRecorder) { + this.recorder = new Recorder(); + } + + if (config.startSilent) { + sendAnalytics(createStartSilentEvent()); + APP.store.dispatch(showNotification({ + descriptionKey: 'notify.startSilentDescription', + titleKey: 'notify.startSilentTitle' + })); + } + + // XXX The API will take care of disconnecting from the XMPP + // server (and, thus, leaving the room) on unload. + return new Promise((resolve, reject) => { + (new ConferenceConnector(resolve, reject)).connect(); + }); + }, + /** - * Open new connection and join to the conference. - * @param {object} options - * @param {string} roomName - The name of the conference. + * Open new connection and join the conference when prejoin page is not enabled. + * If prejoin page is enabled open an new connection in the background + * and create local tracks. + * + * @param {{ roomName: string }} options * @returns {Promise} */ - init(options) { - this.roomName = options.roomName; + async init({ roomName }) { + const initialOptions = { + startAudioOnly: config.startAudioOnly, + startScreenSharing: config.startScreenSharing, + startWithAudioMuted: config.startWithAudioMuted + || config.startSilent + || isUserInteractionRequiredForUnmute(APP.store.getState()), + startWithVideoMuted: config.startWithVideoMuted + || isUserInteractionRequiredForUnmute(APP.store.getState()) + }; + + this.roomName = roomName; window.addEventListener('hashchange', this.onHashChange.bind(this), false); - return ( - + try { // Initialize the device list first. This way, when creating tracks // based on preferred devices, loose label matching can be done in // cases where the exact ID match is no longer available, such as // when the camera device has switched USB ports. // when in startSilent mode we want to start with audio muted - this._initDeviceList() - .catch(error => logger.warn( - 'initial device list initialization failed', error)) - .then(() => this.createInitialLocalTracksAndConnect( - options.roomName, { - startAudioOnly: config.startAudioOnly, - startScreenSharing: config.startScreenSharing, - startWithAudioMuted: config.startWithAudioMuted - || config.startSilent - || isUserInteractionRequiredForUnmute(APP.store.getState()), - startWithVideoMuted: config.startWithVideoMuted - || isUserInteractionRequiredForUnmute(APP.store.getState()) - })) - .then(([ tracks, con ]) => { - tracks.forEach(track => { - if ((track.isAudioTrack() && this.isLocalAudioMuted()) - || (track.isVideoTrack() && this.isLocalVideoMuted())) { - const mediaType = track.getType(); + await this._initDeviceList(); + } catch (error) { + logger.warn('initial device list initialization failed', error); + } - sendAnalytics( - createTrackMutedEvent(mediaType, 'initial mute')); - logger.log(`${mediaType} mute: initially muted.`); - track.mute(); - } - }); - logger.log(`initialized with ${tracks.length} local tracks`); - this._localTracksInitialized = true; - con.addEventListener( - JitsiConnectionEvents.CONNECTION_FAILED, - _connectionFailedHandler); - APP.connection = connection = con; + if (isPrejoinPageEnabled(APP.store.getState())) { + _connectionPromise = connect(roomName); - // Desktop sharing related stuff: - this.isDesktopSharingEnabled - = JitsiMeetJS.isDesktopSharingEnabled(); - eventEmitter.emit( - JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED, - this.isDesktopSharingEnabled); + const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions); + const tracks = await tryCreateLocalTracks; - APP.store.dispatch( - setDesktopSharingEnabled(this.isDesktopSharingEnabled)); + // Initialize device list a second time to ensure device labels + // get populated in case of an initial gUM acceptance; otherwise + // they may remain as empty strings. + this._initDeviceList(true); - this._createRoom(tracks); - APP.remoteControl.init(); + return APP.store.dispatch(initPrejoin(tracks, errors)); + } - // if user didn't give access to mic or camera or doesn't have - // them at all, we mark corresponding toolbar buttons as muted, - // so that the user can try unmute later on and add audio/video - // to the conference - if (!tracks.find(t => t.isAudioTrack())) { - this.setAudioMuteStatus(true); - } + const [ tracks, con ] = await this.createInitialLocalTracksAndConnect( + roomName, initialOptions); - if (!tracks.find(t => t.isVideoTrack())) { - this.setVideoMuteStatus(true); - } + this._initDeviceList(true); - // Initialize device list a second time to ensure device labels - // get populated in case of an initial gUM acceptance; otherwise - // they may remain as empty strings. - this._initDeviceList(true); + return this.startConference(con, tracks); + }, - if (config.iAmRecorder) { - this.recorder = new Recorder(); - } + /** + * Joins conference after the tracks have been configured in the prejoin screen. + * + * @param {Object[]} tracks - An array with the configured tracks + * @returns {Promise} + */ + async prejoinStart(tracks) { + const con = await _connectionPromise; - if (config.startSilent) { - sendAnalytics(createStartSilentEvent()); - APP.store.dispatch(showNotification({ - descriptionKey: 'notify.startSilentDescription', - titleKey: 'notify.startSilentTitle' - })); - } - - // XXX The API will take care of disconnecting from the XMPP - // server (and, thus, leaving the room) on unload. - return new Promise((resolve, reject) => { - (new ConferenceConnector(resolve, reject)).connect(); - }); - }) - ); + return this.startConference(con, tracks); }, /** @@ -1352,6 +1416,18 @@ export default { useVideoStream(newStream) { return new Promise((resolve, reject) => { _replaceLocalVideoTrackQueue.enqueue(onFinish => { + /** + * When the prejoin page is visible there is no conference object + * created. The prejoin tracks are managed separately, + * so this updates the prejoin video track. + */ + if (isPrejoinPageVisible(APP.store.getState())) { + return APP.store.dispatch(replacePrejoinVideoTrack(newStream)) + .then(resolve) + .catch(reject) + .then(onFinish); + } + APP.store.dispatch( replaceLocalTrack(this.localVideo, newStream, room)) .then(() => { @@ -1405,6 +1481,18 @@ export default { useAudioStream(newStream) { return new Promise((resolve, reject) => { _replaceLocalAudioTrackQueue.enqueue(onFinish => { + /** + * When the prejoin page is visible there is no conference object + * created. The prejoin tracks are managed separately, + * so this updates the prejoin audio stream. + */ + if (isPrejoinPageVisible(APP.store.getState())) { + return APP.store.dispatch(replacePrejoinAudioTrack(newStream)) + .then(resolve) + .catch(reject) + .then(onFinish); + } + APP.store.dispatch( replaceLocalTrack(this.localAudio, newStream, room)) .then(() => { diff --git a/config.js b/config.js index 9a297bcd10..80998a54fd 100644 --- a/config.js +++ b/config.js @@ -289,6 +289,9 @@ var config = { // and microsoftApiApplicationClientID // enableCalendarIntegration: false, + // When 'true', it shows an intermediate page before joining, where the user can configure its devices. + // prejoinPageEnabled: false, + // Stats // diff --git a/css/_prejoin.scss b/css/_prejoin.scss new file mode 100644 index 0000000000..99686f96e9 --- /dev/null +++ b/css/_prejoin.scss @@ -0,0 +1,255 @@ +.prejoin { + &-full-page { + background: #1C2025; + position: absolute; + width: 100%; + height: 100%; + z-index: $toolbarZ + 1; + } + + &-input-area-container { + position: absolute; + bottom: 128px; + width: 100%; + z-index: 1; + } + + &-input-area { + margin: 0 auto; + text-align: center; + width: 320px; + } + + &-title { + color: #fff; + font-size: 24px; + line-height: 32px; + margin-bottom: 16px; + } + + &-btn { + border-radius: 3px; + color: #fff; + cursor: pointer; + display: inline-block; + font-size: 15px; + line-height: 24px; + margin-bottom: 16px; + padding: 7px 16px; + text-align: center; + width: 286px; + + &--primary { + background: #0376DA; + border: 1px solid #0376DA; + } + + &--secondary { + background: #2A3A4B; + border: 1px solid #5E6D7A; + } + + &--text { + width: auto; + margin: 0; + padding: 0; + } + } + + &-text-btns { + display: flex; + justify-content: space-between; + } + + &-input-label { + color: #A4B8D1; + font-size: 13px; + line-height: 20px; + margin-top: 32px 0 8px 0; + text-align: center; + width: 100%; + } +} + +@mixin name-placeholder { + color: #fff; + font-weight: 300; + opacity: 0.6; +} + +.prejoin-preview { + height: 100%; + position: absolute; + width: 100%; + + &--no-video { + background: radial-gradient(50% 50% at 50% 50%, #5B6F80 0%, #365067 100%), #FFFFFF; + text-align: center; + } + + &-video { + height: 100%; + object-fit: cover; + position: absolute; + width: 100%; + } + + &-name { + color: #fff; + font-size: 19px; + line-height: 28px; + + &--editable { + background: none; + border: 0; + border-bottom: 1px solid #D1DBE8; + margin: 24px 0 16px 0; + outline: none; + text-align: center; + width: 100%; + + &::-webkit-input-placeholder { + @include name-placeholder; + } + &::-moz-placeholder { + @include name-placeholder; + } + &:-ms-input-placeholder { + @include name-placeholder; + } + } + } + + &-avatar.avatar { + background: #A4B8D1; + margin: 200px auto 0 auto; + } + + &-btn-container { + display: flex; + justify-content: center; + position: absolute; + bottom: 50px; + width: 100%; + z-index: 1; + + &> div { + margin: 0 12px; + } + + .settings-button-small-icon { + right: -8px; + + &--hovered { + right: -10px; + } + } + } + + &-overlay { + height: 100%; + position: absolute; + width: 100%; + z-index: 1; + background: linear-gradient(0deg, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), linear-gradient(360deg, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0) 54.25%); + } + + &-status { + align-items: center; + bottom: 0; + color: #fff; + display: flex; + font-size: 13px; + min-height: 24px; + justify-content: center; + position: absolute; + text-align: center; + width: 100%; + z-index: 1; + + &--warning { + background: rgba(241, 173, 51, 0.5) + } + &--ok { + background: rgba(49, 183, 106, 0.5); + } + } + + &-icon { + background-position: center; + background-repeat: no-repeat; + display: inline-block; + height: 16px; + margin-right: 8px; + width: 16px; + } + + &-error-desc { + margin-right: 4px; + } + + .settings-button-container { + width: 49px; + margin: 0 8px; + } +} + +.prejoin-copy { + &-meeting { + cursor: pointer; + color: #fff; + font-size: 15px; + font-weight: 300; + line-height: 24px; + position: relative; + } + + &-url { + max-width: 278px; + padding: 8px 10px; + overflow: hidden; + text-overflow: ellipsis; + } + + &-badge { + border-radius: 4px; + height: 100%; + line-height: 38px; + position: absolute; + padding-left: 10px; + text-align: left; + top: 0; + width: 100%; + + &--hover { + background: #1C2025; + } + + &--done { + background: #31B76A; + } + } + + &-icon { + position: absolute; + right: 8px; + top: 8px; + + &--white { + &> svg > path { + fill: #fff + } + } + + &--light { + &> svg > path { + fill: #D1DBE8; + } + } + } + + &-textarea { + position: absolute; + left: -9999px; + } +} diff --git a/css/_settings-button.scss b/css/_settings-button.scss index 0cb8ebaa2a..46b776a993 100644 --- a/css/_settings-button.scss +++ b/css/_settings-button.scss @@ -57,6 +57,7 @@ width: 16px; &> svg { + fill: #5e6d7a; margin-top: 5px; } diff --git a/css/_video-preview.css b/css/_video-preview.css index e770d20c39..3834586829 100644 --- a/css/_video-preview.css +++ b/css/_video-preview.css @@ -1,7 +1,11 @@ .video-preview { background: none; max-height: 290px; - overflow: auto; + + &-container { + overflow: auto; + padding: 16px; + } &-entry { cursor: pointer; @@ -61,6 +65,6 @@ // Override @atlaskit/InlineDialog container which is made with styled components & > div > div:nth-child(2) > div > div { outline: none; - padding: 16px; + padding: 0; } } diff --git a/css/main.scss b/css/main.scss index e6269786f3..70b0470198 100644 --- a/css/main.scss +++ b/css/main.scss @@ -90,5 +90,6 @@ $flagsImagePath: "../images/"; @import 'meter'; @import 'audio-preview'; @import 'video-preview'; +@import 'prejoin'; /* Modules END */ diff --git a/lang/main.json b/lang/main.json index da4be2ce14..aae7eb7a81 100644 --- a/lang/main.json +++ b/lang/main.json @@ -476,6 +476,33 @@ "passwordSetRemotely": "set by another participant", "passwordDigitsOnly": "Up to {{number}} digits", "poweredby": "powered by", + "prejoin": { + "audioAndVideoError": "Audio and video error:", + "audioOnlyError": "Audio error:", + "audioTrackError": "Could not create audio track.", + "callMe": "Call me", + "callMeAtNumber": "Call me at this number:", + "configuringDevices": "Configuring devices...", + "connectedWithAudioQ": "You’re connected with audio?", + "copyAndShare": "Copy & share meeting link", + "dialInMeeting": "Dial into the meeting", + "dialInPin": "Dial into the meeting and enter PIN code:", + "dialing": "Dialing", + "iWantToDialIn": "I want to dial in", + "joinAudioByPhone": "Join with phone audio", + "joinMeeting": "Join meeting", + "joinWithoutAudio": "Join without audio", + "initiated": "Call initiated", + "linkCopied": "Link copied to clipboard", + "lookGood": "Speaker and microphone look good", + "or": "or", + "calling": "Calling", + "startWithPhone": "Start with phone audio", + "screenSharingError": "Screen sharing error:", + "videoOnlyError": "Video error:", + "videoTrackError": "Could not create video track.", + "viewAllNumbers": "view all numbers" + }, "presenceStatus": { "busy": "Busy", "calling": "Calling...", diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index 7d41cf115b..670018a4f7 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -246,6 +246,16 @@ export function getNearestReceiverVideoQualityLevel(availableHeight: number) { return selectedLevel; } +/** + * Returns the stored room name. + * + * @param {Object} state - The current state of the app. + * @returns {string} + */ +export function getRoomName(state: Object): string { + return state['features/base/conference'].room; +} + /** * Handle an error thrown by the backend (i.e. {@code lib-jitsi-meet}) while * manipulating a conference participant (e.g. Pin or select participant). diff --git a/react/features/base/config/configWhitelist.js b/react/features/base/config/configWhitelist.js index 35c8acce53..da6e744eb9 100644 --- a/react/features/base/config/configWhitelist.js +++ b/react/features/base/config/configWhitelist.js @@ -131,6 +131,7 @@ export default [ 'p2p', 'pcStatsInterval', 'preferH264', + 'prejoinPageEnabled', 'requireDisplayName', 'remoteVideoMenu', 'resolution', diff --git a/react/features/base/devices/middleware.js b/react/features/base/devices/middleware.js index 50cde651bb..74cc2b59e6 100644 --- a/react/features/base/devices/middleware.js +++ b/react/features/base/devices/middleware.js @@ -18,6 +18,8 @@ import { SET_AUDIO_INPUT_DEVICE, SET_VIDEO_INPUT_DEVICE } from './actionTypes'; +import { replaceAudioTrackById, replaceVideoTrackById } from '../../prejoin/actions'; +import { isPrejoinPageVisible } from '../../prejoin/functions'; import { showNotification, showWarningNotification } from '../../notifications'; import { updateSettings } from '../settings'; import { formatDeviceLabel, setAudioOutputDeviceId } from './functions'; @@ -98,10 +100,18 @@ MiddlewareRegistry.register(store => next => action => { break; } case SET_AUDIO_INPUT_DEVICE: - APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId); + if (isPrejoinPageVisible(store.getState())) { + store.dispatch(replaceAudioTrackById(action.deviceId)); + } else { + APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId); + } break; case SET_VIDEO_INPUT_DEVICE: - APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId); + if (isPrejoinPageVisible(store.getState())) { + store.dispatch(replaceVideoTrackById(action.deviceId)); + } else { + APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId); + } break; case CHECK_AND_NOTIFY_FOR_NEW_DEVICE: _checkAndNotifyForNewDevice(store, action.newDevices, action.oldDevices); @@ -111,7 +121,6 @@ MiddlewareRegistry.register(store => next => action => { return next(action); }); - /** * Does extra sync up on properties that may need to be updated after the * conference was joined. diff --git a/react/features/base/icons/svg/arrow-left.svg b/react/features/base/icons/svg/arrow-left.svg new file mode 100644 index 0000000000..2d5c0c5b12 --- /dev/null +++ b/react/features/base/icons/svg/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/arrow_down.svg b/react/features/base/icons/svg/arrow_down.svg index 7cd9b05e9e..c9265fa41f 100644 --- a/react/features/base/icons/svg/arrow_down.svg +++ b/react/features/base/icons/svg/arrow_down.svg @@ -1,3 +1,3 @@ - + diff --git a/react/features/base/icons/svg/close-x.svg b/react/features/base/icons/svg/close-x.svg new file mode 100644 index 0000000000..ed4b32cc73 --- /dev/null +++ b/react/features/base/icons/svg/close-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 0193277d50..61007d945d 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -4,6 +4,7 @@ export { default as IconAdd } from './add.svg'; export { default as IconAddPeople } from './link.svg'; export { default as IconArrowBack } from './arrow_back.svg'; export { default as IconArrowDown } from './arrow_down.svg'; +export { default as IconArrowLeft } from './arrow-left.svg'; export { default as IconAudioOnly } from './visibility.svg'; export { default as IconAudioOnlyOff } from './visibility-off.svg'; export { default as IconAudioRoute } from './volume.svg'; @@ -16,6 +17,7 @@ export { default as IconChatSend } from './send.svg'; export { default as IconChatUnread } from './chat-unread.svg'; export { default as IconCheck } from './check.svg'; export { default as IconClose } from './close.svg'; +export { default as IconCloseX } from './close-x.svg'; export { default as IconClosedCaption } from './closed_caption.svg'; export { default as IconConnectionActive } from './gsm-bars.svg'; export { default as IconConnectionInactive } from './ninja.svg'; diff --git a/react/features/conference/components/web/Conference.js b/react/features/conference/components/web/Conference.js index 337ba0ae03..9a98079ec8 100644 --- a/react/features/conference/components/web/Conference.js +++ b/react/features/conference/components/web/Conference.js @@ -13,6 +13,7 @@ import { Chat } from '../../../chat'; import { Filmstrip } from '../../../filmstrip'; import { CalleeInfoContainer } from '../../../invite'; import { LargeVideo } from '../../../large-video'; +import { Prejoin, isPrejoinPageVisible } from '../../../prejoin'; import { LAYOUTS, getCurrentLayout } from '../../../video-layout'; import { @@ -84,6 +85,11 @@ type Props = AbstractProps & { */ _roomName: string, + /** + * If prejoin page is visible or not. + */ + _showPrejoin: boolean, + dispatch: Function, t: Function } @@ -178,16 +184,22 @@ class Conference extends AbstractConference { // interfaceConfig is obsolete but legacy support is required. filmStripOnly: filmstripOnly } = interfaceConfig; + const { + _iAmRecorder, + _layoutClassName, + _showPrejoin + } = this.props; const hideVideoQualityLabel = filmstripOnly || VIDEO_QUALITY_LABEL_DISABLED - || this.props._iAmRecorder; + || _iAmRecorder; return (
+
@@ -197,11 +209,13 @@ class Conference extends AbstractConference {
- { filmstripOnly || } + { filmstripOnly || _showPrejoin || } { filmstripOnly || } { this.renderNotificationsContainer() } + { !filmstripOnly && _showPrejoin && } +
); @@ -268,7 +282,8 @@ function _mapStateToProps(state) { ...abstractMapStateToProps(state), _iAmRecorder: state['features/base/config'].iAmRecorder, _layoutClassName: LAYOUT_CLASSNAMES[getCurrentLayout(state)], - _roomName: getConferenceNameForTitle(state) + _roomName: getConferenceNameForTitle(state), + _showPrejoin: isPrejoinPageVisible(state) }; } diff --git a/react/features/invite/functions.js b/react/features/invite/functions.js index 1e58959a2e..cfac4730dc 100644 --- a/react/features/invite/functions.js +++ b/react/features/invite/functions.js @@ -298,9 +298,8 @@ export function invitePeopleAndChatRooms( // eslint-disable-line max-params */ export function isAddPeopleEnabled(state: Object): boolean { const { peopleSearchUrl } = state['features/base/config']; - const { isGuest } = state['features/base/jwt']; - return !isGuest && Boolean(peopleSearchUrl); + return !isGuest(state) && Boolean(peopleSearchUrl); } /** @@ -316,6 +315,16 @@ export function isDialOutEnabled(state: Object): boolean { && conference && conference.isSIPCallingSupported(); } +/** + * Determines if the current user is guest or not. + * + * @param {Object} state - Current state. + * @returns {boolean} + */ +export function isGuest(state: Object): boolean { + return state['features/base/jwt'].isGuest; +} + /** * Checks whether a string looks like it could be for a phone number. * diff --git a/react/features/prejoin/actionTypes.js b/react/features/prejoin/actionTypes.js new file mode 100644 index 0000000000..146af27449 --- /dev/null +++ b/react/features/prejoin/actionTypes.js @@ -0,0 +1,65 @@ +/** + * Action type to add a video track to the store. + */ +export const ADD_PREJOIN_VIDEO_TRACK = 'ADD_PREJOIN_VIDEO_TRACK'; + +/** + * Action type to add an audio track to the store. + */ +export const ADD_PREJOIN_AUDIO_TRACK = 'ADD_PREJOIN_AUDIO_TRACK'; + +/** + * Action type to add a content sharing track to the store. + */ +export const ADD_PREJOIN_CONTENT_SHARING_TRACK + = 'ADD_PREJOIN_CONTENT_SHARING_TRACK'; + +/** + * Action type to signal the start of the conference. + */ +export const PREJOIN_START_CONFERENCE = 'PREJOIN_START_CONFERENCE'; + +/** + * Action type to set the status of the device. + */ +export const SET_DEVICE_STATUS = 'SET_DEVICE_STATUS'; + +/** + * Action type to set the visiblity of the 'JoinByPhone' dialog. + */ +export const SET_JOIN_BY_PHONE_DIALOG_VISIBLITY = 'SET_JOIN_BY_PHONE_DIALOG_VISIBLITY'; + +/** + * Action type to disable the audio while on prejoin page. + */ +export const SET_PREJOIN_AUDIO_DISABLED = 'SET_PREJOIN_AUDIO_DISABLED'; + +/** + * Action type to mute/unmute the audio while on prejoin page. + */ +export const SET_PREJOIN_AUDIO_MUTED = 'SET_PREJOIN_AUDIO_MUTED'; + +/** + * Action type to set the errors while creating the prejoin streams. + */ +export const SET_PREJOIN_DEVICE_ERRORS = 'SET_PREJOIN_DEVICE_ERRORS'; + +/** + * Action type to set the name of the user. + */ +export const SET_PREJOIN_NAME = 'SET_PREJOIN_NAME'; + +/** + * Action type to set the visibility of the prejoin page. + */ +export const SET_PREJOIN_PAGE_VISIBILITY = 'SET_PREJOIN_PAGE_VISIBILITY'; + +/** + * Action type to mute/unmute the video while on prejoin page. + */ +export const SET_PREJOIN_VIDEO_DISABLED = 'SET_PREJOIN_VIDEO_DISABLED'; + +/** + * Action type to mute/unmute the video while on prejoin page. + */ +export const SET_PREJOIN_VIDEO_MUTED = 'SET_PREJOIN_VIDEO_MUTED'; diff --git a/react/features/prejoin/actions.js b/react/features/prejoin/actions.js new file mode 100644 index 0000000000..03b4d29a33 --- /dev/null +++ b/react/features/prejoin/actions.js @@ -0,0 +1,338 @@ +// @flow + +import { + ADD_PREJOIN_AUDIO_TRACK, + ADD_PREJOIN_CONTENT_SHARING_TRACK, + ADD_PREJOIN_VIDEO_TRACK, + PREJOIN_START_CONFERENCE, + SET_DEVICE_STATUS, + SET_JOIN_BY_PHONE_DIALOG_VISIBLITY, + SET_PREJOIN_AUDIO_DISABLED, + SET_PREJOIN_AUDIO_MUTED, + SET_PREJOIN_DEVICE_ERRORS, + SET_PREJOIN_NAME, + SET_PREJOIN_PAGE_VISIBILITY, + SET_PREJOIN_VIDEO_DISABLED, + SET_PREJOIN_VIDEO_MUTED +} from './actionTypes'; +import { createLocalTrack } from '../base/lib-jitsi-meet'; +import { getAudioTrack, getVideoTrack } from './functions'; +import logger from './logger'; + +/** + * Action used to add an audio track to the store. + * + * @param {Object} value - The track to be added. + * @returns {Object} + */ +export function addPrejoinAudioTrack(value: Object) { + return { + type: ADD_PREJOIN_AUDIO_TRACK, + value + }; +} + +/** + * Action used to add a video track to the store. + * + * @param {Object} value - The track to be added. + * @returns {Object} + */ +export function addPrejoinVideoTrack(value: Object) { + return { + type: ADD_PREJOIN_VIDEO_TRACK, + value + }; +} + +/** + * Action used to add a content sharing track to the store. + * + * @param {Object} value - The track to be added. + * @returns {Object} + */ +export function addPrejoinContentSharingTrack(value: Object) { + return { + type: ADD_PREJOIN_CONTENT_SHARING_TRACK, + value + }; +} + +/** + * Adds all the newly created tracks to store on init. + * + * @param {Object[]} tracks - The newly created tracks. + * @param {Object} errors - The errors from creating the tracks. + * + * @returns {Function} + */ +export function initPrejoin(tracks: Object[], errors: Object) { + return async function(dispatch: Function) { + const audioTrack = tracks.find(t => t.isAudioTrack()); + const videoTrack = tracks.find(t => t.isVideoTrack()); + + dispatch(setPrejoinDeviceErrors(errors)); + + if (audioTrack) { + dispatch(addPrejoinAudioTrack(audioTrack)); + } else { + dispatch(setAudioDisabled()); + } + + if (videoTrack) { + if (videoTrack.videoType === 'desktop') { + dispatch(addPrejoinContentSharingTrack(videoTrack)); + dispatch(setPrejoinVideoDisabled(true)); + } else { + dispatch(addPrejoinVideoTrack(videoTrack)); + } + } else { + dispatch(setPrejoinVideoDisabled(true)); + } + }; +} + +/** + * Joins the conference. + * + * @returns {Function} + */ +export function joinConference() { + return function(dispatch: Function) { + dispatch(setPrejoinPageVisibility(false)); + dispatch(startConference()); + }; +} + +/** + * Joins the conference without audio. + * + * @returns {Function} + */ +export function joinConferenceWithoutAudio() { + return async function(dispatch: Function, getState: Function) { + const audioTrack = getAudioTrack(getState()); + + if (audioTrack) { + await dispatch(replacePrejoinAudioTrack(null)); + } + dispatch(setAudioDisabled()); + dispatch(joinConference()); + }; +} + +/** + * Replaces the existing audio track with a new one. + * + * @param {Object} track - The new track. + * @returns {Function} + */ +export function replacePrejoinAudioTrack(track: Object) { + return async (dispatch: Function, getState: Function) => { + const oldTrack = getAudioTrack(getState()); + + oldTrack && await oldTrack.dispose(); + dispatch(addPrejoinAudioTrack(track)); + }; +} + +/** + * Creates a new audio track based on a device id and replaces the current one. + * + * @param {string} deviceId - The deviceId of the microphone. + * @returns {Function} + */ +export function replaceAudioTrackById(deviceId: string) { + return async (dispatch: Function) => { + try { + const track = await createLocalTrack('audio', deviceId); + + dispatch(replacePrejoinAudioTrack(track)); + } catch (err) { + dispatch(setDeviceStatusWarning('prejoin.audioTrackError')); + logger.log('Error replacing audio track', err); + } + }; +} + +/** + * Replaces the existing video track with a new one. + * + * @param {Object} track - The new track. + * @returns {Function} + */ +export function replacePrejoinVideoTrack(track: Object) { + return async (dispatch: Function, getState: Function) => { + const oldTrack = getVideoTrack(getState()); + + oldTrack && await oldTrack.dispose(); + dispatch(addPrejoinVideoTrack(track)); + }; +} + +/** + * Creates a new video track based on a device id and replaces the current one. + * + * @param {string} deviceId - The deviceId of the camera. + * @returns {Function} + */ +export function replaceVideoTrackById(deviceId: Object) { + return async (dispatch: Function) => { + try { + const track = await createLocalTrack('video', deviceId); + + dispatch(replacePrejoinVideoTrack(track)); + } catch (err) { + dispatch(setDeviceStatusWarning('prejoin.videoTrackError')); + logger.log('Error replacing video track', err); + } + }; +} + + +/** + * Action used to mark audio muted. + * + * @param {boolean} value - True for muted. + * @returns {Object} + */ +export function setPrejoinAudioMuted(value: boolean) { + return { + type: SET_PREJOIN_AUDIO_MUTED, + value + }; +} + +/** + * Action used to mark video disabled. + * + * @param {boolean} value - True for muted. + * @returns {Object} + */ +export function setPrejoinVideoDisabled(value: boolean) { + return { + type: SET_PREJOIN_VIDEO_DISABLED, + value + }; +} + + +/** + * Action used to mark video muted. + * + * @param {boolean} value - True for muted. + * @returns {Object} + */ +export function setPrejoinVideoMuted(value: boolean) { + return { + type: SET_PREJOIN_VIDEO_MUTED, + value + }; +} + +/** + * Action used to mark audio as disabled. + * + * @returns {Object} + */ +export function setAudioDisabled() { + return { + type: SET_PREJOIN_AUDIO_DISABLED + }; +} + +/** + * Sets the device status as OK with the corresponding text. + * + * @param {string} deviceStatusText - The text to be set. + * @returns {Object} + */ +export function setDeviceStatusOk(deviceStatusText: string) { + return { + type: SET_DEVICE_STATUS, + value: { + deviceStatusText, + deviceStatusType: 'ok' + } + }; +} + +/** + * Sets the device status as 'warning' with the corresponding text. + * + * @param {string} deviceStatusText - The text to be set. + * @returns {Object} + */ +export function setDeviceStatusWarning(deviceStatusText: string) { + return { + type: SET_DEVICE_STATUS, + value: { + deviceStatusText, + deviceStatusType: 'warning' + } + }; +} + + +/** + * Action used to set the visiblitiy of the 'JoinByPhoneDialog'. + * + * @param {boolean} value - The value. + * @returns {Object} + */ +export function setJoinByPhoneDialogVisiblity(value: boolean) { + return { + type: SET_JOIN_BY_PHONE_DIALOG_VISIBLITY, + value + }; +} + +/** + * Action used to set the initial errors after creating the tracks. + * + * @param {Object} value - The track errors. + * @returns {Object} + */ +export function setPrejoinDeviceErrors(value: Object) { + return { + type: SET_PREJOIN_DEVICE_ERRORS, + value + }; +} + +/** + * Action used to set the name of the guest user. + * + * @param {string} value - The name. + * @returns {Object} + */ +export function setPrejoinName(value: string) { + return { + type: SET_PREJOIN_NAME, + value + }; +} + +/** + * Action used to set the visiblity of the prejoin page. + * + * @param {boolean} value - The value. + * @returns {Object} + */ +export function setPrejoinPageVisibility(value: boolean) { + return { + type: SET_PREJOIN_PAGE_VISIBILITY, + value + }; +} + +/** + * Action used to mark the start of the conference. + * + * @returns {Object} + */ +function startConference() { + return { + type: PREJOIN_START_CONFERENCE + }; +} diff --git a/react/features/prejoin/components/Prejoin.js b/react/features/prejoin/components/Prejoin.js new file mode 100644 index 0000000000..bc1934df21 --- /dev/null +++ b/react/features/prejoin/components/Prejoin.js @@ -0,0 +1,197 @@ +// @flow + +import React, { Component } from 'react'; +import { + joinConference as joinConferenceAction, + joinConferenceWithoutAudio as joinConferenceWithoutAudioAction, + setJoinByPhoneDialogVisiblity as setJoinByPhoneDialogVisiblityAction, + setPrejoinName +} from '../actions'; +import { getRoomName } from '../../base/conference'; +import { translate } from '../../base/i18n'; +import { connect } from '../../base/redux'; +import ActionButton from './buttons/ActionButton'; +import { + areJoinByPhoneButtonsVisible, + getPrejoinName, + isDeviceStatusVisible, + isJoinByPhoneDialogVisible +} from '../functions'; +import { isGuest } from '../../invite'; +import CopyMeetingUrl from './preview/CopyMeetingUrl'; +import DeviceStatus from './preview/DeviceStatus'; +import ParticipantName from './preview/ParticipantName'; +import Preview from './preview/Preview'; +import { VideoSettingsButton, AudioSettingsButton } from '../../toolbox'; + +type Props = { + + /** + * Flag signaling if the device status is visible or not. + */ + deviceStatusVisible: boolean, + + /** + * Flag signaling if a user is logged in or not. + */ + isAnonymousUser: boolean, + + /** + * Joins the current meeting. + */ + joinConference: Function, + + /** + * Joins the current meeting without audio. + */ + joinConferenceWithoutAudio: Function, + + /** + * The name of the user that is about to join. + */ + name: string, + + /** + * Sets the name for the joining user. + */ + setName: Function, + + /** + * The name of the meeting that is about to be joined. + */ + roomName: string, + + /** + * Sets visibilit of the 'JoinByPhoneDialog'. + */ + setJoinByPhoneDialogVisiblity: Function, + + /** + * If 'JoinByPhoneDialog' is visible or not. + */ + showDialog: boolean, + + /** + * If join by phone buttons should be visible. + */ + showJoinByPhoneButtons: boolean, + + /** + * Used for translation. + */ + t: Function, +}; + +/** + * This component is displayed before joining a meeting. + */ +class Prejoin extends Component { + /** + * Initializes a new {@code Prejoin} instance. + * + * @inheritdoc + */ + constructor(props) { + super(props); + + this._showDialog = this._showDialog.bind(this); + } + + _showDialog: () => void; + + /** + * Displays the dialog for joining a meeting by phone. + * + * @returns {undefined} + */ + _showDialog() { + this.props.setJoinByPhoneDialogVisiblity(true); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { + deviceStatusVisible, + isAnonymousUser, + joinConference, + joinConferenceWithoutAudio, + name, + setName, + showJoinByPhoneButtons, + t + } = this.props; + const { _showDialog } = this; + + return ( +
+ +
+
+
+ {t('prejoin.joinMeeting')} +
+ + + + { t('calendarSync.join') } + + {showJoinByPhoneButtons + &&
+ + { t('prejoin.joinWithoutAudio') } + + + { t('prejoin.joinAudioByPhone') } + +
} +
+
+
+ + +
+ { deviceStatusVisible && } +
+ ); + } +} + +/** + * Maps (parts of) the redux state to the React {@code Component} props. + * + * @param {Object} state - The redux state. + * @returns {Object} + */ +function mapStateToProps(state): Object { + return { + isAnonymousUser: isGuest(state), + deviceStatusVisible: isDeviceStatusVisible(state), + name: getPrejoinName(state), + roomName: getRoomName(state), + showDialog: isJoinByPhoneDialogVisible(state), + showJoinByPhoneButtons: areJoinByPhoneButtonsVisible(state) + }; +} + +const mapDispatchToProps = { + joinConferenceWithoutAudio: joinConferenceWithoutAudioAction, + joinConference: joinConferenceAction, + setJoinByPhoneDialogVisiblity: setJoinByPhoneDialogVisiblityAction, + setName: setPrejoinName +}; + +export default connect(mapStateToProps, mapDispatchToProps)(translate(Prejoin)); diff --git a/react/features/prejoin/components/buttons/ActionButton.js b/react/features/prejoin/components/buttons/ActionButton.js new file mode 100644 index 0000000000..ffc07a6171 --- /dev/null +++ b/react/features/prejoin/components/buttons/ActionButton.js @@ -0,0 +1,51 @@ +// @flow + +import React from 'react'; +const classNameByType = { + primary: 'prejoin-btn--primary', + secondary: 'prejoin-btn--secondary', + text: 'prejoin-btn--text' +}; + +type Props = { + + /** + * Text of the button. + */ + children: React$Node, + + /** + * Text css class of the button. + */ + className?: string, + + /** + * The type of th button: primary, secondary, text. + */ + type: string, + + /** + * OnClick button handler. + */ + onClick: Function, +}; + +/** + * Button used for prejoin actions: Join/Join without audio/Join by phone. + * + * @returns {ReactElement} + */ +function ActionButton({ children, className, type, onClick }: Props) { + const ownClassName = `prejoin-btn ${classNameByType[type]}`; + const cls = className ? `${className} ${ownClassName}` : ownClassName; + + return ( +
+ {children} +
+ ); +} + +export default ActionButton; diff --git a/react/features/prejoin/components/preview/CopyMeetingUrl.js b/react/features/prejoin/components/preview/CopyMeetingUrl.js new file mode 100644 index 0000000000..69c992d840 --- /dev/null +++ b/react/features/prejoin/components/preview/CopyMeetingUrl.js @@ -0,0 +1,197 @@ +// @flow + +import React, { Component } from 'react'; +import { connect } from '../../../base/redux'; +import { translate } from '../../../base/i18n'; +import { getCurrentConferenceUrl } from '../../../base/connection'; +import { Icon, IconCopy, IconCheck } from '../../../base/icons'; +import logger from '../../logger'; + +type Props = { + + /** + * The meeting url. + */ + url: string, + + /** + * Used for translation. + */ + t: Function +}; + +type State = { + + /** + * If true it shows the 'copy link' message. + */ + showCopyLink: boolean, + + /** + * If true it shows the 'link copied' message. + */ + showLinkCopied: boolean, +}; + +const COPY_TIMEOUT = 2000; + +/** + * Component used to copy meeting url on prejoin page. + */ +class CopyMeetingUrl extends Component { + + textarea: Object; + + /** + * Initializes a new {@code Prejoin} instance. + * + * @inheritdoc + */ + constructor(props) { + super(props); + + this.textarea = React.createRef(); + this.state = { + showCopyLink: false, + showLinkCopied: false + }; + this._copyUrl = this._copyUrl.bind(this); + this._hideCopyLink = this._hideCopyLink.bind(this); + this._hideLinkCopied = this._hideLinkCopied.bind(this); + this._showCopyLink = this._showCopyLink.bind(this); + this._showLinkCopied = this._showLinkCopied.bind(this); + } + + _copyUrl: () => void; + + /** + * Callback invoked to copy the url to clipboard. + * + * @returns {void} + */ + _copyUrl() { + const textarea = this.textarea.current; + + try { + textarea.select(); + document.execCommand('copy'); + textarea.blur(); + this._showLinkCopied(); + window.setTimeout(this._hideLinkCopied, COPY_TIMEOUT); + } catch (err) { + logger.error('error when copying the meeting url'); + } + } + + _hideLinkCopied: () => void; + + /** + * Hides the 'Link copied' message. + * + * @private + * @returns {void} + */ + _hideLinkCopied() { + this.setState({ + showLinkCopied: false + }); + } + + _hideCopyLink: () => void; + + /** + * Hides the 'Copy link' text. + * + * @private + * @returns {void} + */ + _hideCopyLink() { + this.setState({ + showCopyLink: false + }); + } + + _showCopyLink: () => void; + + /** + * Shows the dark 'Copy link' text on hover. + * + * @private + * @returns {void} + */ + _showCopyLink() { + this.setState({ + showCopyLink: true + }); + } + + _showLinkCopied: () => void; + + /** + * Shows the green 'Link copied' message. + * + * @private + * @returns {void} + */ + _showLinkCopied() { + this.setState({ + showLinkCopied: true, + showCopyLink: false + }); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { showCopyLink, showLinkCopied } = this.state; + const { url, t } = this.props; + const { _copyUrl, _showCopyLink, _hideCopyLink } = this; + const src = showLinkCopied ? IconCheck : IconCopy; + const iconCls = showCopyLink || showCopyLink ? 'prejoin-copy-icon--white' : 'prejoin-copy-icon--light'; + + return ( +
+
{url}
+ {showCopyLink &&
+ {t('prejoin.copyAndShare')} +
} + {showLinkCopied &&
+ {t('prejoin.linkCopied')} +
} + +