Browse Source

feat(prejoin_page): Add prejoin page

master
Vlad Piersec 4 years ago
parent
commit
a45cbf41ef
36 changed files with 2275 additions and 148 deletions
  1. 200
    112
      conference.js
  2. 3
    0
      config.js
  3. 255
    0
      css/_prejoin.scss
  4. 1
    0
      css/_settings-button.scss
  5. 6
    2
      css/_video-preview.css
  6. 1
    0
      css/main.scss
  7. 27
    0
      lang/main.json
  8. 10
    0
      react/features/base/conference/functions.js
  9. 1
    0
      react/features/base/config/configWhitelist.js
  10. 12
    3
      react/features/base/devices/middleware.js
  11. 3
    0
      react/features/base/icons/svg/arrow-left.svg
  12. 1
    1
      react/features/base/icons/svg/arrow_down.svg
  13. 3
    0
      react/features/base/icons/svg/close-x.svg
  14. 2
    0
      react/features/base/icons/svg/index.js
  15. 19
    4
      react/features/conference/components/web/Conference.js
  16. 11
    2
      react/features/invite/functions.js
  17. 65
    0
      react/features/prejoin/actionTypes.js
  18. 338
    0
      react/features/prejoin/actions.js
  19. 197
    0
      react/features/prejoin/components/Prejoin.js
  20. 51
    0
      react/features/prejoin/components/buttons/ActionButton.js
  21. 197
    0
      react/features/prejoin/components/preview/CopyMeetingUrl.js
  22. 83
    0
      react/features/prejoin/components/preview/DeviceStatus.js
  23. 80
    0
      react/features/prejoin/components/preview/ParticipantName.js
  24. 75
    0
      react/features/prejoin/components/preview/Preview.js
  25. 228
    0
      react/features/prejoin/functions.js
  26. 7
    0
      react/features/prejoin/index.js
  27. 5
    0
      react/features/prejoin/logger.js
  28. 95
    0
      react/features/prejoin/middleware.js
  29. 168
    0
      react/features/prejoin/reducer.js
  30. 4
    2
      react/features/settings/components/web/video/VideoSettingsContent.js
  31. 21
    4
      react/features/toolbox/components/AudioMuteButton.js
  32. 30
    1
      react/features/toolbox/components/VideoMuteButton.js
  33. 19
    9
      react/features/toolbox/components/web/AudioSettingsButton.js
  34. 19
    8
      react/features/toolbox/components/web/VideoSettingsButton.js
  35. 2
    0
      react/features/toolbox/components/web/index.js
  36. 36
    0
      react/features/toolbox/functions.web.js

+ 200
- 112
conference.js View File

@@ -27,6 +27,13 @@ import {
27 27
     redirectToStaticPage,
28 28
     reloadWithStoredParams
29 29
 } from './react/features/app';
30
+import {
31
+    initPrejoin,
32
+    isPrejoinPageEnabled,
33
+    isPrejoinPageVisible,
34
+    replacePrejoinAudioTrack,
35
+    replacePrejoinVideoTrack
36
+} from './react/features/prejoin';
30 37
 
31 38
 import EventEmitter from 'events';
32 39
 
@@ -133,6 +140,15 @@ const eventEmitter = new EventEmitter();
133 140
 let room;
134 141
 let connection;
135 142
 
143
+/**
144
+ * The promise is used when the prejoin screen is shown.
145
+ * While the user configures the devices the connection can be made.
146
+ *
147
+ * @type {Promise<Object>}
148
+ * @private
149
+ */
150
+let _connectionPromise;
151
+
136 152
 /**
137 153
  * This promise is used for chaining mutePresenterVideo calls in order to avoid  calling GUM multiple times if it takes
138 154
  * a while to finish.
@@ -471,28 +487,13 @@ export default {
471 487
     localVideo: null,
472 488
 
473 489
     /**
474
-     * Creates local media tracks and connects to a room. Will show error
475
-     * dialogs in case accessing the local microphone and/or camera failed. Will
476
-     * show guidance overlay for users on how to give access to camera and/or
477
-     * microphone.
478
-     * @param {string} roomName
479
-     * @param {object} options
480
-     * @param {boolean} options.startAudioOnly=false - if <tt>true</tt> then
481
-     * only audio track will be created and the audio only mode will be turned
482
-     * on.
483
-     * @param {boolean} options.startScreenSharing=false - if <tt>true</tt>
484
-     * should start with screensharing instead of camera video.
485
-     * @param {boolean} options.startWithAudioMuted - will start the conference
486
-     * without any audio tracks.
487
-     * @param {boolean} options.startWithVideoMuted - will start the conference
488
-     * without any video tracks.
489
-     * @returns {Promise.<JitsiLocalTrack[], JitsiConnection>}
490
+     * Returns an object containing a promise which resolves with the created tracks &
491
+     * the errors resulting from that process.
492
+     *
493
+     * @returns {Promise<JitsiLocalTrack[]>, Object}
490 494
      */
491
-    createInitialLocalTracksAndConnect(roomName, options = {}) {
492
-        let audioAndVideoError,
493
-            audioOnlyError,
494
-            screenSharingError,
495
-            videoOnlyError;
495
+    createInitialLocalTracks(options = {}) {
496
+        const errors = {};
496 497
         const initialDevices = [ 'audio' ];
497 498
         const requestedAudio = true;
498 499
         let requestedVideo = false;
@@ -524,7 +525,7 @@ export default {
524 525
         // FIXME is there any simpler way to rewrite this spaghetti below ?
525 526
         if (options.startScreenSharing) {
526 527
             tryCreateLocalTracks = this._createDesktopTrack()
527
-                .then(desktopStream => {
528
+                .then(([ desktopStream ]) => {
528 529
                     if (!requestedAudio) {
529 530
                         return [ desktopStream ];
530 531
                     }
@@ -533,21 +534,21 @@ export default {
533 534
                         .then(([ audioStream ]) =>
534 535
                             [ desktopStream, audioStream ])
535 536
                         .catch(error => {
536
-                            audioOnlyError = error;
537
+                            errors.audioOnlyError = error;
537 538
 
538 539
                             return [ desktopStream ];
539 540
                         });
540 541
                 })
541 542
                 .catch(error => {
542 543
                     logger.error('Failed to obtain desktop stream', error);
543
-                    screenSharingError = error;
544
+                    errors.screenSharingError = error;
544 545
 
545 546
                     return requestedAudio
546 547
                         ? createLocalTracksF({ devices: [ 'audio' ] }, true)
547 548
                         : [];
548 549
                 })
549 550
                 .catch(error => {
550
-                    audioOnlyError = error;
551
+                    errors.audioOnlyError = error;
551 552
 
552 553
                     return [];
553 554
                 });
@@ -560,16 +561,16 @@ export default {
560 561
                     if (requestedAudio && requestedVideo) {
561 562
 
562 563
                         // Try audio only...
563
-                        audioAndVideoError = err;
564
+                        errors.audioAndVideoError = err;
564 565
 
565 566
                         return (
566 567
                             createLocalTracksF({ devices: [ 'audio' ] }, true));
567 568
                     } else if (requestedAudio && !requestedVideo) {
568
-                        audioOnlyError = err;
569
+                        errors.audioOnlyError = err;
569 570
 
570 571
                         return [];
571 572
                     } else if (requestedVideo && !requestedAudio) {
572
-                        videoOnlyError = err;
573
+                        errors.videoOnlyError = err;
573 574
 
574 575
                         return [];
575 576
                     }
@@ -580,7 +581,7 @@ export default {
580 581
                     if (!requestedAudio) {
581 582
                         logger.error('The impossible just happened', err);
582 583
                     }
583
-                    audioOnlyError = err;
584
+                    errors.audioOnlyError = err;
584 585
 
585 586
                     // Try video only...
586 587
                     return requestedVideo
@@ -592,7 +593,7 @@ export default {
592 593
                     if (!requestedVideo) {
593 594
                         logger.error('The impossible just happened', err);
594 595
                     }
595
-                    videoOnlyError = err;
596
+                    errors.videoOnlyError = err;
596 597
 
597 598
                     return [];
598 599
                 });
@@ -603,8 +604,44 @@ export default {
603 604
         // cases, when auth is rquired, for instance, that won't happen until
604 605
         // the user inputs their credentials, but the dialog would be
605 606
         // overshadowed by the overlay.
606
-        tryCreateLocalTracks.then(() =>
607
-            APP.store.dispatch(mediaPermissionPromptVisibilityChanged(false)));
607
+        tryCreateLocalTracks.then(tracks => {
608
+            APP.store.dispatch(mediaPermissionPromptVisibilityChanged(false));
609
+
610
+            return tracks;
611
+        });
612
+
613
+        return {
614
+            tryCreateLocalTracks,
615
+            errors
616
+        };
617
+    },
618
+
619
+    /**
620
+     * Creates local media tracks and connects to a room. Will show error
621
+     * dialogs in case accessing the local microphone and/or camera failed. Will
622
+     * show guidance overlay for users on how to give access to camera and/or
623
+     * microphone.
624
+     * @param {string} roomName
625
+     * @param {object} options
626
+     * @param {boolean} options.startAudioOnly=false - if <tt>true</tt> then
627
+     * only audio track will be created and the audio only mode will be turned
628
+     * on.
629
+     * @param {boolean} options.startScreenSharing=false - if <tt>true</tt>
630
+     * should start with screensharing instead of camera video.
631
+     * @param {boolean} options.startWithAudioMuted - will start the conference
632
+     * without any audio tracks.
633
+     * @param {boolean} options.startWithVideoMuted - will start the conference
634
+     * without any video tracks.
635
+     * @returns {Promise.<JitsiLocalTrack[], JitsiConnection>}
636
+     */
637
+    createInitialLocalTracksAndConnect(roomName, options = {}) {
638
+        const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(options);
639
+        const {
640
+            audioAndVideoError,
641
+            audioOnlyError,
642
+            screenSharingError,
643
+            videoOnlyError
644
+        } = errors;
608 645
 
609 646
         return Promise.all([ tryCreateLocalTracks, connect(roomName) ])
610 647
             .then(([ tracks, con ]) => {
@@ -636,105 +673,132 @@ export default {
636 673
             });
637 674
     },
638 675
 
676
+    startConference(con, tracks) {
677
+        tracks.forEach(track => {
678
+            if ((track.isAudioTrack() && this.isLocalAudioMuted())
679
+                || (track.isVideoTrack() && this.isLocalVideoMuted())) {
680
+                const mediaType = track.getType();
681
+
682
+                sendAnalytics(
683
+                    createTrackMutedEvent(mediaType, 'initial mute'));
684
+                logger.log(`${mediaType} mute: initially muted.`);
685
+                track.mute();
686
+            }
687
+        });
688
+        logger.log(`Initialized with ${tracks.length} local tracks`);
689
+
690
+        this._localTracksInitialized = true;
691
+        con.addEventListener(JitsiConnectionEvents.CONNECTION_FAILED, _connectionFailedHandler);
692
+        APP.connection = connection = con;
693
+
694
+        // Desktop sharing related stuff:
695
+        this.isDesktopSharingEnabled
696
+            = JitsiMeetJS.isDesktopSharingEnabled();
697
+        eventEmitter.emit(JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED, this.isDesktopSharingEnabled);
698
+
699
+        APP.store.dispatch(
700
+            setDesktopSharingEnabled(this.isDesktopSharingEnabled));
701
+
702
+        this._createRoom(tracks);
703
+        APP.remoteControl.init();
704
+
705
+        // if user didn't give access to mic or camera or doesn't have
706
+        // them at all, we mark corresponding toolbar buttons as muted,
707
+        // so that the user can try unmute later on and add audio/video
708
+        // to the conference
709
+        if (!tracks.find(t => t.isAudioTrack())) {
710
+            this.setAudioMuteStatus(true);
711
+        }
712
+
713
+        if (!tracks.find(t => t.isVideoTrack())) {
714
+            this.setVideoMuteStatus(true);
715
+        }
716
+
717
+        if (config.iAmRecorder) {
718
+            this.recorder = new Recorder();
719
+        }
720
+
721
+        if (config.startSilent) {
722
+            sendAnalytics(createStartSilentEvent());
723
+            APP.store.dispatch(showNotification({
724
+                descriptionKey: 'notify.startSilentDescription',
725
+                titleKey: 'notify.startSilentTitle'
726
+            }));
727
+        }
728
+
729
+        // XXX The API will take care of disconnecting from the XMPP
730
+        // server (and, thus, leaving the room) on unload.
731
+        return new Promise((resolve, reject) => {
732
+            (new ConferenceConnector(resolve, reject)).connect();
733
+        });
734
+    },
735
+
639 736
     /**
640
-     * Open new connection and join to the conference.
641
-     * @param {object} options
642
-     * @param {string} roomName - The name of the conference.
737
+     * Open new connection and join the conference when prejoin page is not enabled.
738
+     * If prejoin page is enabled open an new connection in the background
739
+     * and create local tracks.
740
+     *
741
+     * @param {{ roomName: string }} options
643 742
      * @returns {Promise}
644 743
      */
645
-    init(options) {
646
-        this.roomName = options.roomName;
744
+    async init({ roomName }) {
745
+        const initialOptions = {
746
+            startAudioOnly: config.startAudioOnly,
747
+            startScreenSharing: config.startScreenSharing,
748
+            startWithAudioMuted: config.startWithAudioMuted
749
+                || config.startSilent
750
+                || isUserInteractionRequiredForUnmute(APP.store.getState()),
751
+            startWithVideoMuted: config.startWithVideoMuted
752
+                || isUserInteractionRequiredForUnmute(APP.store.getState())
753
+        };
647 754
 
648
-        window.addEventListener('hashchange', this.onHashChange.bind(this), false);
755
+        this.roomName = roomName;
649 756
 
650
-        return (
757
+        window.addEventListener('hashchange', this.onHashChange.bind(this), false);
651 758
 
759
+        try {
652 760
             // Initialize the device list first. This way, when creating tracks
653 761
             // based on preferred devices, loose label matching can be done in
654 762
             // cases where the exact ID match is no longer available, such as
655 763
             // when the camera device has switched USB ports.
656 764
             // when in startSilent mode we want to start with audio muted
657
-            this._initDeviceList()
658
-                .catch(error => logger.warn(
659
-                    'initial device list initialization failed', error))
660
-                .then(() => this.createInitialLocalTracksAndConnect(
661
-                options.roomName, {
662
-                    startAudioOnly: config.startAudioOnly,
663
-                    startScreenSharing: config.startScreenSharing,
664
-                    startWithAudioMuted: config.startWithAudioMuted
665
-                    || config.startSilent
666
-                    || isUserInteractionRequiredForUnmute(APP.store.getState()),
667
-                    startWithVideoMuted: config.startWithVideoMuted
668
-                    || isUserInteractionRequiredForUnmute(APP.store.getState())
669
-                }))
670
-            .then(([ tracks, con ]) => {
671
-                tracks.forEach(track => {
672
-                    if ((track.isAudioTrack() && this.isLocalAudioMuted())
673
-                        || (track.isVideoTrack() && this.isLocalVideoMuted())) {
674
-                        const mediaType = track.getType();
675
-
676
-                        sendAnalytics(
677
-                            createTrackMutedEvent(mediaType, 'initial mute'));
678
-                        logger.log(`${mediaType} mute: initially muted.`);
679
-                        track.mute();
680
-                    }
681
-                });
682
-                logger.log(`initialized with ${tracks.length} local tracks`);
683
-                this._localTracksInitialized = true;
684
-                con.addEventListener(
685
-                    JitsiConnectionEvents.CONNECTION_FAILED,
686
-                    _connectionFailedHandler);
687
-                APP.connection = connection = con;
688
-
689
-                // Desktop sharing related stuff:
690
-                this.isDesktopSharingEnabled
691
-                    = JitsiMeetJS.isDesktopSharingEnabled();
692
-                eventEmitter.emit(
693
-                    JitsiMeetConferenceEvents.DESKTOP_SHARING_ENABLED_CHANGED,
694
-                    this.isDesktopSharingEnabled);
765
+            await this._initDeviceList();
766
+        } catch (error) {
767
+            logger.warn('initial device list initialization failed', error);
768
+        }
695 769
 
696
-                APP.store.dispatch(
697
-                    setDesktopSharingEnabled(this.isDesktopSharingEnabled));
770
+        if (isPrejoinPageEnabled(APP.store.getState())) {
771
+            _connectionPromise = connect(roomName);
698 772
 
699
-                this._createRoom(tracks);
700
-                APP.remoteControl.init();
773
+            const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions);
774
+            const tracks = await tryCreateLocalTracks;
701 775
 
702
-                // if user didn't give access to mic or camera or doesn't have
703
-                // them at all, we mark corresponding toolbar buttons as muted,
704
-                // so that the user can try unmute later on and add audio/video
705
-                // to the conference
706
-                if (!tracks.find(t => t.isAudioTrack())) {
707
-                    this.setAudioMuteStatus(true);
708
-                }
776
+            // Initialize device list a second time to ensure device labels
777
+            // get populated in case of an initial gUM acceptance; otherwise
778
+            // they may remain as empty strings.
779
+            this._initDeviceList(true);
709 780
 
710
-                if (!tracks.find(t => t.isVideoTrack())) {
711
-                    this.setVideoMuteStatus(true);
712
-                }
781
+            return APP.store.dispatch(initPrejoin(tracks, errors));
782
+        }
713 783
 
714
-                // Initialize device list a second time to ensure device labels
715
-                // get populated in case of an initial gUM acceptance; otherwise
716
-                // they may remain as empty strings.
717
-                this._initDeviceList(true);
784
+        const [ tracks, con ] = await this.createInitialLocalTracksAndConnect(
785
+            roomName, initialOptions);
718 786
 
719
-                if (config.iAmRecorder) {
720
-                    this.recorder = new Recorder();
721
-                }
787
+        this._initDeviceList(true);
722 788
 
723
-                if (config.startSilent) {
724
-                    sendAnalytics(createStartSilentEvent());
725
-                    APP.store.dispatch(showNotification({
726
-                        descriptionKey: 'notify.startSilentDescription',
727
-                        titleKey: 'notify.startSilentTitle'
728
-                    }));
729
-                }
789
+        return this.startConference(con, tracks);
790
+    },
730 791
 
731
-                // XXX The API will take care of disconnecting from the XMPP
732
-                // server (and, thus, leaving the room) on unload.
733
-                return new Promise((resolve, reject) => {
734
-                    (new ConferenceConnector(resolve, reject)).connect();
735
-                });
736
-            })
737
-        );
792
+    /**
793
+     * Joins conference after the tracks have been configured in the prejoin screen.
794
+     *
795
+     * @param {Object[]} tracks - An array with the configured tracks
796
+     * @returns {Promise}
797
+     */
798
+    async prejoinStart(tracks) {
799
+        const con = await _connectionPromise;
800
+
801
+        return this.startConference(con, tracks);
738 802
     },
739 803
 
740 804
     /**
@@ -1352,6 +1416,18 @@ export default {
1352 1416
     useVideoStream(newStream) {
1353 1417
         return new Promise((resolve, reject) => {
1354 1418
             _replaceLocalVideoTrackQueue.enqueue(onFinish => {
1419
+                /**
1420
+                 * When the prejoin page is visible there is no conference object
1421
+                 * created. The prejoin tracks are managed separately,
1422
+                 * so this updates the prejoin video track.
1423
+                 */
1424
+                if (isPrejoinPageVisible(APP.store.getState())) {
1425
+                    return APP.store.dispatch(replacePrejoinVideoTrack(newStream))
1426
+                        .then(resolve)
1427
+                        .catch(reject)
1428
+                        .then(onFinish);
1429
+                }
1430
+
1355 1431
                 APP.store.dispatch(
1356 1432
                 replaceLocalTrack(this.localVideo, newStream, room))
1357 1433
                     .then(() => {
@@ -1405,6 +1481,18 @@ export default {
1405 1481
     useAudioStream(newStream) {
1406 1482
         return new Promise((resolve, reject) => {
1407 1483
             _replaceLocalAudioTrackQueue.enqueue(onFinish => {
1484
+                /**
1485
+                 * When the prejoin page is visible there is no conference object
1486
+                 * created. The prejoin tracks are managed separately,
1487
+                 * so this updates the prejoin audio stream.
1488
+                 */
1489
+                if (isPrejoinPageVisible(APP.store.getState())) {
1490
+                    return APP.store.dispatch(replacePrejoinAudioTrack(newStream))
1491
+                        .then(resolve)
1492
+                        .catch(reject)
1493
+                        .then(onFinish);
1494
+                }
1495
+
1408 1496
                 APP.store.dispatch(
1409 1497
                 replaceLocalTrack(this.localAudio, newStream, room))
1410 1498
                     .then(() => {

+ 3
- 0
config.js View File

@@ -289,6 +289,9 @@ var config = {
289 289
     // and microsoftApiApplicationClientID
290 290
     // enableCalendarIntegration: false,
291 291
 
292
+    // When 'true', it shows an intermediate page before joining, where the user can  configure its devices.
293
+    // prejoinPageEnabled: false,
294
+
292 295
     // Stats
293 296
     //
294 297
 

+ 255
- 0
css/_prejoin.scss View File

@@ -0,0 +1,255 @@
1
+.prejoin {
2
+    &-full-page {
3
+        background: #1C2025;
4
+        position: absolute;
5
+        width: 100%;
6
+        height: 100%;
7
+        z-index: $toolbarZ + 1;
8
+    }
9
+
10
+    &-input-area-container {
11
+        position: absolute;
12
+        bottom: 128px;
13
+        width: 100%;
14
+        z-index: 1;
15
+    }
16
+
17
+    &-input-area {
18
+        margin: 0 auto;
19
+        text-align: center;
20
+        width: 320px;
21
+    }
22
+
23
+    &-title {
24
+        color: #fff;
25
+        font-size: 24px;
26
+        line-height: 32px;
27
+        margin-bottom: 16px;
28
+    }
29
+
30
+    &-btn {
31
+        border-radius: 3px;
32
+        color: #fff;
33
+        cursor: pointer;
34
+        display: inline-block;
35
+        font-size: 15px;
36
+        line-height: 24px;
37
+        margin-bottom: 16px;
38
+        padding: 7px 16px;
39
+        text-align: center;
40
+        width: 286px;
41
+
42
+        &--primary {
43
+            background: #0376DA;
44
+            border: 1px solid #0376DA;
45
+        }
46
+
47
+        &--secondary {
48
+            background: #2A3A4B;
49
+            border: 1px solid #5E6D7A;
50
+        }
51
+
52
+        &--text {
53
+            width: auto;
54
+            margin: 0;
55
+            padding: 0;
56
+        }
57
+    }
58
+
59
+    &-text-btns {
60
+        display: flex;
61
+        justify-content: space-between;
62
+    }
63
+
64
+    &-input-label {
65
+        color: #A4B8D1;
66
+        font-size: 13px;
67
+        line-height: 20px;
68
+        margin-top: 32px 0 8px 0;
69
+        text-align: center;
70
+        width: 100%;
71
+    }
72
+}
73
+
74
+@mixin name-placeholder {
75
+    color: #fff;
76
+    font-weight: 300;
77
+    opacity: 0.6;
78
+}
79
+
80
+.prejoin-preview {
81
+    height: 100%;
82
+    position: absolute;
83
+    width: 100%;
84
+
85
+    &--no-video {
86
+        background: radial-gradient(50% 50% at 50% 50%, #5B6F80 0%, #365067 100%), #FFFFFF;
87
+        text-align: center;
88
+    }
89
+
90
+    &-video {
91
+        height: 100%;
92
+        object-fit: cover;
93
+        position: absolute;
94
+        width: 100%;
95
+    }
96
+
97
+    &-name {
98
+        color: #fff;
99
+        font-size: 19px;
100
+        line-height: 28px;
101
+
102
+        &--editable {
103
+            background: none;
104
+            border: 0;
105
+            border-bottom: 1px solid #D1DBE8;
106
+            margin: 24px 0 16px 0;
107
+            outline: none;
108
+            text-align: center;
109
+            width: 100%;
110
+
111
+            &::-webkit-input-placeholder {
112
+                @include name-placeholder;
113
+            }
114
+            &::-moz-placeholder {
115
+                @include name-placeholder;
116
+            }
117
+            &:-ms-input-placeholder {
118
+                @include name-placeholder;
119
+            }
120
+        }
121
+    }
122
+
123
+    &-avatar.avatar {
124
+        background: #A4B8D1;
125
+        margin: 200px auto 0 auto;
126
+    }
127
+
128
+    &-btn-container {
129
+        display: flex;
130
+        justify-content: center;
131
+        position: absolute;
132
+        bottom: 50px;
133
+        width: 100%;
134
+        z-index: 1;
135
+
136
+        &> div {
137
+            margin: 0 12px;
138
+        }
139
+
140
+        .settings-button-small-icon {
141
+            right: -8px;
142
+
143
+            &--hovered {
144
+                right: -10px;
145
+            }
146
+        }
147
+    }
148
+
149
+    &-overlay {
150
+        height: 100%;
151
+        position: absolute;
152
+        width: 100%;
153
+        z-index: 1;
154
+        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%);
155
+    }
156
+
157
+    &-status {
158
+        align-items: center;
159
+        bottom: 0;
160
+        color: #fff;
161
+        display: flex;
162
+        font-size: 13px;
163
+        min-height: 24px;
164
+        justify-content: center;
165
+        position: absolute;
166
+        text-align: center;
167
+        width: 100%;
168
+        z-index: 1;
169
+
170
+        &--warning {
171
+            background: rgba(241, 173, 51, 0.5)
172
+        }
173
+        &--ok {
174
+            background: rgba(49, 183, 106, 0.5);
175
+        }
176
+    }
177
+
178
+    &-icon {
179
+        background-position: center;
180
+        background-repeat: no-repeat;
181
+        display: inline-block;
182
+        height: 16px;
183
+        margin-right: 8px;
184
+        width: 16px;
185
+    }
186
+
187
+    &-error-desc {
188
+        margin-right: 4px;
189
+    }
190
+
191
+    .settings-button-container {
192
+        width: 49px;
193
+        margin: 0 8px;
194
+    }
195
+}
196
+
197
+.prejoin-copy {
198
+    &-meeting {
199
+        cursor: pointer;
200
+        color: #fff;
201
+        font-size: 15px;
202
+        font-weight: 300;
203
+        line-height: 24px;
204
+        position: relative;
205
+    }
206
+
207
+    &-url {
208
+        max-width: 278px;
209
+        padding: 8px 10px;
210
+        overflow: hidden;
211
+        text-overflow: ellipsis;
212
+    }
213
+
214
+    &-badge {
215
+        border-radius: 4px;
216
+        height: 100%;
217
+        line-height: 38px;
218
+        position: absolute;
219
+        padding-left: 10px;
220
+        text-align: left;
221
+        top: 0;
222
+        width: 100%;
223
+
224
+        &--hover {
225
+            background: #1C2025;
226
+        }
227
+
228
+        &--done {
229
+            background: #31B76A;
230
+        }
231
+    }
232
+
233
+    &-icon {
234
+        position: absolute;
235
+        right: 8px;
236
+        top: 8px;
237
+
238
+       &--white {
239
+           &> svg > path {
240
+               fill: #fff
241
+           }
242
+       }
243
+
244
+       &--light {
245
+           &> svg > path {
246
+               fill: #D1DBE8;
247
+           }
248
+       }
249
+    }
250
+
251
+    &-textarea {
252
+        position: absolute;
253
+        left: -9999px;
254
+    }
255
+}

+ 1
- 0
css/_settings-button.scss View File

@@ -57,6 +57,7 @@
57 57
         width: 16px;
58 58
 
59 59
         &> svg {
60
+            fill: #5e6d7a;
60 61
             margin-top: 5px;
61 62
         }
62 63
 

+ 6
- 2
css/_video-preview.css View File

@@ -1,7 +1,11 @@
1 1
 .video-preview {
2 2
     background: none;
3 3
     max-height: 290px;
4
-    overflow: auto;
4
+
5
+    &-container {
6
+        overflow: auto;
7
+        padding: 16px;
8
+    }
5 9
 
6 10
     &-entry {
7 11
         cursor: pointer;
@@ -61,6 +65,6 @@
61 65
     // Override @atlaskit/InlineDialog container which is made with styled components
62 66
     & > div > div:nth-child(2) > div > div {
63 67
         outline: none;
64
-        padding: 16px;
68
+        padding: 0;
65 69
     }
66 70
 }

+ 1
- 0
css/main.scss View File

@@ -90,5 +90,6 @@ $flagsImagePath: "../images/";
90 90
 @import 'meter';
91 91
 @import 'audio-preview';
92 92
 @import 'video-preview';
93
+@import 'prejoin';
93 94
 
94 95
 /* Modules END */

+ 27
- 0
lang/main.json View File

@@ -476,6 +476,33 @@
476 476
     "passwordSetRemotely": "set by another participant",
477 477
     "passwordDigitsOnly": "Up to {{number}} digits",
478 478
     "poweredby": "powered by",
479
+    "prejoin": {
480
+        "audioAndVideoError": "Audio and video error:",
481
+        "audioOnlyError": "Audio error:",
482
+        "audioTrackError": "Could not create audio track.",
483
+        "callMe": "Call me",
484
+        "callMeAtNumber": "Call me at this number:",
485
+        "configuringDevices": "Configuring devices...",
486
+        "connectedWithAudioQ": "You’re connected with audio?",
487
+        "copyAndShare": "Copy & share meeting link",
488
+        "dialInMeeting": "Dial into the meeting",
489
+        "dialInPin": "Dial into the meeting and enter PIN code:",
490
+        "dialing": "Dialing",
491
+        "iWantToDialIn": "I want to dial in",
492
+        "joinAudioByPhone": "Join with phone audio",
493
+        "joinMeeting": "Join meeting",
494
+        "joinWithoutAudio": "Join without audio",
495
+        "initiated": "Call initiated",
496
+        "linkCopied": "Link copied to clipboard",
497
+        "lookGood": "Speaker and microphone look good",
498
+        "or": "or",
499
+        "calling": "Calling",
500
+        "startWithPhone": "Start with phone audio",
501
+        "screenSharingError": "Screen sharing error:",
502
+        "videoOnlyError": "Video error:",
503
+        "videoTrackError": "Could not create video track.",
504
+        "viewAllNumbers": "view all numbers"
505
+    },
479 506
     "presenceStatus": {
480 507
         "busy": "Busy",
481 508
         "calling": "Calling...",

+ 10
- 0
react/features/base/conference/functions.js View File

@@ -246,6 +246,16 @@ export function getNearestReceiverVideoQualityLevel(availableHeight: number) {
246 246
     return selectedLevel;
247 247
 }
248 248
 
249
+/**
250
+ * Returns the stored room name.
251
+ *
252
+ * @param {Object} state - The current state of the app.
253
+ * @returns {string}
254
+ */
255
+export function getRoomName(state: Object): string {
256
+    return state['features/base/conference'].room;
257
+}
258
+
249 259
 /**
250 260
  * Handle an error thrown by the backend (i.e. {@code lib-jitsi-meet}) while
251 261
  * manipulating a conference participant (e.g. Pin or select participant).

+ 1
- 0
react/features/base/config/configWhitelist.js View File

@@ -131,6 +131,7 @@ export default [
131 131
     'p2p',
132 132
     'pcStatsInterval',
133 133
     'preferH264',
134
+    'prejoinPageEnabled',
134 135
     'requireDisplayName',
135 136
     'remoteVideoMenu',
136 137
     'resolution',

+ 12
- 3
react/features/base/devices/middleware.js View File

@@ -18,6 +18,8 @@ import {
18 18
     SET_AUDIO_INPUT_DEVICE,
19 19
     SET_VIDEO_INPUT_DEVICE
20 20
 } from './actionTypes';
21
+import { replaceAudioTrackById, replaceVideoTrackById } from '../../prejoin/actions';
22
+import { isPrejoinPageVisible } from '../../prejoin/functions';
21 23
 import { showNotification, showWarningNotification } from '../../notifications';
22 24
 import { updateSettings } from '../settings';
23 25
 import { formatDeviceLabel, setAudioOutputDeviceId } from './functions';
@@ -98,10 +100,18 @@ MiddlewareRegistry.register(store => next => action => {
98 100
         break;
99 101
     }
100 102
     case SET_AUDIO_INPUT_DEVICE:
101
-        APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId);
103
+        if (isPrejoinPageVisible(store.getState())) {
104
+            store.dispatch(replaceAudioTrackById(action.deviceId));
105
+        } else {
106
+            APP.UI.emitEvent(UIEvents.AUDIO_DEVICE_CHANGED, action.deviceId);
107
+        }
102 108
         break;
103 109
     case SET_VIDEO_INPUT_DEVICE:
104
-        APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId);
110
+        if (isPrejoinPageVisible(store.getState())) {
111
+            store.dispatch(replaceVideoTrackById(action.deviceId));
112
+        } else {
113
+            APP.UI.emitEvent(UIEvents.VIDEO_DEVICE_CHANGED, action.deviceId);
114
+        }
105 115
         break;
106 116
     case CHECK_AND_NOTIFY_FOR_NEW_DEVICE:
107 117
         _checkAndNotifyForNewDevice(store, action.newDevices, action.oldDevices);
@@ -111,7 +121,6 @@ MiddlewareRegistry.register(store => next => action => {
111 121
     return next(action);
112 122
 });
113 123
 
114
-
115 124
 /**
116 125
  * Does extra sync up on properties that may need to be updated after the
117 126
  * conference was joined.

+ 3
- 0
react/features/base/icons/svg/arrow-left.svg View File

@@ -0,0 +1,3 @@
1
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5223 18.2701C16.9328 18.6395 16.9661 19.2718 16.5966 19.6823C16.2272 20.0928 15.5949 20.1261 15.1844 19.7567L7.31769 12.6767C6.87631 12.2794 6.87631 11.5873 7.31769 11.1901L15.1844 4.11007C15.5949 3.74061 16.2272 3.77389 16.5966 4.1844C16.9661 4.59491 16.9328 5.2272 16.5223 5.59666L9.4815 11.9334L16.5223 18.2701Z" fill="#A4B8D1"/>
3
+</svg>

+ 1
- 1
react/features/base/icons/svg/arrow_down.svg View File

@@ -1,3 +1,3 @@
1 1
 <svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
2
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.07001 0.248238C8.3471 -0.0596449 8.82132 -0.0846038 9.1292 0.192491C9.43709 0.469585 9.46205 0.943802 9.18495 1.25168L5.65622 5.19348C5.35829 5.52451 4.83922 5.52451 4.54128 5.19348L1.06752 1.25168C0.79043 0.943802 0.81539 0.469585 1.12327 0.192491C1.43115 -0.0846038 1.90537 -0.0596449 2.18247 0.248238L5.09875 3.57062L8.07001 0.248238Z" fill="#5E6D7A"/>
2
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.07001 0.248238C8.3471 -0.0596449 8.82132 -0.0846038 9.1292 0.192491C9.43709 0.469585 9.46205 0.943802 9.18495 1.25168L5.65622 5.19348C5.35829 5.52451 4.83922 5.52451 4.54128 5.19348L1.06752 1.25168C0.79043 0.943802 0.81539 0.469585 1.12327 0.192491C1.43115 -0.0846038 1.90537 -0.0596449 2.18247 0.248238L5.09875 3.57062L8.07001 0.248238Z"/>
3 3
 </svg>

+ 3
- 0
react/features/base/icons/svg/close-x.svg View File

@@ -0,0 +1,3 @@
1
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.41416 8L15.071 2.34315C15.4615 1.95262 15.4615 1.31946 15.071 0.928933C14.6805 0.538409 14.0473 0.538409 13.6568 0.928933L7.99995 6.58579L2.34309 0.928933C1.95257 0.538409 1.3194 0.538409 0.92888 0.928933C0.538355 1.31946 0.538355 1.95262 0.92888 2.34315L6.58573 8L0.92888 13.6569C0.538355 14.0474 0.538355 14.6805 0.92888 15.0711C1.3194 15.4616 1.95257 15.4616 2.34309 15.0711L7.99995 9.41421L13.6568 15.0711C14.0473 15.4616 14.6805 15.4616 15.071 15.0711C15.4615 14.6805 15.4615 14.0474 15.071 13.6569L9.41416 8Z" />
3
+</svg>

+ 2
- 0
react/features/base/icons/svg/index.js View File

@@ -4,6 +4,7 @@ export { default as IconAdd } from './add.svg';
4 4
 export { default as IconAddPeople } from './link.svg';
5 5
 export { default as IconArrowBack } from './arrow_back.svg';
6 6
 export { default as IconArrowDown } from './arrow_down.svg';
7
+export { default as IconArrowLeft } from './arrow-left.svg';
7 8
 export { default as IconAudioOnly } from './visibility.svg';
8 9
 export { default as IconAudioOnlyOff } from './visibility-off.svg';
9 10
 export { default as IconAudioRoute } from './volume.svg';
@@ -16,6 +17,7 @@ export { default as IconChatSend } from './send.svg';
16 17
 export { default as IconChatUnread } from './chat-unread.svg';
17 18
 export { default as IconCheck } from './check.svg';
18 19
 export { default as IconClose } from './close.svg';
20
+export { default as IconCloseX } from './close-x.svg';
19 21
 export { default as IconClosedCaption } from './closed_caption.svg';
20 22
 export { default as IconConnectionActive } from './gsm-bars.svg';
21 23
 export { default as IconConnectionInactive } from './ninja.svg';

+ 19
- 4
react/features/conference/components/web/Conference.js View File

@@ -13,6 +13,7 @@ import { Chat } from '../../../chat';
13 13
 import { Filmstrip } from '../../../filmstrip';
14 14
 import { CalleeInfoContainer } from '../../../invite';
15 15
 import { LargeVideo } from '../../../large-video';
16
+import { Prejoin, isPrejoinPageVisible } from '../../../prejoin';
16 17
 import { LAYOUTS, getCurrentLayout } from '../../../video-layout';
17 18
 
18 19
 import {
@@ -84,6 +85,11 @@ type Props = AbstractProps & {
84 85
      */
85 86
     _roomName: string,
86 87
 
88
+    /**
89
+     * If prejoin page is visible or not.
90
+     */
91
+    _showPrejoin: boolean,
92
+
87 93
     dispatch: Function,
88 94
     t: Function
89 95
 }
@@ -178,16 +184,22 @@ class Conference extends AbstractConference<Props, *> {
178 184
             // interfaceConfig is obsolete but legacy support is required.
179 185
             filmStripOnly: filmstripOnly
180 186
         } = interfaceConfig;
187
+        const {
188
+            _iAmRecorder,
189
+            _layoutClassName,
190
+            _showPrejoin
191
+        } = this.props;
181 192
         const hideVideoQualityLabel
182 193
             = filmstripOnly
183 194
                 || VIDEO_QUALITY_LABEL_DISABLED
184
-                || this.props._iAmRecorder;
195
+                || _iAmRecorder;
185 196
 
186 197
         return (
187 198
             <div
188
-                className = { this.props._layoutClassName }
199
+                className = { _layoutClassName }
189 200
                 id = 'videoconference_page'
190 201
                 onMouseMove = { this._onShowToolbar }>
202
+
191 203
                 <Notice />
192 204
                 <Subject />
193 205
                 <div id = 'videospace'>
@@ -197,11 +209,13 @@ class Conference extends AbstractConference<Props, *> {
197 209
                     <Filmstrip filmstripOnly = { filmstripOnly } />
198 210
                 </div>
199 211
 
200
-                { filmstripOnly || <Toolbox /> }
212
+                { filmstripOnly || _showPrejoin || <Toolbox /> }
201 213
                 { filmstripOnly || <Chat /> }
202 214
 
203 215
                 { this.renderNotificationsContainer() }
204 216
 
217
+                { !filmstripOnly && _showPrejoin && <Prejoin />}
218
+
205 219
                 <CalleeInfoContainer />
206 220
             </div>
207 221
         );
@@ -268,7 +282,8 @@ function _mapStateToProps(state) {
268 282
         ...abstractMapStateToProps(state),
269 283
         _iAmRecorder: state['features/base/config'].iAmRecorder,
270 284
         _layoutClassName: LAYOUT_CLASSNAMES[getCurrentLayout(state)],
271
-        _roomName: getConferenceNameForTitle(state)
285
+        _roomName: getConferenceNameForTitle(state),
286
+        _showPrejoin: isPrejoinPageVisible(state)
272 287
     };
273 288
 }
274 289
 

+ 11
- 2
react/features/invite/functions.js View File

@@ -298,9 +298,8 @@ export function invitePeopleAndChatRooms( // eslint-disable-line max-params
298 298
  */
299 299
 export function isAddPeopleEnabled(state: Object): boolean {
300 300
     const { peopleSearchUrl } = state['features/base/config'];
301
-    const { isGuest } = state['features/base/jwt'];
302 301
 
303
-    return !isGuest && Boolean(peopleSearchUrl);
302
+    return !isGuest(state) && Boolean(peopleSearchUrl);
304 303
 }
305 304
 
306 305
 /**
@@ -316,6 +315,16 @@ export function isDialOutEnabled(state: Object): boolean {
316 315
         && conference && conference.isSIPCallingSupported();
317 316
 }
318 317
 
318
+/**
319
+ * Determines if the current user is guest or not.
320
+ *
321
+ * @param {Object} state - Current state.
322
+ * @returns {boolean}
323
+ */
324
+export function isGuest(state: Object): boolean {
325
+    return state['features/base/jwt'].isGuest;
326
+}
327
+
319 328
 /**
320 329
  * Checks whether a string looks like it could be for a phone number.
321 330
  *

+ 65
- 0
react/features/prejoin/actionTypes.js View File

@@ -0,0 +1,65 @@
1
+/**
2
+ * Action type to add a video track to the store.
3
+ */
4
+export const ADD_PREJOIN_VIDEO_TRACK = 'ADD_PREJOIN_VIDEO_TRACK';
5
+
6
+/**
7
+ * Action type to add an audio track to the store.
8
+ */
9
+export const ADD_PREJOIN_AUDIO_TRACK = 'ADD_PREJOIN_AUDIO_TRACK';
10
+
11
+/**
12
+ * Action type to add a content sharing track to the store.
13
+ */
14
+export const ADD_PREJOIN_CONTENT_SHARING_TRACK
15
+    = 'ADD_PREJOIN_CONTENT_SHARING_TRACK';
16
+
17
+/**
18
+ * Action type to signal the start of the conference.
19
+ */
20
+export const PREJOIN_START_CONFERENCE = 'PREJOIN_START_CONFERENCE';
21
+
22
+/**
23
+ * Action type to set the status of the device.
24
+ */
25
+export const SET_DEVICE_STATUS = 'SET_DEVICE_STATUS';
26
+
27
+/**
28
+ * Action type to set the visiblity of the 'JoinByPhone' dialog.
29
+ */
30
+export const SET_JOIN_BY_PHONE_DIALOG_VISIBLITY = 'SET_JOIN_BY_PHONE_DIALOG_VISIBLITY';
31
+
32
+/**
33
+ * Action type to disable the audio while on prejoin page.
34
+ */
35
+export const SET_PREJOIN_AUDIO_DISABLED = 'SET_PREJOIN_AUDIO_DISABLED';
36
+
37
+/**
38
+ * Action type to mute/unmute the audio while on prejoin page.
39
+ */
40
+export const SET_PREJOIN_AUDIO_MUTED = 'SET_PREJOIN_AUDIO_MUTED';
41
+
42
+/**
43
+ * Action type to set the errors while creating the prejoin streams.
44
+ */
45
+export const SET_PREJOIN_DEVICE_ERRORS = 'SET_PREJOIN_DEVICE_ERRORS';
46
+
47
+/**
48
+ * Action type to set the name of the user.
49
+ */
50
+export const SET_PREJOIN_NAME = 'SET_PREJOIN_NAME';
51
+
52
+/**
53
+ * Action type to set the visibility of the prejoin page.
54
+ */
55
+export const SET_PREJOIN_PAGE_VISIBILITY = 'SET_PREJOIN_PAGE_VISIBILITY';
56
+
57
+/**
58
+ * Action type to mute/unmute the video while on prejoin page.
59
+ */
60
+export const SET_PREJOIN_VIDEO_DISABLED = 'SET_PREJOIN_VIDEO_DISABLED';
61
+
62
+/**
63
+ * Action type to mute/unmute the video while on prejoin page.
64
+ */
65
+export const SET_PREJOIN_VIDEO_MUTED = 'SET_PREJOIN_VIDEO_MUTED';

+ 338
- 0
react/features/prejoin/actions.js View File

@@ -0,0 +1,338 @@
1
+// @flow
2
+
3
+import {
4
+    ADD_PREJOIN_AUDIO_TRACK,
5
+    ADD_PREJOIN_CONTENT_SHARING_TRACK,
6
+    ADD_PREJOIN_VIDEO_TRACK,
7
+    PREJOIN_START_CONFERENCE,
8
+    SET_DEVICE_STATUS,
9
+    SET_JOIN_BY_PHONE_DIALOG_VISIBLITY,
10
+    SET_PREJOIN_AUDIO_DISABLED,
11
+    SET_PREJOIN_AUDIO_MUTED,
12
+    SET_PREJOIN_DEVICE_ERRORS,
13
+    SET_PREJOIN_NAME,
14
+    SET_PREJOIN_PAGE_VISIBILITY,
15
+    SET_PREJOIN_VIDEO_DISABLED,
16
+    SET_PREJOIN_VIDEO_MUTED
17
+} from './actionTypes';
18
+import { createLocalTrack } from '../base/lib-jitsi-meet';
19
+import { getAudioTrack, getVideoTrack } from './functions';
20
+import logger from './logger';
21
+
22
+/**
23
+ * Action used to add an audio track to the store.
24
+ *
25
+ * @param {Object} value - The track to be added.
26
+ * @returns {Object}
27
+ */
28
+export function addPrejoinAudioTrack(value: Object) {
29
+    return {
30
+        type: ADD_PREJOIN_AUDIO_TRACK,
31
+        value
32
+    };
33
+}
34
+
35
+/**
36
+ * Action used to add a video track to the store.
37
+ *
38
+ * @param {Object} value - The track to be added.
39
+ * @returns {Object}
40
+ */
41
+export function addPrejoinVideoTrack(value: Object) {
42
+    return {
43
+        type: ADD_PREJOIN_VIDEO_TRACK,
44
+        value
45
+    };
46
+}
47
+
48
+/**
49
+ * Action used to add a content sharing track to the store.
50
+ *
51
+ * @param {Object} value - The track to be added.
52
+ * @returns {Object}
53
+ */
54
+export function addPrejoinContentSharingTrack(value: Object) {
55
+    return {
56
+        type: ADD_PREJOIN_CONTENT_SHARING_TRACK,
57
+        value
58
+    };
59
+}
60
+
61
+/**
62
+ * Adds all the newly created tracks to store on init.
63
+ *
64
+ * @param {Object[]} tracks - The newly created tracks.
65
+ * @param {Object} errors - The errors from creating the tracks.
66
+ *
67
+ * @returns {Function}
68
+ */
69
+export function initPrejoin(tracks: Object[], errors: Object) {
70
+    return async function(dispatch: Function) {
71
+        const audioTrack = tracks.find(t => t.isAudioTrack());
72
+        const videoTrack = tracks.find(t => t.isVideoTrack());
73
+
74
+        dispatch(setPrejoinDeviceErrors(errors));
75
+
76
+        if (audioTrack) {
77
+            dispatch(addPrejoinAudioTrack(audioTrack));
78
+        } else {
79
+            dispatch(setAudioDisabled());
80
+        }
81
+
82
+        if (videoTrack) {
83
+            if (videoTrack.videoType === 'desktop') {
84
+                dispatch(addPrejoinContentSharingTrack(videoTrack));
85
+                dispatch(setPrejoinVideoDisabled(true));
86
+            } else {
87
+                dispatch(addPrejoinVideoTrack(videoTrack));
88
+            }
89
+        } else {
90
+            dispatch(setPrejoinVideoDisabled(true));
91
+        }
92
+    };
93
+}
94
+
95
+/**
96
+ * Joins the conference.
97
+ *
98
+ * @returns {Function}
99
+ */
100
+export function joinConference() {
101
+    return function(dispatch: Function) {
102
+        dispatch(setPrejoinPageVisibility(false));
103
+        dispatch(startConference());
104
+    };
105
+}
106
+
107
+/**
108
+ * Joins the conference without audio.
109
+ *
110
+ * @returns {Function}
111
+ */
112
+export function joinConferenceWithoutAudio() {
113
+    return async function(dispatch: Function, getState: Function) {
114
+        const audioTrack = getAudioTrack(getState());
115
+
116
+        if (audioTrack) {
117
+            await dispatch(replacePrejoinAudioTrack(null));
118
+        }
119
+        dispatch(setAudioDisabled());
120
+        dispatch(joinConference());
121
+    };
122
+}
123
+
124
+/**
125
+ * Replaces the existing audio track with a new one.
126
+ *
127
+ * @param {Object} track - The new track.
128
+ * @returns {Function}
129
+ */
130
+export function replacePrejoinAudioTrack(track: Object) {
131
+    return async (dispatch: Function, getState: Function) => {
132
+        const oldTrack = getAudioTrack(getState());
133
+
134
+        oldTrack && await oldTrack.dispose();
135
+        dispatch(addPrejoinAudioTrack(track));
136
+    };
137
+}
138
+
139
+/**
140
+ * Creates a new audio track based on a device id and replaces the current one.
141
+ *
142
+ * @param {string} deviceId - The deviceId of the microphone.
143
+ * @returns {Function}
144
+ */
145
+export function replaceAudioTrackById(deviceId: string) {
146
+    return async (dispatch: Function) => {
147
+        try {
148
+            const track = await createLocalTrack('audio', deviceId);
149
+
150
+            dispatch(replacePrejoinAudioTrack(track));
151
+        } catch (err) {
152
+            dispatch(setDeviceStatusWarning('prejoin.audioTrackError'));
153
+            logger.log('Error replacing audio track', err);
154
+        }
155
+    };
156
+}
157
+
158
+/**
159
+ * Replaces the existing video track with a new one.
160
+ *
161
+ * @param {Object} track - The new track.
162
+ * @returns {Function}
163
+ */
164
+export function replacePrejoinVideoTrack(track: Object) {
165
+    return async (dispatch: Function, getState: Function) => {
166
+        const oldTrack = getVideoTrack(getState());
167
+
168
+        oldTrack && await oldTrack.dispose();
169
+        dispatch(addPrejoinVideoTrack(track));
170
+    };
171
+}
172
+
173
+/**
174
+ * Creates a new video track based on a device id and replaces the current one.
175
+ *
176
+ * @param {string} deviceId - The deviceId of the camera.
177
+ * @returns {Function}
178
+ */
179
+export function replaceVideoTrackById(deviceId: Object) {
180
+    return async (dispatch: Function) => {
181
+        try {
182
+            const track = await createLocalTrack('video', deviceId);
183
+
184
+            dispatch(replacePrejoinVideoTrack(track));
185
+        } catch (err) {
186
+            dispatch(setDeviceStatusWarning('prejoin.videoTrackError'));
187
+            logger.log('Error replacing video track', err);
188
+        }
189
+    };
190
+}
191
+
192
+
193
+/**
194
+ * Action used to mark audio muted.
195
+ *
196
+ * @param {boolean} value - True for muted.
197
+ * @returns {Object}
198
+ */
199
+export function setPrejoinAudioMuted(value: boolean) {
200
+    return {
201
+        type: SET_PREJOIN_AUDIO_MUTED,
202
+        value
203
+    };
204
+}
205
+
206
+/**
207
+ * Action used to mark video disabled.
208
+ *
209
+ * @param {boolean} value - True for muted.
210
+ * @returns {Object}
211
+ */
212
+export function setPrejoinVideoDisabled(value: boolean) {
213
+    return {
214
+        type: SET_PREJOIN_VIDEO_DISABLED,
215
+        value
216
+    };
217
+}
218
+
219
+
220
+/**
221
+ * Action used to mark video muted.
222
+ *
223
+ * @param {boolean} value - True for muted.
224
+ * @returns {Object}
225
+ */
226
+export function setPrejoinVideoMuted(value: boolean) {
227
+    return {
228
+        type: SET_PREJOIN_VIDEO_MUTED,
229
+        value
230
+    };
231
+}
232
+
233
+/**
234
+ * Action used to mark audio as disabled.
235
+ *
236
+ * @returns {Object}
237
+ */
238
+export function setAudioDisabled() {
239
+    return {
240
+        type: SET_PREJOIN_AUDIO_DISABLED
241
+    };
242
+}
243
+
244
+/**
245
+ * Sets the device status as OK with the corresponding text.
246
+ *
247
+ * @param {string} deviceStatusText - The text to be set.
248
+ * @returns {Object}
249
+ */
250
+export function setDeviceStatusOk(deviceStatusText: string) {
251
+    return {
252
+        type: SET_DEVICE_STATUS,
253
+        value: {
254
+            deviceStatusText,
255
+            deviceStatusType: 'ok'
256
+        }
257
+    };
258
+}
259
+
260
+/**
261
+ * Sets the device status as 'warning' with the corresponding text.
262
+ *
263
+ * @param {string} deviceStatusText - The text to be set.
264
+ * @returns {Object}
265
+ */
266
+export function setDeviceStatusWarning(deviceStatusText: string) {
267
+    return {
268
+        type: SET_DEVICE_STATUS,
269
+        value: {
270
+            deviceStatusText,
271
+            deviceStatusType: 'warning'
272
+        }
273
+    };
274
+}
275
+
276
+
277
+/**
278
+ * Action used to set the visiblitiy of the 'JoinByPhoneDialog'.
279
+ *
280
+ * @param {boolean} value - The value.
281
+ * @returns {Object}
282
+ */
283
+export function setJoinByPhoneDialogVisiblity(value: boolean) {
284
+    return {
285
+        type: SET_JOIN_BY_PHONE_DIALOG_VISIBLITY,
286
+        value
287
+    };
288
+}
289
+
290
+/**
291
+ * Action used to set the initial errors after creating the tracks.
292
+ *
293
+ * @param {Object} value - The track errors.
294
+ * @returns {Object}
295
+ */
296
+export function setPrejoinDeviceErrors(value: Object) {
297
+    return {
298
+        type: SET_PREJOIN_DEVICE_ERRORS,
299
+        value
300
+    };
301
+}
302
+
303
+/**
304
+ * Action used to set the name of the guest user.
305
+ *
306
+ * @param {string} value - The name.
307
+ * @returns {Object}
308
+ */
309
+export function setPrejoinName(value: string) {
310
+    return {
311
+        type: SET_PREJOIN_NAME,
312
+        value
313
+    };
314
+}
315
+
316
+/**
317
+ * Action used to set the visiblity of the prejoin page.
318
+ *
319
+ * @param {boolean} value - The value.
320
+ * @returns {Object}
321
+ */
322
+export function setPrejoinPageVisibility(value: boolean) {
323
+    return {
324
+        type: SET_PREJOIN_PAGE_VISIBILITY,
325
+        value
326
+    };
327
+}
328
+
329
+/**
330
+ * Action used to mark the start of the conference.
331
+ *
332
+ * @returns {Object}
333
+ */
334
+function startConference() {
335
+    return {
336
+        type: PREJOIN_START_CONFERENCE
337
+    };
338
+}

+ 197
- 0
react/features/prejoin/components/Prejoin.js View File

@@ -0,0 +1,197 @@
1
+// @flow
2
+
3
+import React, { Component } from 'react';
4
+import {
5
+    joinConference as joinConferenceAction,
6
+    joinConferenceWithoutAudio as joinConferenceWithoutAudioAction,
7
+    setJoinByPhoneDialogVisiblity as setJoinByPhoneDialogVisiblityAction,
8
+    setPrejoinName
9
+} from '../actions';
10
+import { getRoomName } from '../../base/conference';
11
+import { translate } from '../../base/i18n';
12
+import { connect } from '../../base/redux';
13
+import ActionButton from './buttons/ActionButton';
14
+import {
15
+    areJoinByPhoneButtonsVisible,
16
+    getPrejoinName,
17
+    isDeviceStatusVisible,
18
+    isJoinByPhoneDialogVisible
19
+} from '../functions';
20
+import { isGuest } from '../../invite';
21
+import CopyMeetingUrl from './preview/CopyMeetingUrl';
22
+import DeviceStatus from './preview/DeviceStatus';
23
+import ParticipantName from './preview/ParticipantName';
24
+import Preview from './preview/Preview';
25
+import { VideoSettingsButton, AudioSettingsButton } from '../../toolbox';
26
+
27
+type Props = {
28
+
29
+    /**
30
+     * Flag signaling if the device status is visible or not.
31
+     */
32
+    deviceStatusVisible: boolean,
33
+
34
+    /**
35
+     * Flag signaling if a user is logged in or not.
36
+     */
37
+    isAnonymousUser: boolean,
38
+
39
+    /**
40
+     * Joins the current meeting.
41
+     */
42
+    joinConference: Function,
43
+
44
+    /**
45
+     * Joins the current meeting without audio.
46
+     */
47
+    joinConferenceWithoutAudio: Function,
48
+
49
+    /**
50
+     * The name of the user that is about to join.
51
+     */
52
+    name: string,
53
+
54
+    /**
55
+     * Sets the name for the joining user.
56
+     */
57
+    setName: Function,
58
+
59
+    /**
60
+     * The name of the meeting that is about to be joined.
61
+     */
62
+    roomName: string,
63
+
64
+    /**
65
+     * Sets visibilit of the 'JoinByPhoneDialog'.
66
+     */
67
+    setJoinByPhoneDialogVisiblity: Function,
68
+
69
+    /**
70
+     * If 'JoinByPhoneDialog' is visible or not.
71
+     */
72
+    showDialog: boolean,
73
+
74
+    /**
75
+     * If join by phone buttons should be visible.
76
+     */
77
+    showJoinByPhoneButtons: boolean,
78
+
79
+    /**
80
+     * Used for translation.
81
+     */
82
+    t: Function,
83
+};
84
+
85
+/**
86
+ * This component is displayed before joining a meeting.
87
+ */
88
+class Prejoin extends Component<Props> {
89
+    /**
90
+     * Initializes a new {@code Prejoin} instance.
91
+     *
92
+     * @inheritdoc
93
+     */
94
+    constructor(props) {
95
+        super(props);
96
+
97
+        this._showDialog = this._showDialog.bind(this);
98
+    }
99
+
100
+    _showDialog: () => void;
101
+
102
+    /**
103
+     * Displays the dialog for joining a meeting by phone.
104
+     *
105
+     * @returns {undefined}
106
+     */
107
+    _showDialog() {
108
+        this.props.setJoinByPhoneDialogVisiblity(true);
109
+    }
110
+
111
+    /**
112
+     * Implements React's {@link Component#render()}.
113
+     *
114
+     * @inheritdoc
115
+     * @returns {ReactElement}
116
+     */
117
+    render() {
118
+        const {
119
+            deviceStatusVisible,
120
+            isAnonymousUser,
121
+            joinConference,
122
+            joinConferenceWithoutAudio,
123
+            name,
124
+            setName,
125
+            showJoinByPhoneButtons,
126
+            t
127
+        } = this.props;
128
+        const { _showDialog } = this;
129
+
130
+        return (
131
+            <div className = 'prejoin-full-page'>
132
+                <Preview />
133
+                <div className = 'prejoin-input-area-container'>
134
+                    <div className = 'prejoin-input-area'>
135
+                        <div className = 'prejoin-title'>
136
+                            {t('prejoin.joinMeeting')}
137
+                        </div>
138
+                        <CopyMeetingUrl />
139
+                        <ParticipantName
140
+                            isEditable = { isAnonymousUser }
141
+                            setName = { setName }
142
+                            value = { name } />
143
+                        <ActionButton
144
+                            onClick = { joinConference }
145
+                            type = 'primary'>
146
+                            { t('calendarSync.join') }
147
+                        </ActionButton>
148
+                        {showJoinByPhoneButtons
149
+                            && <div className = 'prejoin-text-btns'>
150
+                                <ActionButton
151
+                                    onClick = { joinConferenceWithoutAudio }
152
+                                    type = 'text'>
153
+                                    { t('prejoin.joinWithoutAudio') }
154
+                                </ActionButton>
155
+                                <ActionButton
156
+                                    onClick = { _showDialog }
157
+                                    type = 'text'>
158
+                                    { t('prejoin.joinAudioByPhone') }
159
+                                </ActionButton>
160
+                            </div>}
161
+                    </div>
162
+                </div>
163
+                <div className = 'prejoin-preview-btn-container'>
164
+                    <AudioSettingsButton visible = { true } />
165
+                    <VideoSettingsButton visible = { true } />
166
+                </div>
167
+                { deviceStatusVisible && <DeviceStatus /> }
168
+            </div>
169
+        );
170
+    }
171
+}
172
+
173
+/**
174
+ * Maps (parts of) the redux state to the React {@code Component} props.
175
+ *
176
+ * @param {Object} state - The redux state.
177
+ * @returns {Object}
178
+ */
179
+function mapStateToProps(state): Object {
180
+    return {
181
+        isAnonymousUser: isGuest(state),
182
+        deviceStatusVisible: isDeviceStatusVisible(state),
183
+        name: getPrejoinName(state),
184
+        roomName: getRoomName(state),
185
+        showDialog: isJoinByPhoneDialogVisible(state),
186
+        showJoinByPhoneButtons: areJoinByPhoneButtonsVisible(state)
187
+    };
188
+}
189
+
190
+const mapDispatchToProps = {
191
+    joinConferenceWithoutAudio: joinConferenceWithoutAudioAction,
192
+    joinConference: joinConferenceAction,
193
+    setJoinByPhoneDialogVisiblity: setJoinByPhoneDialogVisiblityAction,
194
+    setName: setPrejoinName
195
+};
196
+
197
+export default connect(mapStateToProps, mapDispatchToProps)(translate(Prejoin));

+ 51
- 0
react/features/prejoin/components/buttons/ActionButton.js View File

@@ -0,0 +1,51 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+const classNameByType = {
5
+    primary: 'prejoin-btn--primary',
6
+    secondary: 'prejoin-btn--secondary',
7
+    text: 'prejoin-btn--text'
8
+};
9
+
10
+type Props = {
11
+
12
+    /**
13
+     * Text of the button.
14
+     */
15
+    children: React$Node,
16
+
17
+    /**
18
+     * Text css class of the button.
19
+     */
20
+    className?: string,
21
+
22
+    /**
23
+     * The type of th button: primary, secondary, text.
24
+     */
25
+    type: string,
26
+
27
+    /**
28
+     * OnClick button handler.
29
+     */
30
+    onClick: Function,
31
+};
32
+
33
+/**
34
+ * Button used for prejoin actions: Join/Join without audio/Join by phone.
35
+ *
36
+ * @returns {ReactElement}
37
+ */
38
+function ActionButton({ children, className, type, onClick }: Props) {
39
+    const ownClassName = `prejoin-btn ${classNameByType[type]}`;
40
+    const cls = className ? `${className} ${ownClassName}` : ownClassName;
41
+
42
+    return (
43
+        <div
44
+            className = { cls }
45
+            onClick = { onClick }>
46
+            {children}
47
+        </div>
48
+    );
49
+}
50
+
51
+export default ActionButton;

+ 197
- 0
react/features/prejoin/components/preview/CopyMeetingUrl.js View File

@@ -0,0 +1,197 @@
1
+// @flow
2
+
3
+import React, { Component } from 'react';
4
+import { connect } from '../../../base/redux';
5
+import { translate } from '../../../base/i18n';
6
+import { getCurrentConferenceUrl } from '../../../base/connection';
7
+import { Icon, IconCopy, IconCheck } from '../../../base/icons';
8
+import logger from '../../logger';
9
+
10
+type Props = {
11
+
12
+    /**
13
+     * The meeting url.
14
+     */
15
+    url: string,
16
+
17
+    /**
18
+     * Used for translation.
19
+     */
20
+    t: Function
21
+};
22
+
23
+type State = {
24
+
25
+    /**
26
+     * If true it shows the 'copy link' message.
27
+     */
28
+    showCopyLink: boolean,
29
+
30
+    /**
31
+     * If true it shows the 'link copied' message.
32
+     */
33
+    showLinkCopied: boolean,
34
+};
35
+
36
+const COPY_TIMEOUT = 2000;
37
+
38
+/**
39
+ * Component used to copy meeting url on prejoin page.
40
+ */
41
+class CopyMeetingUrl extends Component<Props, State> {
42
+
43
+    textarea: Object;
44
+
45
+    /**
46
+     * Initializes a new {@code Prejoin} instance.
47
+     *
48
+     * @inheritdoc
49
+     */
50
+    constructor(props) {
51
+        super(props);
52
+
53
+        this.textarea = React.createRef();
54
+        this.state = {
55
+            showCopyLink: false,
56
+            showLinkCopied: false
57
+        };
58
+        this._copyUrl = this._copyUrl.bind(this);
59
+        this._hideCopyLink = this._hideCopyLink.bind(this);
60
+        this._hideLinkCopied = this._hideLinkCopied.bind(this);
61
+        this._showCopyLink = this._showCopyLink.bind(this);
62
+        this._showLinkCopied = this._showLinkCopied.bind(this);
63
+    }
64
+
65
+    _copyUrl: () => void;
66
+
67
+    /**
68
+     * Callback invoked to copy the url to clipboard.
69
+     *
70
+     * @returns {void}
71
+     */
72
+    _copyUrl() {
73
+        const textarea = this.textarea.current;
74
+
75
+        try {
76
+            textarea.select();
77
+            document.execCommand('copy');
78
+            textarea.blur();
79
+            this._showLinkCopied();
80
+            window.setTimeout(this._hideLinkCopied, COPY_TIMEOUT);
81
+        } catch (err) {
82
+            logger.error('error when copying the meeting url');
83
+        }
84
+    }
85
+
86
+    _hideLinkCopied: () => void;
87
+
88
+    /**
89
+     * Hides the 'Link copied' message.
90
+     *
91
+     * @private
92
+     * @returns {void}
93
+     */
94
+    _hideLinkCopied() {
95
+        this.setState({
96
+            showLinkCopied: false
97
+        });
98
+    }
99
+
100
+    _hideCopyLink: () => void;
101
+
102
+    /**
103
+     * Hides the 'Copy link' text.
104
+     *
105
+     * @private
106
+     * @returns {void}
107
+     */
108
+    _hideCopyLink() {
109
+        this.setState({
110
+            showCopyLink: false
111
+        });
112
+    }
113
+
114
+    _showCopyLink: () => void;
115
+
116
+    /**
117
+     * Shows the dark 'Copy link' text on hover.
118
+     *
119
+     * @private
120
+     * @returns {void}
121
+     */
122
+    _showCopyLink() {
123
+        this.setState({
124
+            showCopyLink: true
125
+        });
126
+    }
127
+
128
+    _showLinkCopied: () => void;
129
+
130
+    /**
131
+     * Shows the green 'Link copied' message.
132
+     *
133
+     * @private
134
+     * @returns {void}
135
+     */
136
+    _showLinkCopied() {
137
+        this.setState({
138
+            showLinkCopied: true,
139
+            showCopyLink: false
140
+        });
141
+    }
142
+
143
+    /**
144
+     * Implements React's {@link Component#render()}.
145
+     *
146
+     * @inheritdoc
147
+     * @returns {ReactElement}
148
+     */
149
+    render() {
150
+        const { showCopyLink, showLinkCopied } = this.state;
151
+        const { url, t } = this.props;
152
+        const { _copyUrl, _showCopyLink, _hideCopyLink } = this;
153
+        const src = showLinkCopied ? IconCheck : IconCopy;
154
+        const iconCls = showCopyLink || showCopyLink ? 'prejoin-copy-icon--white' : 'prejoin-copy-icon--light';
155
+
156
+        return (
157
+            <div
158
+                className = 'prejoin-copy-meeting'
159
+                onMouseEnter = { _showCopyLink }
160
+                onMouseLeave = { _hideCopyLink }>
161
+                <div className = 'prejoin-copy-url'>{url}</div>
162
+                {showCopyLink && <div
163
+                    className = 'prejoin-copy-badge prejoin-copy-badge--hover'
164
+                    onClick = { _copyUrl }>
165
+                    {t('prejoin.copyAndShare')}
166
+                </div>}
167
+                {showLinkCopied && <div
168
+                    className = 'prejoin-copy-badge prejoin-copy-badge--done'>
169
+                    {t('prejoin.linkCopied')}
170
+                </div>}
171
+                <Icon
172
+                    className = { `prejoin-copy-icon ${iconCls}` }
173
+                    size = { 24 }
174
+                    src = { src } />
175
+                <textarea
176
+                    className = 'prejoin-copy-textarea'
177
+                    readOnly = { true }
178
+                    ref = { this.textarea }
179
+                    tabIndex = '-1'
180
+                    value = { url } />
181
+            </div>);
182
+    }
183
+}
184
+
185
+/**
186
+ * Maps (parts of) the redux state to the React {@code Component} props.
187
+ *
188
+ * @param {Object} state - The redux state.
189
+ * @returns {Object}
190
+ */
191
+function mapStateToProps(state) {
192
+    return {
193
+        url: getCurrentConferenceUrl(state)
194
+    };
195
+}
196
+
197
+export default connect(mapStateToProps)(translate(CopyMeetingUrl));

+ 83
- 0
react/features/prejoin/components/preview/DeviceStatus.js View File

@@ -0,0 +1,83 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+import { translate } from '../../../base/i18n';
5
+import { Icon, IconCheck, IconExclamation } from '../../../base/icons';
6
+import { connect } from '../../../base/redux';
7
+import {
8
+    getDeviceStatusType,
9
+    getDeviceStatusText,
10
+    getRawError
11
+} from '../../functions';
12
+
13
+export type Props = {
14
+
15
+    /**
16
+     * The text to be displayed in relation to the status of the audio/video devices.
17
+     */
18
+    deviceStatusText: string,
19
+
20
+    /**
21
+     * The type of status for current devices, controlling the background color of the text.
22
+     * Can be `ok` or `warning`.
23
+     */
24
+    deviceStatusType: string,
25
+
26
+    /**
27
+     * The error coming from device configuration.
28
+     */
29
+    rawError: string,
30
+
31
+    /**
32
+     * Used for translation.
33
+     */
34
+    t: Function
35
+};
36
+
37
+const iconMap = {
38
+    warning: {
39
+        src: IconExclamation,
40
+        className: 'prejoin-preview-status--warning'
41
+    },
42
+    ok: {
43
+        src: IconCheck,
44
+        className: 'prejoin-preview-status--ok'
45
+    }
46
+};
47
+
48
+/**
49
+ * Strip showing the current status of the devices.
50
+ * User is informed if there are missing or malfunctioning devices.
51
+ *
52
+ * @returns {ReactElement}
53
+ */
54
+function DeviceStatus({ deviceStatusType, deviceStatusText, rawError, t }: Props) {
55
+    const { src, className } = iconMap[deviceStatusType];
56
+
57
+    return (
58
+        <div className = { `prejoin-preview-status ${className}` }>
59
+            <Icon
60
+                className = 'prejoin-preview-icon'
61
+                size = { 16 }
62
+                src = { src } />
63
+            <span className = 'prejoin-preview-error-desc'>{t(deviceStatusText)}</span>
64
+            <span>{rawError}</span>
65
+        </div>
66
+    );
67
+}
68
+
69
+/**
70
+ * Maps (parts of) the redux state to the React {@code Component} props.
71
+ *
72
+ * @param {Object} state - The redux state.
73
+ * @returns {{ deviceStatusText: string, deviceStatusText: string }}
74
+ */
75
+function mapStateToProps(state) {
76
+    return {
77
+        deviceStatusText: getDeviceStatusText(state),
78
+        deviceStatusType: getDeviceStatusType(state),
79
+        rawError: getRawError(state)
80
+    };
81
+}
82
+
83
+export default translate(connect(mapStateToProps)(DeviceStatus));

+ 80
- 0
react/features/prejoin/components/preview/ParticipantName.js View File

@@ -0,0 +1,80 @@
1
+// @flow
2
+
3
+import React, { Component } from 'react';
4
+import { translate } from '../../../base/i18n';
5
+
6
+type Props = {
7
+
8
+    /**
9
+     * Flag signaling if the name is ediable or not.
10
+     */
11
+    isEditable: boolean,
12
+
13
+    /**
14
+     * Sets the name for the joining user.
15
+     */
16
+    setName: Function,
17
+
18
+    /**
19
+     * Used to obtain translations.
20
+     */
21
+    t: Function,
22
+
23
+    /**
24
+     * The text to be displayed.
25
+     */
26
+    value: string,
27
+};
28
+
29
+/**
30
+ * Participant name - can be an editable input or just the text name.
31
+ *
32
+ * @returns {ReactElement}
33
+ */
34
+class ParticipantName extends Component<Props> {
35
+    /**
36
+     * Initializes a new {@code ParticipantName} instance.
37
+     *
38
+     * @param {Props} props - The props of the component.
39
+     * @inheritdoc
40
+     */
41
+    constructor(props) {
42
+        super(props);
43
+
44
+        this._onNameChange = this._onNameChange.bind(this);
45
+    }
46
+
47
+    _onNameChange: () => void;
48
+
49
+    /**
50
+     * Handler used for changing the guest user name.
51
+     *
52
+     * @returns {undefined}
53
+     */
54
+    _onNameChange({ target: { value } }) {
55
+        this.props.setName(value);
56
+    }
57
+
58
+    /**
59
+     * Implements React's {@link Component#render()}.
60
+     *
61
+     * @inheritdoc
62
+     * @returns {ReactElement}
63
+     */
64
+    render() {
65
+        const { value, isEditable, t } = this.props;
66
+
67
+        return isEditable ? (
68
+            <input
69
+                className = 'prejoin-preview-name prejoin-preview-name--editable'
70
+                onChange = { this._onNameChange }
71
+                placeholder = { t('dialog.enterDisplayName') }
72
+                value = { value } />
73
+        )
74
+            : <div className = 'prejoin-preview-name'>{value}</div>
75
+        ;
76
+    }
77
+}
78
+
79
+
80
+export default translate(ParticipantName);

+ 75
- 0
react/features/prejoin/components/preview/Preview.js View File

@@ -0,0 +1,75 @@
1
+// @flow
2
+
3
+import React from 'react';
4
+import { Avatar } from '../../../base/avatar';
5
+import { Video } from '../../../base/media';
6
+import { connect } from '../../../base/redux';
7
+import { getActiveVideoTrack, getPrejoinName, isPrejoinVideoMuted } from '../../functions';
8
+
9
+export type Props = {
10
+
11
+    /**
12
+     * The name of the user that is about to join.
13
+     */
14
+    name: string,
15
+
16
+    /**
17
+     * Flag signaling the visibility of camera preview.
18
+     */
19
+    showCameraPreview: boolean,
20
+
21
+    /**
22
+     * The JitsiLocalTrack to display.
23
+     */
24
+    videoTrack: ?Object,
25
+};
26
+
27
+/**
28
+ * Component showing the video preview and device status.
29
+ *
30
+ * @param {Props} props - The props of the component.
31
+ * @returns {ReactElement}
32
+ */
33
+function Preview(props: Props) {
34
+    const {
35
+        name,
36
+        showCameraPreview,
37
+        videoTrack
38
+    } = props;
39
+
40
+    if (showCameraPreview && videoTrack) {
41
+        return (
42
+            <div className = 'prejoin-preview'>
43
+                <div className = 'prejoin-preview-overlay' />
44
+                <Video
45
+                    className = 'flipVideoX prejoin-preview-video'
46
+                    videoTrack = {{ jitsiTrack: videoTrack }} />
47
+            </div>
48
+        );
49
+    }
50
+
51
+    return (
52
+        <div className = 'prejoin-preview prejoin-preview--no-video'>
53
+            <Avatar
54
+                className = 'prejoin-preview-avatar'
55
+                displayName = { name }
56
+                size = { 200 } />
57
+        </div>
58
+    );
59
+}
60
+
61
+/**
62
+ * Maps the redux state to the React {@code Component} props.
63
+ *
64
+ * @param {Object} state - The redux state.
65
+ * @returns {Object}
66
+ */
67
+function mapStateToProps(state) {
68
+    return {
69
+        name: getPrejoinName(state),
70
+        videoTrack: getActiveVideoTrack(state),
71
+        showCameraPreview: !isPrejoinVideoMuted(state)
72
+    };
73
+}
74
+
75
+export default connect(mapStateToProps)(Preview);

+ 228
- 0
react/features/prejoin/functions.js View File

@@ -0,0 +1,228 @@
1
+// @flow
2
+
3
+
4
+/**
5
+ * Mutes or unmutes a track.
6
+ *
7
+ * @param {Object} track - The track to be configured.
8
+ * @param {boolean} shouldMute - If it should mute or not.
9
+ * @returns {Promise<void>}
10
+ */
11
+function applyMuteOptionsToTrack(track, shouldMute) {
12
+    if (track.isMuted() === shouldMute) {
13
+        return;
14
+    }
15
+
16
+    if (shouldMute) {
17
+        return track.mute();
18
+    }
19
+
20
+    return track.unmute();
21
+}
22
+
23
+/**
24
+ * Selector for the visibility of the 'join by phone' buttons.
25
+ *
26
+ * @param {Object} state - The state of the app.
27
+ * @returns {boolean}
28
+ */
29
+export function areJoinByPhoneButtonsVisible(state: Object): boolean {
30
+    return state['features/prejoin'].buttonsVisible;
31
+}
32
+
33
+/**
34
+ * Selector for determining if the device status strip is visible or not.
35
+ *
36
+ * @param {Object} state - The state of the app.
37
+ * @returns {boolean}
38
+ */
39
+export function isDeviceStatusVisible(state: Object): boolean {
40
+    return !((isAudioDisabled(state) && isPrejoinVideoDisabled(state))
41
+        || (isPrejoinAudioMuted(state) && isPrejoinVideoMuted(state)));
42
+}
43
+
44
+/**
45
+ * Selector for getting the active video/content sharing track.
46
+ *
47
+ * @param {Object} state - The state of the app.
48
+ * @returns {boolean}
49
+ */
50
+export function getActiveVideoTrack(state: Object): Object {
51
+    const track = getVideoTrack(state) || getContentSharingTrack(state);
52
+
53
+    if (track && track.isActive()) {
54
+        return track;
55
+    }
56
+
57
+    return null;
58
+}
59
+
60
+/**
61
+ * Returns a list with all the prejoin tracks configured according to
62
+ * user's preferences.
63
+ *
64
+ * @param {Object} state - The state of the app.
65
+ * @returns {Promise<Object[]>}
66
+ */
67
+export async function getAllPrejoinConfiguredTracks(state: Object): Promise<Object[]> {
68
+    const tracks = [];
69
+    const audioTrack = getAudioTrack(state);
70
+    const videoTrack = getVideoTrack(state);
71
+    const csTrack = getContentSharingTrack(state);
72
+
73
+    if (csTrack) {
74
+        tracks.push(csTrack);
75
+    } else if (videoTrack) {
76
+        await applyMuteOptionsToTrack(videoTrack, isPrejoinVideoMuted(state));
77
+        tracks.push(videoTrack);
78
+    }
79
+
80
+    if (audioTrack) {
81
+        await applyMuteOptionsToTrack(audioTrack, isPrejoinAudioMuted(state));
82
+        isPrejoinAudioMuted(state) && audioTrack.mute();
83
+        tracks.push(audioTrack);
84
+    }
85
+
86
+    return tracks;
87
+}
88
+
89
+/**
90
+ * Selector for getting the prejoin audio track.
91
+ *
92
+ * @param {Object} state - The state of the app.
93
+ * @returns {Object}
94
+ */
95
+export function getAudioTrack(state: Object): Object {
96
+    return state['features/prejoin'].audioTrack;
97
+}
98
+
99
+/**
100
+ * Selector for getting the prejoin content sharing track.
101
+ *
102
+ * @param {Object} state - The state of the app.
103
+ * @returns {Object}
104
+ */
105
+export function getContentSharingTrack(state: Object): Object {
106
+    return state['features/prejoin'].contentSharingTrack;
107
+}
108
+
109
+/**
110
+ * Returns the text for the prejoin status bar.
111
+ *
112
+ * @param {Object} state - The state of the app.
113
+ * @returns {string}
114
+ */
115
+export function getDeviceStatusText(state: Object): string {
116
+    return state['features/prejoin'].deviceStatusText;
117
+}
118
+
119
+/**
120
+ * Returns the type of the prejoin status bar: 'ok'|'warning'.
121
+ *
122
+ * @param {Object} state - The state of the app.
123
+ * @returns {string}
124
+ */
125
+export function getDeviceStatusType(state: Object): string {
126
+    return state['features/prejoin'].deviceStatusType;
127
+}
128
+
129
+/**
130
+ * Selector for getting the prejoin video track.
131
+ *
132
+ * @param {Object} state - The state of the app.
133
+ * @returns {Object}
134
+ */
135
+export function getVideoTrack(state: Object): Object {
136
+    return state['features/prejoin'].videoTrack;
137
+}
138
+
139
+/**
140
+ * Selector for getting the mute status of the prejoin audio.
141
+ *
142
+ * @param {Object} state - The state of the app.
143
+ * @returns {boolean}
144
+ */
145
+export function isPrejoinAudioMuted(state: Object): boolean {
146
+    return state['features/prejoin'].audioMuted;
147
+}
148
+
149
+/**
150
+ * Selector for getting the name that the user filled while configuring.
151
+ *
152
+ * @param {Object} state - The state of the app.
153
+ * @returns {boolean}
154
+ */
155
+export function getPrejoinName(state: Object): string {
156
+    return state['features/prejoin'].name;
157
+}
158
+
159
+/**
160
+ * Selector for getting the mute status of the prejoin video.
161
+ *
162
+ * @param {Object} state - The state of the app.
163
+ * @returns {boolean}
164
+ */
165
+export function isPrejoinVideoMuted(state: Object): boolean {
166
+    return state['features/prejoin'].videoMuted;
167
+}
168
+
169
+/**
170
+ * Selector for getting the error if any while creating streams.
171
+ *
172
+ * @param {Object} state - The state of the app.
173
+ * @returns {string}
174
+ */
175
+export function getRawError(state: Object): string {
176
+    return state['features/prejoin'].rawError;
177
+}
178
+
179
+/**
180
+ * Selector for getting state of the prejoin audio.
181
+ *
182
+ * @param {Object} state - The state of the app.
183
+ * @returns {boolean}
184
+ */
185
+export function isAudioDisabled(state: Object): Object {
186
+    return state['features/prejoin'].audioDisabled;
187
+}
188
+
189
+/**
190
+ * Selector for getting state of the prejoin video.
191
+ *
192
+ * @param {Object} state - The state of the app.
193
+ * @returns {boolean}
194
+ */
195
+export function isPrejoinVideoDisabled(state: Object): Object {
196
+    return state['features/prejoin'].videoDisabled;
197
+}
198
+
199
+/**
200
+ * Selector for getting the visiblity state for the 'JoinByPhoneDialog'.
201
+ *
202
+ * @param {Object} state - The state of the app.
203
+ * @returns {boolean}
204
+ */
205
+export function isJoinByPhoneDialogVisible(state: Object): boolean {
206
+    return state['features/prejoin'].showJoinByPhoneDialog;
207
+}
208
+
209
+/**
210
+ * Returns true if the prejoin page is enabled and no flag
211
+ * to bypass showing the page is present.
212
+ *
213
+ * @param {Object} state - The state of the app.
214
+ * @returns {boolean}
215
+ */
216
+export function isPrejoinPageEnabled(state: Object): boolean {
217
+    return state['features/base/config'].prejoinPageEnabled;
218
+}
219
+
220
+/**
221
+ * Returns true if the prejoin page is visible & active.
222
+ *
223
+ * @param {Object} state - The state of the app.
224
+ * @returns {boolean}
225
+ */
226
+export function isPrejoinPageVisible(state: Object): boolean {
227
+    return isPrejoinPageEnabled(state) && state['features/prejoin'].showPrejoin;
228
+}

+ 7
- 0
react/features/prejoin/index.js View File

@@ -0,0 +1,7 @@
1
+export * from './actions';
2
+export * from './functions';
3
+
4
+export { default as Prejoin } from './components/Prejoin';
5
+
6
+import './middleware';
7
+import './reducer';

+ 5
- 0
react/features/prejoin/logger.js View File

@@ -0,0 +1,5 @@
1
+// @flow
2
+
3
+import { getLogger } from '../base/logging/functions';
4
+
5
+export default getLogger('features/prejoin');

+ 95
- 0
react/features/prejoin/middleware.js View File

@@ -0,0 +1,95 @@
1
+// @flow
2
+
3
+import {
4
+    ADD_PREJOIN_AUDIO_TRACK,
5
+    ADD_PREJOIN_VIDEO_TRACK,
6
+    PREJOIN_START_CONFERENCE
7
+} from './actionTypes';
8
+import { setPrejoinAudioMuted, setPrejoinVideoMuted } from './actions';
9
+import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../base/media';
10
+import { participantUpdated, getLocalParticipant } from '../base/participants';
11
+import { MiddlewareRegistry } from '../base/redux';
12
+import { updateSettings } from '../base/settings';
13
+import { getAllPrejoinConfiguredTracks, getPrejoinName } from './functions';
14
+
15
+declare var APP: Object;
16
+
17
+/**
18
+ * The redux middleware for {@link PrejoinPage}.
19
+ *
20
+ * @param {Store} store - The redux store.
21
+ * @returns {Function}
22
+ */
23
+MiddlewareRegistry.register(store => next => async action => {
24
+    switch (action.type) {
25
+    case ADD_PREJOIN_AUDIO_TRACK: {
26
+        const { value: audioTrack } = action;
27
+
28
+        if (audioTrack) {
29
+            store.dispatch(
30
+                    updateSettings({
31
+                        micDeviceId: audioTrack.getDeviceId()
32
+                    }),
33
+            );
34
+        }
35
+
36
+        break;
37
+    }
38
+
39
+    case ADD_PREJOIN_VIDEO_TRACK: {
40
+        const { value: videoTrack } = action;
41
+
42
+        if (videoTrack) {
43
+            store.dispatch(
44
+                    updateSettings({
45
+                        cameraDeviceId: videoTrack.getDeviceId()
46
+                    }),
47
+            );
48
+        }
49
+
50
+        break;
51
+    }
52
+
53
+    case PREJOIN_START_CONFERENCE: {
54
+        const { dispatch, getState } = store;
55
+
56
+        _syncParticipantName(dispatch, getState);
57
+        const tracks = await getAllPrejoinConfiguredTracks(getState());
58
+
59
+        APP.conference.prejoinStart(tracks);
60
+
61
+        break;
62
+    }
63
+
64
+    case SET_AUDIO_MUTED: {
65
+        store.dispatch(setPrejoinAudioMuted(Boolean(action.muted)));
66
+        break;
67
+    }
68
+
69
+    case SET_VIDEO_MUTED: {
70
+        store.dispatch(setPrejoinVideoMuted(Boolean(action.muted)));
71
+        break;
72
+    }
73
+    }
74
+
75
+    return next(action);
76
+});
77
+
78
+/**
79
+ * Sets the local participant name if one is present.
80
+ *
81
+ * @param {Function} dispatch - The redux dispatch function.
82
+ * @param {Function} getState - Gets the current state.
83
+ * @returns {undefined}
84
+ */
85
+function _syncParticipantName(dispatch, getState) {
86
+    const state = getState();
87
+    const name = getPrejoinName(state);
88
+
89
+    name && dispatch(
90
+            participantUpdated({
91
+                ...getLocalParticipant(state),
92
+                name
93
+            }),
94
+    );
95
+}

+ 168
- 0
react/features/prejoin/reducer.js View File

@@ -0,0 +1,168 @@
1
+import { ReducerRegistry } from '../base/redux';
2
+
3
+import {
4
+    ADD_PREJOIN_AUDIO_TRACK,
5
+    ADD_PREJOIN_CONTENT_SHARING_TRACK,
6
+    ADD_PREJOIN_VIDEO_TRACK,
7
+    SET_DEVICE_STATUS,
8
+    SET_JOIN_BY_PHONE_DIALOG_VISIBLITY,
9
+    SET_PREJOIN_AUDIO_DISABLED,
10
+    SET_PREJOIN_AUDIO_MUTED,
11
+    SET_PREJOIN_DEVICE_ERRORS,
12
+    SET_PREJOIN_NAME,
13
+    SET_PREJOIN_PAGE_VISIBILITY,
14
+    SET_PREJOIN_VIDEO_DISABLED,
15
+    SET_PREJOIN_VIDEO_MUTED
16
+} from './actionTypes';
17
+
18
+const DEFAULT_STATE = {
19
+    audioDisabled: false,
20
+    audioMuted: false,
21
+    videoMuted: false,
22
+    videoDisabled: false,
23
+    deviceStatusText: 'prejoin.configuringDevices',
24
+    deviceStatusType: 'ok',
25
+    showPrejoin: true,
26
+    showJoinByPhoneDialog: false,
27
+    videoTrack: null,
28
+    audioTrack: null,
29
+    contentSharingTrack: null,
30
+    rawError: '',
31
+    name: ''
32
+};
33
+
34
+/**
35
+ * Listen for actions that mutate the prejoin state
36
+ */
37
+ReducerRegistry.register(
38
+    'features/prejoin', (state = DEFAULT_STATE, action) => {
39
+        switch (action.type) {
40
+        case ADD_PREJOIN_AUDIO_TRACK: {
41
+            return {
42
+                ...state,
43
+                audioTrack: action.value
44
+            };
45
+        }
46
+
47
+        case ADD_PREJOIN_CONTENT_SHARING_TRACK: {
48
+            return {
49
+                ...state,
50
+                contentSharingTrack: action.value
51
+            };
52
+        }
53
+
54
+        case ADD_PREJOIN_VIDEO_TRACK: {
55
+            return {
56
+                ...state,
57
+                videoTrack: action.value
58
+            };
59
+        }
60
+
61
+        case SET_PREJOIN_NAME: {
62
+            return {
63
+                ...state,
64
+                name: action.value
65
+            };
66
+        }
67
+
68
+        case SET_PREJOIN_PAGE_VISIBILITY:
69
+            return {
70
+                ...state,
71
+                showPrejoin: action.value
72
+            };
73
+
74
+        case SET_PREJOIN_VIDEO_DISABLED: {
75
+            return {
76
+                ...state,
77
+                videoDisabled: action.value
78
+            };
79
+        }
80
+
81
+        case SET_PREJOIN_VIDEO_MUTED:
82
+            return {
83
+                ...state,
84
+                videoMuted: action.value
85
+            };
86
+
87
+        case SET_PREJOIN_AUDIO_MUTED:
88
+            return {
89
+                ...state,
90
+                audioMuted: action.value
91
+            };
92
+
93
+        case SET_PREJOIN_DEVICE_ERRORS: {
94
+            const status = getStatusFromErrors(action.value);
95
+
96
+            return {
97
+                ...state,
98
+                ...status
99
+            };
100
+        }
101
+
102
+        case SET_DEVICE_STATUS: {
103
+            return {
104
+                ...state,
105
+                deviceStatusText: action.text,
106
+                deviceStatusType: action.type
107
+            };
108
+        }
109
+
110
+        case SET_PREJOIN_AUDIO_DISABLED: {
111
+            return {
112
+                ...state,
113
+                audioDisabled: true
114
+            };
115
+        }
116
+
117
+        case SET_JOIN_BY_PHONE_DIALOG_VISIBLITY: {
118
+            return {
119
+                ...state,
120
+                showJoinByPhoneDialog: action.value
121
+            };
122
+        }
123
+
124
+        default:
125
+            return state;
126
+        }
127
+    },
128
+);
129
+
130
+/**
131
+ * Returns a suitable error object based on the track errors.
132
+ *
133
+ * @param {Object} errors - The errors got while creating local tracks.
134
+ * @returns {Object}
135
+ */
136
+function getStatusFromErrors(errors) {
137
+    const { audioOnlyError, videoOnlyError, audioAndVideoError } = errors;
138
+
139
+    if (audioAndVideoError) {
140
+        if (audioOnlyError) {
141
+            if (videoOnlyError) {
142
+                return {
143
+                    deviceStatusType: 'warning',
144
+                    deviceStatusText: 'prejoin.audioAndVideoError',
145
+                    rawError: audioAndVideoError.message
146
+                };
147
+            }
148
+
149
+            return {
150
+                deviceStatusType: 'warning',
151
+                deviceStatusText: 'prejoin.audioOnlyError',
152
+                rawError: audioOnlyError.message
153
+            };
154
+        }
155
+
156
+        return {
157
+            deviceStatusType: 'warning',
158
+            deviceStatusText: 'prejoin.videoOnlyError',
159
+            rawError: audioAndVideoError.message
160
+        };
161
+    }
162
+
163
+    return {
164
+        deviceStatusType: 'ok',
165
+        deviceStatusText: 'prejoin.lookGood',
166
+        rawError: ''
167
+    };
168
+}

+ 4
- 2
react/features/settings/components/web/video/VideoSettingsContent.js View File

@@ -211,8 +211,10 @@ class VideoSettingsContent extends Component<Props, State> {
211 211
         const { trackData } = this.state;
212 212
 
213 213
         return (
214
-            <div className = 'video-preview'>
215
-                {trackData.map((data, i) => this._renderPreviewEntry(data, i))}
214
+            <div className = 'video-preview-container'>
215
+                <div className = 'video-preview'>
216
+                    {trackData.map((data, i) => this._renderPreviewEntry(data, i))}
217
+                </div>
216 218
             </div>
217 219
         );
218 220
     }

+ 21
- 4
react/features/toolbox/components/AudioMuteButton.js View File

@@ -12,6 +12,11 @@ import { connect } from '../../base/redux';
12 12
 import { AbstractAudioMuteButton } from '../../base/toolbox';
13 13
 import type { AbstractButtonProps } from '../../base/toolbox';
14 14
 import { isLocalTrackMuted } from '../../base/tracks';
15
+import {
16
+    isPrejoinAudioMuted,
17
+    isAudioDisabled,
18
+    isPrejoinPageVisible
19
+} from '../../prejoin';
15 20
 import { muteLocal } from '../../remote-video-menu/actions';
16 21
 
17 22
 declare var APP: Object;
@@ -144,15 +149,27 @@ class AudioMuteButton extends AbstractAudioMuteButton<Props, *> {
144 149
  * @param {Object} state - The Redux state.
145 150
  * @private
146 151
  * @returns {{
147
- *     _audioMuted: boolean
152
+ *     _audioMuted: boolean,
153
+ *     _disabled: boolean
148 154
  * }}
149 155
  */
150 156
 function _mapStateToProps(state): Object {
151
-    const tracks = state['features/base/tracks'];
157
+    let _audioMuted;
158
+    let _disabled;
159
+
160
+    if (isPrejoinPageVisible(state)) {
161
+        _audioMuted = isPrejoinAudioMuted(state);
162
+        _disabled = state['features/base/config'].startSilent;
163
+    } else {
164
+        const tracks = state['features/base/tracks'];
165
+
166
+        _audioMuted = isLocalTrackMuted(tracks, MEDIA_TYPE.AUDIO);
167
+        _disabled = state['features/base/config'].startSilent || isAudioDisabled(state);
168
+    }
152 169
 
153 170
     return {
154
-        _audioMuted: isLocalTrackMuted(tracks, MEDIA_TYPE.AUDIO),
155
-        _disabled: state['features/base/config'].startSilent
171
+        _audioMuted,
172
+        _disabled
156 173
     };
157 174
 }
158 175
 

+ 30
- 1
react/features/toolbox/components/VideoMuteButton.js View File

@@ -17,6 +17,11 @@ import { connect } from '../../base/redux';
17 17
 import { AbstractVideoMuteButton } from '../../base/toolbox';
18 18
 import type { AbstractButtonProps } from '../../base/toolbox';
19 19
 import { getLocalVideoType, isLocalVideoTrackMuted } from '../../base/tracks';
20
+import {
21
+    isPrejoinPageVisible,
22
+    isPrejoinVideoDisabled,
23
+    isPrejoinVideoMuted
24
+} from '../../prejoin';
20 25
 import UIEvents from '../../../../service/UI/UIEvents';
21 26
 
22 27
 declare var APP: Object;
@@ -41,6 +46,11 @@ type Props = AbstractButtonProps & {
41 46
      */
42 47
     _videoMuted: boolean,
43 48
 
49
+    /**
50
+     * Whether video button is disabled or not.
51
+     */
52
+    _videoDisabled: boolean,
53
+
44 54
     /**
45 55
      * The redux {@code dispatch} function.
46 56
      */
@@ -96,6 +106,17 @@ class VideoMuteButton extends AbstractVideoMuteButton<Props, *> {
96 106
             || APP.keyboardshortcut.unregisterShortcut('V');
97 107
     }
98 108
 
109
+    /**
110
+     * Indicates if video is currently disabled or not.
111
+     *
112
+     * @override
113
+     * @protected
114
+     * @returns {boolean}
115
+     */
116
+    _isDisabled() {
117
+        return this.props._videoDisabled;
118
+    }
119
+
99 120
     /**
100 121
      * Indicates if video is currently muted ot nor.
101 122
      *
@@ -170,11 +191,19 @@ class VideoMuteButton extends AbstractVideoMuteButton<Props, *> {
170 191
 function _mapStateToProps(state): Object {
171 192
     const { enabled: audioOnly } = state['features/base/audio-only'];
172 193
     const tracks = state['features/base/tracks'];
194
+    let _videoMuted = isLocalVideoTrackMuted(tracks);
195
+    let _videoDisabled = false;
196
+
197
+    if (isPrejoinPageVisible(state)) {
198
+        _videoMuted = isPrejoinVideoMuted(state);
199
+        _videoDisabled = isPrejoinVideoDisabled(state);
200
+    }
173 201
 
174 202
     return {
175 203
         _audioOnly: Boolean(audioOnly),
204
+        _videoDisabled,
176 205
         _videoMediaType: getLocalVideoType(tracks),
177
-        _videoMuted: isLocalVideoTrackMuted(tracks)
206
+        _videoMuted
178 207
     };
179 208
 }
180 209
 

+ 19
- 9
react/features/toolbox/components/web/AudioSettingsButton.js View File

@@ -3,7 +3,7 @@
3 3
 import React, { Component } from 'react';
4 4
 
5 5
 import AudioMuteButton from '../AudioMuteButton';
6
-import { hasAvailableDevices } from '../../../base/devices';
6
+import { isAudioSettingsButtonDisabled } from '../../functions';
7 7
 import { IconArrowDown } from '../../../base/icons';
8 8
 import JitsiMeetJS from '../../../base/lib-jitsi-meet/_';
9 9
 import { ToolboxButtonWithIcon } from '../../../base/toolbox';
@@ -25,9 +25,9 @@ type Props = {
25 25
     permissionPromptVisibility: boolean,
26 26
 
27 27
     /**
28
-     * If the user has audio input or audio output devices.
28
+     * If the button should be disabled.
29 29
      */
30
-    hasDevices: boolean,
30
+    isDisabled: boolean,
31 31
 
32 32
     /**
33 33
      * Flag controlling the visibility of the button.
@@ -49,6 +49,8 @@ type State = {
49 49
  * @returns {ReactElement}
50 50
  */
51 51
 class AudioSettingsButton extends Component<Props, State> {
52
+    _isMounted: boolean;
53
+
52 54
     /**
53 55
      * Initializes a new {@code AudioSettingsButton} instance.
54 56
      *
@@ -58,6 +60,7 @@ class AudioSettingsButton extends Component<Props, State> {
58 60
     constructor(props) {
59 61
         super(props);
60 62
 
63
+        this._isMounted = true;
61 64
         this.state = {
62 65
             hasPermissions: false
63 66
         };
@@ -73,7 +76,7 @@ class AudioSettingsButton extends Component<Props, State> {
73 76
             'audio',
74 77
         );
75 78
 
76
-        this.setState({
79
+        this._isMounted && this.setState({
77 80
             hasPermissions
78 81
         });
79 82
     }
@@ -98,14 +101,23 @@ class AudioSettingsButton extends Component<Props, State> {
98 101
         }
99 102
     }
100 103
 
104
+    /**
105
+     * Implements React's {@link Component#componentWillUnmount}.
106
+     *
107
+     * @inheritdoc
108
+     */
109
+    componentWillUnmount() {
110
+        this._isMounted = false;
111
+    }
112
+
101 113
     /**
102 114
      * Implements React's {@link Component#render}.
103 115
      *
104 116
      * @inheritdoc
105 117
      */
106 118
     render() {
107
-        const { hasDevices, onAudioOptionsClick, visible } = this.props;
108
-        const settingsDisabled = !this.state.hasPermissions || !hasDevices;
119
+        const { isDisabled, onAudioOptionsClick, visible } = this.props;
120
+        const settingsDisabled = !this.state.hasPermissions || isDisabled;
109 121
 
110 122
         return visible ? (
111 123
             <AudioSettingsPopup>
@@ -128,9 +140,7 @@ class AudioSettingsButton extends Component<Props, State> {
128 140
  */
129 141
 function mapStateToProps(state) {
130 142
     return {
131
-        hasDevices:
132
-            hasAvailableDevices(state, 'audioInput')
133
-            || hasAvailableDevices(state, 'audioOutput'),
143
+        isDisabled: isAudioSettingsButtonDisabled(state),
134 144
         permissionPromptVisibility: getMediaPermissionPromptVisibility(state)
135 145
     };
136 146
 }

+ 19
- 8
react/features/toolbox/components/web/VideoSettingsButton.js View File

@@ -1,11 +1,10 @@
1 1
 // @flow
2 2
 
3 3
 import React, { Component } from 'react';
4
-
4
+import { isVideoSettingsButtonDisabled } from '../../functions';
5 5
 import { toggleVideoSettings, VideoSettingsPopup } from '../../../settings';
6 6
 import VideoMuteButton from '../VideoMuteButton';
7 7
 import JitsiMeetJS from '../../../base/lib-jitsi-meet/_';
8
-import { hasAvailableDevices } from '../../../base/devices';
9 8
 import { IconArrowDown } from '../../../base/icons';
10 9
 import { connect } from '../../../base/redux';
11 10
 import { ToolboxButtonWithIcon } from '../../../base/toolbox';
@@ -25,9 +24,9 @@ type Props = {
25 24
     permissionPromptVisibility: boolean,
26 25
 
27 26
     /**
28
-     * If the user has any video devices.
27
+     * If the button should be disabled
29 28
      */
30
-    hasDevices: boolean,
29
+    isDisabled: boolean,
31 30
 
32 31
     /**
33 32
      * Flag controlling the visibility of the button.
@@ -49,6 +48,8 @@ type State = {
49 48
  * @returns {ReactElement}
50 49
  */
51 50
 class VideoSettingsButton extends Component<Props, State> {
51
+    _isMounted: boolean;
52
+
52 53
     /**
53 54
      * Initializes a new {@code VideoSettingsButton} instance.
54 55
      *
@@ -58,6 +59,7 @@ class VideoSettingsButton extends Component<Props, State> {
58 59
     constructor(props) {
59 60
         super(props);
60 61
 
62
+        this._isMounted = true;
61 63
         this.state = {
62 64
             hasPermissions: false
63 65
         };
@@ -73,7 +75,7 @@ class VideoSettingsButton extends Component<Props, State> {
73 75
             'video',
74 76
         );
75 77
 
76
-        this.setState({
78
+        this._isMounted && this.setState({
77 79
             hasPermissions
78 80
         });
79 81
     }
@@ -98,14 +100,23 @@ class VideoSettingsButton extends Component<Props, State> {
98 100
         }
99 101
     }
100 102
 
103
+    /**
104
+     * Implements React's {@link Component#componentWillUnmount}.
105
+     *
106
+     * @inheritdoc
107
+     */
108
+    componentWillUnmount() {
109
+        this._isMounted = false;
110
+    }
111
+
101 112
     /**
102 113
      * Implements React's {@link Component#render}.
103 114
      *
104 115
      * @inheritdoc
105 116
      */
106 117
     render() {
107
-        const { hasDevices, onVideoOptionsClick, visible } = this.props;
108
-        const iconDisabled = !this.state.hasPermissions || !hasDevices;
118
+        const { isDisabled, onVideoOptionsClick, visible } = this.props;
119
+        const iconDisabled = !this.state.hasPermissions || isDisabled;
109 120
 
110 121
         return visible ? (
111 122
             <VideoSettingsPopup>
@@ -128,7 +139,7 @@ class VideoSettingsButton extends Component<Props, State> {
128 139
  */
129 140
 function mapStateToProps(state) {
130 141
     return {
131
-        hasDevices: hasAvailableDevices(state, 'videoInput'),
142
+        isDisabled: isVideoSettingsButtonDisabled(state),
132 143
         permissionPromptVisibility: getMediaPermissionPromptVisibility(state)
133 144
     };
134 145
 }

+ 2
- 0
react/features/toolbox/components/web/index.js View File

@@ -1,2 +1,4 @@
1
+export { default as AudioSettingsButton } from './AudioSettingsButton';
2
+export { default as VideoSettingsButton } from './VideoSettingsButton';
1 3
 export { default as ToolbarButton } from './ToolbarButton';
2 4
 export { default as Toolbox } from './Toolbox';

+ 36
- 0
react/features/toolbox/functions.web.js View File

@@ -1,5 +1,12 @@
1 1
 // @flow
2 2
 
3
+import {
4
+    isAudioDisabled,
5
+    isPrejoinPageVisible,
6
+    isPrejoinVideoDisabled
7
+} from '../prejoin';
8
+import { hasAvailableDevices } from '../base/devices';
9
+
3 10
 declare var interfaceConfig: Object;
4 11
 
5 12
 /**
@@ -45,3 +52,32 @@ export function isToolboxVisible(state: Object) {
45 52
     return Boolean(!iAmSipGateway && (timeoutID || visible || alwaysVisible
46 53
                                       || audioSettingsVisible || videoSettingsVisible));
47 54
 }
55
+
56
+/**
57
+ * Indicates if the audio settings button is disabled or not.
58
+ *
59
+ * @param {string} state - The state from the Redux store.
60
+ * @returns {boolean}
61
+ */
62
+export function isAudioSettingsButtonDisabled(state: Object) {
63
+    const devicesMissing = !hasAvailableDevices(state, 'audioInput')
64
+          && !hasAvailableDevices(state, 'audioOutput');
65
+
66
+    return isPrejoinPageVisible(state)
67
+        ? devicesMissing || isAudioDisabled(state)
68
+        : devicesMissing;
69
+}
70
+
71
+/**
72
+ * Indicates if the video settings button is disabled or not.
73
+ *
74
+ * @param {string} state - The state from the Redux store.
75
+ * @returns {boolean}
76
+ */
77
+export function isVideoSettingsButtonDisabled(state: Object) {
78
+    const devicesMissing = !hasAvailableDevices(state, 'videoInput');
79
+
80
+    return isPrejoinPageVisible(state)
81
+        ? devicesMissing || isPrejoinVideoDisabled(state)
82
+        : devicesMissing;
83
+}

Loading…
Cancel
Save