Browse Source

feat: UI part for A/V moderation. (#9195)

* feat: Initial UI part for A/V moderation.

Based on https://github.com/jitsi/jitsi-meet/pull/7779

Co-authored-by: Gabriel Imre <gabriel.lucaci@8x8.com>

* feat: Hides context menu in p2p or only moderators in the meeting.

* feat: Show notifications on enable/disable.

* feat(moderation): Add buttons to participant list & notifications

* fix(moderation): Fix raised hand participant leaving

* feat(moderation): Add support for video moderation

* feat(moderation): Add mute all video to context menu

* feat(moderation): Redo participants list 'More menu'

* fix: Fixes clearing av_moderation table.

* fix: Start moderation context menu

* fix(moderation): Show notification if unapproved participant tries to start CS

Co-authored-by: Gabriel Imre <gabriel.lucaci@8x8.com>
Co-authored-by: Vlad Piersec <vlad.piersec@8x8.com>
master
Дамян Минков 4 years ago
parent
commit
64ae9c7953
No account linked to committer's email address
44 changed files with 1642 additions and 197 deletions
  1. 5
    7
      css/_lobby.scss
  2. 17
    0
      lang/main.json
  3. 3
    9
      modules/API/API.js
  4. 1
    0
      react/features/app/middlewares.web.js
  5. 1
    0
      react/features/app/reducers.web.js
  6. 87
    0
      react/features/av-moderation/actionTypes.js
  7. 173
    0
      react/features/av-moderation/actions.js
  8. 35
    0
      react/features/av-moderation/components/AudioModerationNotifications.js
  9. 19
    0
      react/features/av-moderation/constants.js
  10. 115
    0
      react/features/av-moderation/functions.js
  11. 190
    0
      react/features/av-moderation/middleware.js
  12. 134
    0
      react/features/av-moderation/reducer.js
  13. 16
    4
      react/features/base/media/actions.js
  14. 6
    1
      react/features/base/media/constants.js
  15. 7
    0
      react/features/base/participants/actionTypes.js
  16. 16
    0
      react/features/base/participants/actions.js
  17. 24
    0
      react/features/base/participants/middleware.js
  18. 10
    0
      react/features/base/tracks/middleware.js
  19. 6
    1
      react/features/conference/components/web/Conference.js
  20. 28
    0
      react/features/lobby/actions.web.js
  21. 15
    61
      react/features/lobby/components/web/KnockingParticipantList.js
  22. 2
    2
      react/features/notifications/actionTypes.js
  23. 3
    3
      react/features/notifications/actions.js
  24. 1
    1
      react/features/notifications/components/AbstractNotification.js
  25. 52
    0
      react/features/notifications/components/web/NotificationButton.js
  26. 97
    0
      react/features/notifications/components/web/NotificationWithParticipants.js
  27. 82
    0
      react/features/notifications/components/web/ParticipantNotificationList.js
  28. 43
    0
      react/features/participants-pane/components/AskToUnmuteButton.js
  29. 101
    0
      react/features/participants-pane/components/FooterContextMenu.js
  30. 7
    7
      react/features/participants-pane/components/LobbyParticipantItem.js
  31. 34
    15
      react/features/participants-pane/components/MeetingParticipantContextMenu.js
  32. 17
    4
      react/features/participants-pane/components/MeetingParticipantItem.js
  33. 10
    2
      react/features/participants-pane/components/MeetingParticipantList.js
  34. 28
    23
      react/features/participants-pane/components/ParticipantItem.js
  35. 59
    0
      react/features/participants-pane/components/ParticipantQuickAction.js
  36. 38
    8
      react/features/participants-pane/components/ParticipantsPane.js
  37. 14
    3
      react/features/participants-pane/components/styled.js
  38. 34
    8
      react/features/participants-pane/constants.js
  39. 86
    8
      react/features/participants-pane/functions.js
  40. 4
    1
      react/features/participants-pane/theme.json
  41. 2
    12
      react/features/toolbox/components/native/RaiseHandButton.js
  42. 3
    12
      react/features/toolbox/components/web/Toolbox.js
  43. 11
    1
      react/features/video-menu/actions.any.js
  44. 6
    4
      resources/prosody-plugins/mod_av_moderation_component.lua

+ 5
- 7
css/_lobby.scss View File

38
     }
38
     }
39
 }
39
 }
40
 
40
 
41
-#knocking-participant-list {
41
+#notification-participant-list {
42
     background-color: $newToolbarBackgroundColor;
42
     background-color: $newToolbarBackgroundColor;
43
     border: 1px solid rgba(255, 255, 255, .4);
43
     border: 1px solid rgba(255, 255, 255, .4);
44
     border-radius: 8px;
44
     border-radius: 8px;
45
-    display: flex;
46
-    flex-direction: column;
47
     left: 0;
45
     left: 0;
48
     margin: 20px;
46
     margin: 20px;
47
+    max-height: 600px;
48
+    overflow: hidden;
49
+    overflow-y: auto;
49
     position: fixed;
50
     position: fixed;
50
-    top: 20;
51
-    transition: top 1s ease;
51
+    top: 30px;
52
     z-index: $toolbarZ + 1;
52
     z-index: $toolbarZ + 1;
53
 
53
 
54
     &.toolbox-visible {
54
     &.toolbox-visible {
94
 
94
 
95
 .knocking-participants-container {
95
 .knocking-participants-container {
96
     list-style-type: none;
96
     list-style-type: none;
97
-    max-height: 600px;
98
-    overflow-y: scroll;
99
     padding: 0 15px 15px 15px;
97
     padding: 0 15px 15px 15px;
100
 }
98
 }
101
 
99
 

+ 17
- 0
lang/main.json View File

521
         "focus": "Conference focus",
521
         "focus": "Conference focus",
522
         "focusFail": "{{component}} not available - retry in {{ms}} sec",
522
         "focusFail": "{{component}} not available - retry in {{ms}} sec",
523
         "grantedTo": "Moderator rights granted to {{to}}!",
523
         "grantedTo": "Moderator rights granted to {{to}}!",
524
+        "hostAskedUnmute": "The host would like you to unmute",
524
         "invitedOneMember": "{{name}} has been invited",
525
         "invitedOneMember": "{{name}} has been invited",
525
         "invitedThreePlusMembers": "{{name}} and {{count}} others have been invited",
526
         "invitedThreePlusMembers": "{{name}} and {{count}} others have been invited",
526
         "invitedTwoMembers": "{{first}} and {{second}} have been invited",
527
         "invitedTwoMembers": "{{first}} and {{second}} have been invited",
551
         "oldElectronClientDescription1": "You appear to be using an old version of the Jitsi Meet client which has known security vulnerabilities. Please make sure you update to our ",
552
         "oldElectronClientDescription1": "You appear to be using an old version of the Jitsi Meet client which has known security vulnerabilities. Please make sure you update to our ",
552
         "oldElectronClientDescription2": "latest build",
553
         "oldElectronClientDescription2": "latest build",
553
         "oldElectronClientDescription3": " now!",
554
         "oldElectronClientDescription3": " now!",
555
+        "moderationInEffectDescription": "Please raise hand if you want to speak",
556
+        "moderationInEffectCSDescription": "Please raise hand if you want to share your video",
557
+        "moderationInEffectVideoDescription": "Please raise your hand if you want your video to be visible",
558
+        "moderationInEffectTitle": "The microphone is muted by the moderator",
559
+        "moderationInEffectCSTitle": "Content sharing is disabled by moderator",
560
+        "moderationInEffectVideoTitle": "The video is muted by the moderator",
561
+        "moderationRequestFromModerator": "The host would like you to unmute",
562
+        "moderationRequestFromParticipant": "Wants to speak",
563
+        "moderationStartedTitle": "Moderation started",
564
+        "moderationStoppedTitle": "Moderation stopped",
565
+        "moderationToggleDescription": "by {{participantDisplayName}}",
566
+        "raiseHandAction": "Raise hand",
554
         "groupTitle": "Notifications"
567
         "groupTitle": "Notifications"
555
     },
568
     },
556
     "participantsPane": {
569
     "participantsPane": {
560
             "participantsList": "Meeting participants ({{count}})"
573
             "participantsList": "Meeting participants ({{count}})"
561
         },
574
         },
562
         "actions": {
575
         "actions": {
576
+            "allow": "Allow attendees to:",
563
             "invite": "Invite Someone",
577
             "invite": "Invite Someone",
578
+            "askUnmute": "Ask to unmute",
564
             "muteAll": "Mute all",
579
             "muteAll": "Mute all",
580
+            "startModeration": "Unmute themselves or start video",
581
+            "stopEveryonesVideo": "Stop everyone's video",
565
             "stopVideo": "Stop video"
582
             "stopVideo": "Stop video"
566
         }
583
         }
567
     },
584
     },

+ 3
- 9
modules/API/API.js View File

20
 import {
20
 import {
21
     getLocalParticipant,
21
     getLocalParticipant,
22
     getParticipantById,
22
     getParticipantById,
23
-    participantUpdated,
24
     pinParticipant,
23
     pinParticipant,
25
-    kickParticipant
24
+    kickParticipant,
25
+    raiseHand
26
 } from '../../react/features/base/participants';
26
 } from '../../react/features/base/participants';
27
 import { updateSettings } from '../../react/features/base/settings';
27
 import { updateSettings } from '../../react/features/base/settings';
28
 import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks';
28
 import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks';
205
             const { raisedHand } = localParticipant;
205
             const { raisedHand } = localParticipant;
206
 
206
 
207
             sendAnalytics(createApiEvent('raise-hand.toggled'));
207
             sendAnalytics(createApiEvent('raise-hand.toggled'));
208
-            APP.store.dispatch(
209
-                participantUpdated({
210
-                    id: APP.conference.getMyUserId(),
211
-                    local: true,
212
-                    raisedHand: !raisedHand
213
-                })
214
-            );
208
+            APP.store.dispatch(raiseHand(!raisedHand));
215
         },
209
         },
216
 
210
 
217
         /**
211
         /**

+ 1
- 0
react/features/app/middlewares.web.js View File

1
 // @flow
1
 // @flow
2
 
2
 
3
 import '../authentication/middleware';
3
 import '../authentication/middleware';
4
+import '../av-moderation/middleware';
4
 import '../base/devices/middleware';
5
 import '../base/devices/middleware';
5
 import '../e2ee/middleware';
6
 import '../e2ee/middleware';
6
 import '../external-api/middleware';
7
 import '../external-api/middleware';

+ 1
- 0
react/features/app/reducers.web.js View File

1
 // @flow
1
 // @flow
2
 
2
 
3
+import '../av-moderation/reducer';
3
 import '../base/devices/reducer';
4
 import '../base/devices/reducer';
4
 import '../e2ee/reducer';
5
 import '../e2ee/reducer';
5
 import '../feedback/reducer';
6
 import '../feedback/reducer';

+ 87
- 0
react/features/av-moderation/actionTypes.js View File

1
+/**
2
+ * The type of (redux) action which signals that A/V Moderation had been disabled.
3
+ *
4
+ * {
5
+ *     type: DISABLE_MODERATION
6
+ * }
7
+ */
8
+export const DISABLE_MODERATION = 'DISABLE_MODERATION';
9
+
10
+/**
11
+ * The type of (redux) action which signals that the notification for audio/video unmute should
12
+ * be dismissed.
13
+ *
14
+ * {
15
+ *     type: DISMISS_PARTICIPANT_PENDING_AUDIO
16
+ * }
17
+ */
18
+export const DISMISS_PENDING_PARTICIPANT = 'DISMISS_PENDING_PARTICIPANT';
19
+
20
+
21
+/**
22
+ * The type of (redux) action which signals that A/V Moderation had been enabled.
23
+ *
24
+ * {
25
+ *     type: ENABLE_MODERATION
26
+ * }
27
+ */
28
+export const ENABLE_MODERATION = 'ENABLE_MODERATION';
29
+
30
+
31
+/**
32
+ * The type of (redux) action which signals that A/V Moderation disable has been requested.
33
+ *
34
+ * {
35
+ *     type: REQUEST_DISABLE_MODERATION
36
+ * }
37
+ */
38
+export const REQUEST_DISABLE_MODERATION = 'REQUEST_DISABLE_MODERATION';
39
+
40
+/**
41
+ * The type of (redux) action which signals that A/V Moderation enable has been requested.
42
+ *
43
+ * {
44
+ *     type: REQUEST_ENABLE_MODERATION
45
+ * }
46
+ */
47
+export const REQUEST_ENABLE_MODERATION = 'REQUEST_ENABLE_MODERATION';
48
+
49
+/**
50
+ * The type of (redux) action which signals that the local participant had been approved.
51
+ *
52
+ * {
53
+ *     type: LOCAL_PARTICIPANT_APPROVED,
54
+ *     mediaType: MediaType
55
+ * }
56
+ */
57
+export const LOCAL_PARTICIPANT_APPROVED = 'LOCAL_PARTICIPANT_APPROVED';
58
+
59
+/**
60
+ * The type of (redux) action which signals to show notification to the local participant.
61
+ *
62
+ * {
63
+ *     type: LOCAL_PARTICIPANT_MODERATION_NOTIFICATION
64
+ * }
65
+ */
66
+export const LOCAL_PARTICIPANT_MODERATION_NOTIFICATION = 'LOCAL_PARTICIPANT_MODERATION_NOTIFICATION';
67
+
68
+/**
69
+ * The type of (redux) action which signals that a participant was approved for a media type.
70
+ *
71
+ * {
72
+ *     type: PARTICIPANT_APPROVED,
73
+ *     mediaType: MediaType
74
+ *     participantId: String
75
+ * }
76
+ */
77
+export const PARTICIPANT_APPROVED = 'PARTICIPANT_APPROVED';
78
+
79
+
80
+/**
81
+ * The type of (redux) action which signals that a participant asked to have its audio umuted.
82
+ *
83
+ * {
84
+ *     type: PARTICIPANT_PENDING_AUDIO
85
+ * }
86
+ */
87
+export const PARTICIPANT_PENDING_AUDIO = 'PARTICIPANT_PENDING_AUDIO';

+ 173
- 0
react/features/av-moderation/actions.js View File

1
+// @flow
2
+
3
+import { getConferenceState } from '../base/conference';
4
+import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
5
+
6
+import {
7
+    DISMISS_PENDING_PARTICIPANT,
8
+    DISABLE_MODERATION,
9
+    ENABLE_MODERATION,
10
+    LOCAL_PARTICIPANT_APPROVED,
11
+    LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
12
+    PARTICIPANT_APPROVED,
13
+    PARTICIPANT_PENDING_AUDIO,
14
+    REQUEST_DISABLE_MODERATION,
15
+    REQUEST_ENABLE_MODERATION
16
+} from './actionTypes';
17
+
18
+/**
19
+ * Action used by moderator to approve audio and video for a participant.
20
+ *
21
+ * @param {staring} id - The id of the participant to be approved.
22
+ * @returns {void}
23
+ */
24
+export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => {
25
+    const { conference } = getConferenceState(getState());
26
+
27
+    conference.avModerationApprove(MEDIA_TYPE.AUDIO, id);
28
+    conference.avModerationApprove(MEDIA_TYPE.VIDEO, id);
29
+};
30
+
31
+/**
32
+ * Audio or video moderation is disabled.
33
+ *
34
+ * @param {MediaType} mediaType - The media type that was disabled.
35
+ * @param {JitsiParticipant} actor - The actor disabling.
36
+ * @returns {{
37
+ *     type: REQUEST_DISABLE_MODERATED_AUDIO
38
+ * }}
39
+ */
40
+export const disableModeration = (mediaType: MediaType, actor: Object) => {
41
+    return {
42
+        type: DISABLE_MODERATION,
43
+        mediaType,
44
+        actor
45
+    };
46
+};
47
+
48
+
49
+/**
50
+ * Hides the notification with the participant that asked to unmute audio.
51
+ *
52
+ * @param {string} id - The participant id.
53
+ * @returns {Object}
54
+ */
55
+export function dismissPendingAudioParticipant(id: string) {
56
+    return dismissPendingParticipant(id, MEDIA_TYPE.AUDIO);
57
+}
58
+
59
+/**
60
+ * Hides the notification with the participant that asked to unmute.
61
+ *
62
+ * @param {string} id - The participant id.
63
+ * @param {MediaType} mediaType - The media type.
64
+ * @returns {Object}
65
+ */
66
+export function dismissPendingParticipant(id: string, mediaType: MediaType) {
67
+    return {
68
+        type: DISMISS_PENDING_PARTICIPANT,
69
+        id,
70
+        mediaType
71
+    };
72
+}
73
+
74
+/**
75
+ * Audio or video moderation is enabled.
76
+ *
77
+ * @param {MediaType} mediaType - The media type that was enabled.
78
+ * @param {JitsiParticipant} actor - The actor enabling.
79
+ * @returns {{
80
+ *     type: REQUEST_ENABLE_MODERATED_AUDIO
81
+ * }}
82
+ */
83
+export const enableModeration = (mediaType: MediaType, actor: Object) => {
84
+    return {
85
+        type: ENABLE_MODERATION,
86
+        mediaType,
87
+        actor
88
+    };
89
+};
90
+
91
+/**
92
+ * Requests disable of audio and video moderation.
93
+ *
94
+ * @returns {{
95
+ *     type: REQUEST_DISABLE_MODERATED_AUDIO
96
+ * }}
97
+ */
98
+export const requestDisableModeration = () => {
99
+    return {
100
+        type: REQUEST_DISABLE_MODERATION
101
+    };
102
+};
103
+
104
+/**
105
+ * Requests enabled audio & video moderation.
106
+ *
107
+ * @returns {{
108
+ *     type: REQUEST_ENABLE_MODERATED_AUDIO
109
+ * }}
110
+ */
111
+export const requestEnableModeration = () => {
112
+    return {
113
+        type: REQUEST_ENABLE_MODERATION
114
+    };
115
+};
116
+
117
+/**
118
+ * Local participant was approved to be able to unmute audio and video.
119
+ *
120
+ * @param {MediaType} mediaType - The media type to disable.
121
+ * @returns {{
122
+ *     type: LOCAL_PARTICIPANT_APPROVED
123
+ * }}
124
+ */
125
+export const localParticipantApproved = (mediaType: MediaType) => {
126
+    return {
127
+        type: LOCAL_PARTICIPANT_APPROVED,
128
+        mediaType
129
+    };
130
+};
131
+
132
+/**
133
+ * Shows notification when A/V moderation is enabled and local participant is still not approved.
134
+ *
135
+ * @param {MediaType} mediaType - Audio or video media type.
136
+ * @returns {Object}
137
+ */
138
+export function showModeratedNotification(mediaType: MediaType) {
139
+    return {
140
+        type: LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
141
+        mediaType
142
+    };
143
+}
144
+
145
+/**
146
+ * Shows a notification with the participant that asked to audio unmute.
147
+ *
148
+ * @param {string} id - The participant id.
149
+ * @returns {Object}
150
+ */
151
+export function participantPendingAudio(id: string) {
152
+    return {
153
+        type: PARTICIPANT_PENDING_AUDIO,
154
+        id
155
+    };
156
+}
157
+
158
+/**
159
+ * A participant was approved to unmute for a mediaType.
160
+ *
161
+ * @param {string} id - The id of the approved participant.
162
+ * @param {MediaType} mediaType - The media type which was approved.
163
+ * @returns {{
164
+ *     type: PARTICIPANT_APPROVED,
165
+ * }}
166
+ */
167
+export function participantApproved(id: string, mediaType: MediaType) {
168
+    return {
169
+        type: PARTICIPANT_APPROVED,
170
+        id,
171
+        mediaType
172
+    };
173
+}

+ 35
- 0
react/features/av-moderation/components/AudioModerationNotifications.js View File

1
+import React from 'react';
2
+import { useTranslation } from 'react-i18next';
3
+import { useSelector } from 'react-redux';
4
+
5
+import NotificationWithParticipants from '../../notifications/components/web/NotificationWithParticipants';
6
+import { approveAudio, dismissPendingAudioParticipant } from '../actions';
7
+import { getParticipantsAskingToAudioUnmute } from '../functions';
8
+
9
+
10
+/**
11
+ * Component used to display a list of participants who asked to be unmuted.
12
+ * This is visible only to moderators.
13
+ *
14
+ * @returns {React$Element<'ul'> | null}
15
+ */
16
+export default function() {
17
+    const participants = useSelector(getParticipantsAskingToAudioUnmute);
18
+    const { t } = useTranslation();
19
+
20
+    return participants.length
21
+        ? (
22
+            <>
23
+                <div className = 'title'>
24
+                    { t('raisedHand') }
25
+                </div>
26
+                <NotificationWithParticipants
27
+                    approveButtonText = { t('notify.unmute') }
28
+                    onApprove = { approveAudio }
29
+                    onReject = { dismissPendingAudioParticipant }
30
+                    participants = { participants }
31
+                    rejectButtonText = { t('dialog.dismiss') }
32
+                    testIdPrefix = 'avModeration' />
33
+            </>
34
+        ) : null;
35
+}

+ 19
- 0
react/features/av-moderation/constants.js View File

1
+// @flow
2
+
3
+import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
4
+
5
+/**
6
+ * Mapping between a media type and the witelist reducer key.
7
+ */
8
+export const MEDIA_TYPE_TO_WHITELIST_STORE_KEY: {[key: MediaType]: string} = {
9
+    [MEDIA_TYPE.AUDIO]: 'audioWhitelist',
10
+    [MEDIA_TYPE.VIDEO]: 'videoWhitelist'
11
+};
12
+
13
+/**
14
+ * Mapping between a media type and the pending reducer key.
15
+ */
16
+export const MEDIA_TYPE_TO_PENDING_STORE_KEY: {[key: MediaType]: string} = {
17
+    [MEDIA_TYPE.AUDIO]: 'pendingAudio',
18
+    [MEDIA_TYPE.VIDEO]: 'pendingVideo'
19
+};

+ 115
- 0
react/features/av-moderation/functions.js View File

1
+// @flow
2
+
3
+import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
4
+import { getParticipantById, isLocalParticipantModerator } from '../base/participants/functions';
5
+
6
+import { MEDIA_TYPE_TO_WHITELIST_STORE_KEY, MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants';
7
+
8
+/**
9
+ * Returns this feature's root state.
10
+ *
11
+ * @param {Object} state - Global state.
12
+ * @returns {Object} Feature state.
13
+ */
14
+const getState = state => state['features/av-moderation'];
15
+
16
+/**
17
+ * Returns whether moderation is enabled per media type.
18
+ *
19
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
20
+ * @param {Object} state - Global state.
21
+ * @returns {null|boolean|*}
22
+ */
23
+export const isEnabledFromState = (mediaType: MediaType, state: Object) =>
24
+    (mediaType === MEDIA_TYPE.AUDIO
25
+        ? getState(state).audioModerationEnabled
26
+        : getState(state).videoModerationEnabled) === true;
27
+
28
+/**
29
+ * Returns whether moderation is enabled per media type.
30
+ *
31
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
32
+ * @returns {null|boolean|*}
33
+ */
34
+export const isEnabled = (mediaType: MediaType) => (state: Object) => isEnabledFromState(mediaType, state);
35
+
36
+/**
37
+ * Returns whether local participant is approved to unmute a media type.
38
+ *
39
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
40
+ * @param {Object} state - Global state.
41
+ * @returns {boolean}
42
+ */
43
+export const isLocalParticipantApprovedFromState = (mediaType: MediaType, state: Object) => {
44
+    const approved = (mediaType === MEDIA_TYPE.AUDIO
45
+        ? getState(state).audioUnmuteApproved
46
+        : getState(state).videoUnmuteApproved) === true;
47
+
48
+    return approved || isLocalParticipantModerator(state);
49
+};
50
+
51
+/**
52
+ * Returns whether local participant is approved to unmute a media type.
53
+ *
54
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
55
+ * @returns {null|boolean|*}
56
+ */
57
+export const isLocalParticipantApproved = (mediaType: MediaType) =>
58
+    (state: Object) =>
59
+        isLocalParticipantApprovedFromState(mediaType, state);
60
+
61
+/**
62
+ * Returns a selector creator which determines if the participant is approved or not for a media type.
63
+ *
64
+ * @param {string} id - The participant id.
65
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
66
+ * @returns {boolean}
67
+ */
68
+export const isParticipantApproved = (id: string, mediaType: MediaType) => (state: Object) => {
69
+    const storeKey = MEDIA_TYPE_TO_WHITELIST_STORE_KEY[mediaType];
70
+
71
+    return Boolean(getState(state)[storeKey][id]);
72
+};
73
+
74
+/**
75
+ * Returns a selector creator which determines if the participant is pending or not for a media type.
76
+ *
77
+ * @param {string} id - The participant id.
78
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
79
+ * @returns {boolean}
80
+ */
81
+export const isParticipantPending = (id: string, mediaType: MediaType) => (state: Object) => {
82
+    const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
83
+    const arr = getState(state)[storeKey];
84
+
85
+    return Boolean(arr.find(pending => pending === id));
86
+};
87
+
88
+/**
89
+ * Selector which returns a list with all the participants asking to audio unmute.
90
+ * This is visible ony for the moderator.
91
+ *
92
+ * @param {Object} state - The global state.
93
+ * @returns {Array<Object>}
94
+ */
95
+export const getParticipantsAskingToAudioUnmute = (state: Object) => {
96
+    if (isLocalParticipantModerator(state)) {
97
+        const ids = getState(state).pendingAudio;
98
+
99
+        return ids.map(id => getParticipantById(state, id)).filter(Boolean);
100
+    }
101
+
102
+    return [];
103
+};
104
+
105
+/**
106
+ * Returns true if a special notification can be displayed when a participant
107
+ * tries to unmute.
108
+ *
109
+ * @param {MediaType} mediaType - 'audio' or 'video' media type.
110
+ * @param {Object} state - The global state.
111
+ * @returns {boolean}
112
+ */
113
+export const shouldShowModeratedNotification = (mediaType: MediaType, state: Object) =>
114
+    isEnabledFromState(mediaType, state)
115
+    && !isLocalParticipantApprovedFromState(mediaType, state);

+ 190
- 0
react/features/av-moderation/middleware.js View File

1
+// @flow
2
+import { batch } from 'react-redux';
3
+
4
+import { getConferenceState } from '../base/conference';
5
+import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
6
+import { MEDIA_TYPE } from '../base/media';
7
+import {
8
+    getParticipantDisplayName,
9
+    isLocalParticipantModerator,
10
+    PARTICIPANT_UPDATED,
11
+    raiseHand
12
+} from '../base/participants';
13
+import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
14
+import {
15
+    hideNotification,
16
+    NOTIFICATION_TIMEOUT,
17
+    showNotification
18
+} from '../notifications';
19
+
20
+import {
21
+    DISABLE_MODERATION,
22
+    ENABLE_MODERATION,
23
+    LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
24
+    REQUEST_DISABLE_MODERATION,
25
+    REQUEST_ENABLE_MODERATION
26
+} from './actionTypes';
27
+import {
28
+    disableModeration,
29
+    dismissPendingParticipant,
30
+    dismissPendingAudioParticipant,
31
+    enableModeration,
32
+    localParticipantApproved,
33
+    participantApproved,
34
+    participantPendingAudio
35
+} from './actions';
36
+import {
37
+    isEnabledFromState,
38
+    isParticipantApproved,
39
+    isParticipantPending
40
+} from './functions';
41
+
42
+const VIDEO_MODERATION_NOTIFICATION_ID = 'video-moderation';
43
+const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation';
44
+const CS_MODERATION_NOTIFICATION_ID = 'video-moderation';
45
+
46
+MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
47
+    const { actor, mediaType, type } = action;
48
+
49
+    switch (type) {
50
+    case DISABLE_MODERATION:
51
+    case ENABLE_MODERATION: {
52
+        // Audio & video moderation are both enabled at the same time.
53
+        // Avoid displaying 2 different notifications.
54
+        if (mediaType === MEDIA_TYPE.VIDEO) {
55
+            const titleKey = type === ENABLE_MODERATION
56
+                ? 'notify.moderationStartedTitle'
57
+                : 'notify.moderationStoppedTitle';
58
+
59
+            dispatch(showNotification({
60
+                descriptionKey: actor ? 'notify.moderationToggleDescription' : undefined,
61
+                descriptionArguments: actor ? {
62
+                    participantDisplayName: getParticipantDisplayName(getState, actor.getId())
63
+                } : undefined,
64
+                titleKey
65
+            }, NOTIFICATION_TIMEOUT));
66
+        }
67
+
68
+        break;
69
+    }
70
+    case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: {
71
+        let descriptionKey;
72
+        let titleKey;
73
+        let uid;
74
+
75
+        switch (action.mediaType) {
76
+        case MEDIA_TYPE.AUDIO: {
77
+            titleKey = 'notify.moderationInEffectTitle';
78
+            descriptionKey = 'notify.moderationInEffectDescription';
79
+            uid = AUDIO_MODERATION_NOTIFICATION_ID;
80
+            break;
81
+        }
82
+        case MEDIA_TYPE.VIDEO: {
83
+            titleKey = 'notify.moderationInEffectVideoTitle';
84
+            descriptionKey = 'notify.moderationInEffectVideoDescription';
85
+            uid = VIDEO_MODERATION_NOTIFICATION_ID;
86
+            break;
87
+        }
88
+        case MEDIA_TYPE.PRESENTER: {
89
+            titleKey = 'notify.moderationInEffectCSTitle';
90
+            descriptionKey = 'notify.moderationInEffectCSDescription';
91
+            uid = CS_MODERATION_NOTIFICATION_ID;
92
+            break;
93
+        }
94
+        }
95
+
96
+        dispatch(showNotification({
97
+            customActionNameKey: 'notify.raiseHandAction',
98
+            customActionHandler: () => batch(() => {
99
+                dispatch(raiseHand(true));
100
+                dispatch(hideNotification(uid));
101
+            }),
102
+            descriptionKey,
103
+            sticky: true,
104
+            titleKey,
105
+            uid
106
+        }));
107
+
108
+        break;
109
+    }
110
+    case REQUEST_DISABLE_MODERATION: {
111
+        const { conference } = getConferenceState(getState());
112
+
113
+        conference.disableAVModeration(MEDIA_TYPE.AUDIO);
114
+        conference.disableAVModeration(MEDIA_TYPE.VIDEO);
115
+        break;
116
+    }
117
+    case REQUEST_ENABLE_MODERATION: {
118
+        const { conference } = getConferenceState(getState());
119
+
120
+        conference.enableAVModeration(MEDIA_TYPE.AUDIO);
121
+        conference.enableAVModeration(MEDIA_TYPE.VIDEO);
122
+        break;
123
+    }
124
+    case PARTICIPANT_UPDATED: {
125
+        const state = getState();
126
+        const audioModerationEnabled = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
127
+
128
+        // this is handled only by moderators
129
+        if (audioModerationEnabled && isLocalParticipantModerator(state)) {
130
+            const { participant: { id, raisedHand } } = action;
131
+
132
+            if (raisedHand) {
133
+                // if participant raises hand show notification
134
+                !isParticipantApproved(id, MEDIA_TYPE.AUDIO)(state) && dispatch(participantPendingAudio(id));
135
+            } else {
136
+                // if participant lowers hand hide notification
137
+                isParticipantPending(id, MEDIA_TYPE.AUDIO)(state) && dispatch(dismissPendingAudioParticipant(id));
138
+            }
139
+        }
140
+
141
+        break;
142
+    }
143
+    }
144
+
145
+    return next(action);
146
+});
147
+
148
+/**
149
+ * Registers a change handler for state['features/base/conference'].conference to
150
+ * set the event listeners needed for the A/V moderation feature to operate.
151
+ */
152
+StateListenerRegistry.register(
153
+    state => state['features/base/conference'].conference,
154
+    (conference, { dispatch }, previousConference) => {
155
+        if (conference && !previousConference) {
156
+            // local participant is allowed to unmute
157
+            conference.on(JitsiConferenceEvents.AV_MODERATION_APPROVED, ({ mediaType }) => {
158
+                dispatch(localParticipantApproved(mediaType));
159
+
160
+                // Audio & video moderation are both enabled at the same time.
161
+                // Avoid displaying 2 different notifications.
162
+                if (mediaType === MEDIA_TYPE.VIDEO) {
163
+                    dispatch(showNotification({
164
+                        titleKey: 'notify.unmute',
165
+                        descriptionKey: 'notify.hostAskedUnmute',
166
+                        sticky: true
167
+                    }));
168
+                }
169
+            });
170
+
171
+            conference.on(JitsiConferenceEvents.AV_MODERATION_CHANGED, ({ enabled, mediaType, actor }) => {
172
+                enabled ? dispatch(enableModeration(mediaType, actor)) : dispatch(disableModeration(mediaType, actor));
173
+            });
174
+
175
+            // this is received by moderators
176
+            conference.on(
177
+                JitsiConferenceEvents.AV_MODERATION_PARTICIPANT_APPROVED,
178
+                ({ participant, mediaType }) => {
179
+                    const { _id: id } = participant;
180
+
181
+                    batch(() => {
182
+                        // store in the whitelist
183
+                        dispatch(participantApproved(id, mediaType));
184
+
185
+                        // remove from pending list
186
+                        dispatch(dismissPendingParticipant(id, mediaType));
187
+                    });
188
+                });
189
+        }
190
+    });

+ 134
- 0
react/features/av-moderation/reducer.js View File

1
+/* @flow */
2
+
3
+import { MEDIA_TYPE } from '../base/media/constants';
4
+import { ReducerRegistry } from '../base/redux';
5
+
6
+import {
7
+    DISABLE_MODERATION,
8
+    DISMISS_PENDING_PARTICIPANT,
9
+    ENABLE_MODERATION,
10
+    LOCAL_PARTICIPANT_APPROVED,
11
+    PARTICIPANT_APPROVED,
12
+    PARTICIPANT_PENDING_AUDIO
13
+} from './actionTypes';
14
+
15
+const initialState = {
16
+    audioModerationEnabled: false,
17
+    videoModerationEnabled: false,
18
+    audioWhitelist: {},
19
+    videoWhitelist: {},
20
+    pendingAudio: [],
21
+    pendingVideo: []
22
+};
23
+
24
+ReducerRegistry.register('features/av-moderation', (state = initialState, action) => {
25
+
26
+    switch (action.type) {
27
+    case DISABLE_MODERATION: {
28
+        const newState = action.mediaType === MEDIA_TYPE.AUDIO
29
+            ? {
30
+                audioModerationEnabled: false,
31
+                audioUnmuteApproved: undefined
32
+            } : {
33
+                videoModerationEnabled: false,
34
+                videoUnmuteApproved: undefined
35
+            };
36
+
37
+        return {
38
+            ...state,
39
+            ...newState,
40
+            audioWhitelist: {},
41
+            videoWhitelist: {},
42
+            pendingAudio: [],
43
+            pendingVideo: []
44
+        };
45
+    }
46
+
47
+    case ENABLE_MODERATION: {
48
+        const newState = action.mediaType === MEDIA_TYPE.AUDIO
49
+            ? { audioModerationEnabled: true } : { videoModerationEnabled: true };
50
+
51
+        return {
52
+            ...state,
53
+            ...newState
54
+        };
55
+    }
56
+
57
+    case LOCAL_PARTICIPANT_APPROVED: {
58
+        const newState = action.mediaType === MEDIA_TYPE.AUDIO
59
+            ? { audioUnmuteApproved: true } : { videoUnmuteApproved: true };
60
+
61
+        return {
62
+            ...state,
63
+            ...newState
64
+        };
65
+    }
66
+
67
+    case PARTICIPANT_PENDING_AUDIO: {
68
+        const { id } = action;
69
+
70
+        // Add participant to pendigAudio array only if it's not already added
71
+        if (!state.pendingAudio.find(pending => pending === id)) {
72
+            const updated = [ ...state.pendingAudio ];
73
+
74
+            updated.push(id);
75
+
76
+            return {
77
+                ...state,
78
+                pendingAudio: updated
79
+            };
80
+        }
81
+
82
+        return state;
83
+    }
84
+
85
+    case DISMISS_PENDING_PARTICIPANT: {
86
+        const { id, mediaType } = action;
87
+
88
+        if (mediaType === MEDIA_TYPE.AUDIO) {
89
+            return {
90
+                ...state,
91
+                pendingAudio: state.pendingAudio.filter(pending => pending !== id)
92
+            };
93
+        }
94
+
95
+        if (mediaType === MEDIA_TYPE.VIDEO) {
96
+            return {
97
+                ...state,
98
+                pendingAudio: state.pendingVideo.filter(pending => pending !== id)
99
+            };
100
+        }
101
+
102
+        return state;
103
+    }
104
+
105
+    case PARTICIPANT_APPROVED: {
106
+        const { mediaType, id } = action;
107
+
108
+        if (mediaType === MEDIA_TYPE.AUDIO) {
109
+            return {
110
+                ...state,
111
+                audioWhitelist: {
112
+                    ...state.audioWhitelist,
113
+                    [id]: true
114
+                }
115
+            };
116
+        }
117
+
118
+        if (mediaType === MEDIA_TYPE.VIDEO) {
119
+            return {
120
+                ...state,
121
+                videoWhitelist: {
122
+                    ...state.videoWhitelist,
123
+                    [id]: true
124
+                }
125
+            };
126
+        }
127
+
128
+        return state;
129
+    }
130
+
131
+    }
132
+
133
+    return state;
134
+});

+ 16
- 4
react/features/base/media/actions.js View File

2
 
2
 
3
 import type { Dispatch } from 'redux';
3
 import type { Dispatch } from 'redux';
4
 
4
 
5
+import { showModeratedNotification } from '../../av-moderation/actions';
6
+import { shouldShowModeratedNotification } from '../../av-moderation/functions';
7
+
5
 import {
8
 import {
6
     SET_AUDIO_MUTED,
9
     SET_AUDIO_MUTED,
7
     SET_AUDIO_AVAILABLE,
10
     SET_AUDIO_AVAILABLE,
12
     TOGGLE_CAMERA_FACING_MODE
15
     TOGGLE_CAMERA_FACING_MODE
13
 } from './actionTypes';
16
 } from './actionTypes';
14
 import {
17
 import {
15
-    CAMERA_FACING_MODE,
16
     MEDIA_TYPE,
18
     MEDIA_TYPE,
19
+    type MediaType,
17
     VIDEO_MUTISM_AUTHORITY
20
     VIDEO_MUTISM_AUTHORITY
18
 } from './constants';
21
 } from './constants';
19
 
22
 
64
  *     cameraFacingMode: CAMERA_FACING_MODE
67
  *     cameraFacingMode: CAMERA_FACING_MODE
65
  * }}
68
  * }}
66
  */
69
  */
67
-export function setCameraFacingMode(cameraFacingMode: CAMERA_FACING_MODE) {
70
+export function setCameraFacingMode(cameraFacingMode: string) {
68
     return {
71
     return {
69
         type: SET_CAMERA_FACING_MODE,
72
         type: SET_CAMERA_FACING_MODE,
70
         cameraFacingMode
73
         cameraFacingMode
102
  */
105
  */
103
 export function setVideoMuted(
106
 export function setVideoMuted(
104
         muted: boolean,
107
         muted: boolean,
105
-        mediaType: MEDIA_TYPE = MEDIA_TYPE.VIDEO,
108
+        mediaType: MediaType = MEDIA_TYPE.VIDEO,
106
         authority: number = VIDEO_MUTISM_AUTHORITY.USER,
109
         authority: number = VIDEO_MUTISM_AUTHORITY.USER,
107
         ensureTrack: boolean = false) {
110
         ensureTrack: boolean = false) {
108
     return (dispatch: Dispatch<any>, getState: Function) => {
111
     return (dispatch: Dispatch<any>, getState: Function) => {
109
-        const oldValue = getState()['features/base/media'].video.muted;
112
+        const state = getState();
113
+
114
+        // check for A/V Moderation when trying to unmute
115
+        if (!muted && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, state)) {
116
+            ensureTrack && dispatch(showModeratedNotification(MEDIA_TYPE.VIDEO));
117
+
118
+            return;
119
+        }
120
+
121
+        const oldValue = state['features/base/media'].video.muted;
110
 
122
 
111
         // eslint-disable-next-line no-bitwise
123
         // eslint-disable-next-line no-bitwise
112
         const newValue = muted ? oldValue | authority : oldValue & ~authority;
124
         const newValue = muted ? oldValue | authority : oldValue & ~authority;

+ 6
- 1
react/features/base/media/constants.js View File

1
+// @flow
2
+
1
 /**
3
 /**
2
  * The set of facing modes for camera.
4
  * The set of facing modes for camera.
3
  *
5
  *
8
     USER: 'user'
10
     USER: 'user'
9
 };
11
 };
10
 
12
 
13
+export type MediaType = 'audio' | 'video' | 'presenter';
14
+
11
 /**
15
 /**
12
  * The set of media types.
16
  * The set of media types.
13
  *
17
  *
14
  * @enum {string}
18
  * @enum {string}
15
  */
19
  */
16
-export const MEDIA_TYPE = {
20
+export const MEDIA_TYPE: { AUDIO: MediaType, PRESENTER: MediaType, VIDEO: MediaType} = {
17
     AUDIO: 'audio',
21
     AUDIO: 'audio',
18
     PRESENTER: 'presenter',
22
     PRESENTER: 'presenter',
19
     VIDEO: 'video'
23
     VIDEO: 'video'
20
 };
24
 };
21
 
25
 
26
+
22
 /* eslint-disable no-bitwise */
27
 /* eslint-disable no-bitwise */
23
 
28
 
24
 /**
29
 /**

+ 7
- 0
react/features/base/participants/actionTypes.js View File

171
  */
171
  */
172
 export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL';
172
 export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL';
173
 
173
 
174
+/**
175
+ * Raises hand for the local participant.
176
+ * {
177
+ *     type: LOCAL_PARTICIPANT_RAISE_HAND
178
+ * }
179
+ */
180
+export const LOCAL_PARTICIPANT_RAISE_HAND = 'LOCAL_PARTICIPANT_RAISE_HAND';

+ 16
- 0
react/features/base/participants/actions.js View File

7
     HIDDEN_PARTICIPANT_LEFT,
7
     HIDDEN_PARTICIPANT_LEFT,
8
     GRANT_MODERATOR,
8
     GRANT_MODERATOR,
9
     KICK_PARTICIPANT,
9
     KICK_PARTICIPANT,
10
+    LOCAL_PARTICIPANT_RAISE_HAND,
10
     MUTE_REMOTE_PARTICIPANT,
11
     MUTE_REMOTE_PARTICIPANT,
11
     PARTICIPANT_ID_CHANGED,
12
     PARTICIPANT_ID_CHANGED,
12
     PARTICIPANT_JOINED,
13
     PARTICIPANT_JOINED,
555
     };
556
     };
556
 }
557
 }
557
 
558
 
559
+/**
560
+ * Raise hand for the local participant.
561
+ *
562
+ * @param {boolean} enabled - Raise or lower hand.
563
+ * @returns {{
564
+ *     type: LOCAL_PARTICIPANT_RAISE_HAND,
565
+ *     enabled: boolean
566
+ * }}
567
+ */
568
+export function raiseHand(enabled) {
569
+    return {
570
+        type: LOCAL_PARTICIPANT_RAISE_HAND,
571
+        enabled
572
+    };
573
+}

+ 24
- 0
react/features/base/participants/middleware.js View File

18
     DOMINANT_SPEAKER_CHANGED,
18
     DOMINANT_SPEAKER_CHANGED,
19
     GRANT_MODERATOR,
19
     GRANT_MODERATOR,
20
     KICK_PARTICIPANT,
20
     KICK_PARTICIPANT,
21
+    LOCAL_PARTICIPANT_RAISE_HAND,
21
     MUTE_REMOTE_PARTICIPANT,
22
     MUTE_REMOTE_PARTICIPANT,
22
     PARTICIPANT_DISPLAY_NAME_CHANGED,
23
     PARTICIPANT_DISPLAY_NAME_CHANGED,
23
     PARTICIPANT_JOINED,
24
     PARTICIPANT_JOINED,
110
         break;
111
         break;
111
     }
112
     }
112
 
113
 
114
+    case LOCAL_PARTICIPANT_RAISE_HAND: {
115
+        const { enabled } = action;
116
+        const localId = getLocalParticipant(store.getState())?.id;
117
+
118
+        store.dispatch(participantUpdated({
119
+            // XXX Only the local participant is allowed to update without
120
+            // stating the JitsiConference instance (i.e. participant property
121
+            // `conference` for a remote participant) because the local
122
+            // participant is uniquely identified by the very fact that there is
123
+            // only one local participant.
124
+
125
+            id: localId,
126
+            local: true,
127
+            raisedHand: enabled
128
+        }));
129
+
130
+        if (typeof APP !== 'undefined') {
131
+            APP.API.notifyRaiseHandUpdated(localId, enabled);
132
+        }
133
+
134
+        break;
135
+    }
136
+
113
     case MUTE_REMOTE_PARTICIPANT: {
137
     case MUTE_REMOTE_PARTICIPANT: {
114
         const { conference } = store.getState()['features/base/conference'];
138
         const { conference } = store.getState()['features/base/conference'];
115
 
139
 

+ 10
- 0
react/features/base/tracks/middleware.js View File

1
 // @flow
1
 // @flow
2
 
2
 
3
 import UIEvents from '../../../../service/UI/UIEvents';
3
 import UIEvents from '../../../../service/UI/UIEvents';
4
+import { showModeratedNotification } from '../../av-moderation/actions';
5
+import { shouldShowModeratedNotification } from '../../av-moderation/functions';
4
 import { hideNotification } from '../../notifications';
6
 import { hideNotification } from '../../notifications';
5
 import { isPrejoinPageVisible } from '../../prejoin/functions';
7
 import { isPrejoinPageVisible } from '../../prejoin/functions';
6
 import { getAvailableDevices } from '../devices/actions';
8
 import { getAvailableDevices } from '../devices/actions';
135
 
137
 
136
     case TOGGLE_SCREENSHARING:
138
     case TOGGLE_SCREENSHARING:
137
         if (typeof APP === 'object') {
139
         if (typeof APP === 'object') {
140
+
141
+            // check for A/V Moderation when trying to start screen sharing
142
+            if (action.enabled && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, store.getState())) {
143
+                store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER));
144
+
145
+                return;
146
+            }
147
+
138
             APP.UI.emitEvent(UIEvents.TOGGLE_SCREENSHARING, action.audioOnly);
148
             APP.UI.emitEvent(UIEvents.TOGGLE_SCREENSHARING, action.audioOnly);
139
         }
149
         }
140
         break;
150
         break;

+ 6
- 1
react/features/conference/components/web/Conference.js View File

4
 import React from 'react';
4
 import React from 'react';
5
 
5
 
6
 import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout';
6
 import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout';
7
+import AudioModerationNotifications from '../../../av-moderation/components/AudioModerationNotifications';
7
 import { getConferenceNameForTitle } from '../../../base/conference';
8
 import { getConferenceNameForTitle } from '../../../base/conference';
8
 import { connect, disconnect } from '../../../base/connection';
9
 import { connect, disconnect } from '../../../base/connection';
9
 import { translate } from '../../../base/i18n';
10
 import { translate } from '../../../base/i18n';
228
                     <Notice />
229
                     <Notice />
229
                     <div id = 'videospace'>
230
                     <div id = 'videospace'>
230
                         <LargeVideo />
231
                         <LargeVideo />
231
-                        {!_isParticipantsPaneVisible && <KnockingParticipantList />}
232
+                        {!_isParticipantsPaneVisible
233
+                         && <div id = 'notification-participant-list'>
234
+                             <KnockingParticipantList />
235
+                             <AudioModerationNotifications />
236
+                         </div>}
232
                         <Filmstrip />
237
                         <Filmstrip />
233
                     </div>
238
                     </div>
234
 
239
 

+ 28
- 0
react/features/lobby/actions.web.js View File

145
     };
145
     };
146
 }
146
 }
147
 
147
 
148
+/**
149
+ * Approves the request of a knocking participant to join the meeting.
150
+ *
151
+ * @param {string} id - The id of the knocking participant.
152
+ * @returns {Function}
153
+ */
154
+export function approveKnockingParticipant(id: string) {
155
+    return (dispatch: Dispatch<any>, getState: Function) => {
156
+        const conference = getCurrentConference(getState);
157
+
158
+        conference && conference.lobbyApproveAccess(id);
159
+    };
160
+}
161
+
162
+/**
163
+ * Denies the request of a knocking participant to join the meeting.
164
+ *
165
+ * @param {string} id - The id of the knocking participant.
166
+ * @returns {Function}
167
+ */
168
+export function rejectKnockingParticipant(id: string) {
169
+    return (dispatch: Dispatch<any>, getState: Function) => {
170
+        const conference = getCurrentConference(getState);
171
+
172
+        conference && conference.lobbyDenyAccess(id);
173
+    };
174
+}
175
+
148
 /**
176
 /**
149
  * Action to set the knocking state of the participant.
177
  * Action to set the knocking state of the participant.
150
  *
178
  *

+ 15
- 61
react/features/lobby/components/web/KnockingParticipantList.js View File

2
 
2
 
3
 import React from 'react';
3
 import React from 'react';
4
 
4
 
5
-import { Avatar } from '../../../base/avatar';
6
 import { translate } from '../../../base/i18n';
5
 import { translate } from '../../../base/i18n';
7
 import { connect } from '../../../base/redux';
6
 import { connect } from '../../../base/redux';
8
-import { isToolboxVisible } from '../../../toolbox/functions.web';
9
-import { HIDDEN_EMAILS } from '../../constants';
7
+import NotificationWithParticipants from '../../../notifications/components/web/NotificationWithParticipants';
8
+import { approveKnockingParticipant, rejectKnockingParticipant } from '../../actions';
10
 import AbstractKnockingParticipantList, {
9
 import AbstractKnockingParticipantList, {
11
     mapStateToProps as abstractMapStateToProps,
10
     mapStateToProps as abstractMapStateToProps,
12
     type Props as AbstractProps
11
     type Props as AbstractProps
17
     /**
16
     /**
18
      * True if the toolbox is visible, so we need to adjust the position.
17
      * True if the toolbox is visible, so we need to adjust the position.
19
      */
18
      */
20
-    _toolboxVisible: boolean,
19
+    _toolboxVisible: boolean
21
 };
20
 };
22
 
21
 
23
 /**
22
 /**
30
      * @inheritdoc
29
      * @inheritdoc
31
      */
30
      */
32
     render() {
31
     render() {
33
-        const { _participants, _toolboxVisible, _visible, t } = this.props;
32
+        const { _participants, _visible, t } = this.props;
34
 
33
 
35
         if (!_visible) {
34
         if (!_visible) {
36
             return null;
35
             return null;
37
         }
36
         }
38
 
37
 
39
         return (
38
         return (
40
-            <div
41
-                className = { _toolboxVisible ? 'toolbox-visible' : '' }
42
-                id = 'knocking-participant-list'>
43
-                <span className = 'title'>
39
+            <div id = 'knocking-participant-list'>
40
+                <div className = 'title'>
44
                     { t('lobby.knockingParticipantList') }
41
                     { t('lobby.knockingParticipantList') }
45
-                </span>
46
-                <ul className = 'knocking-participants-container'>
47
-                    { _participants.map(p => (
48
-                        <li
49
-                            className = 'knocking-participant'
50
-                            key = { p.id }>
51
-                            <Avatar
52
-                                displayName = { p.name }
53
-                                size = { 48 }
54
-                                testId = 'knockingParticipant.avatar'
55
-                                url = { p.loadableAvatarUrl } />
56
-                            <div className = 'details'>
57
-                                <span data-testid = 'knockingParticipant.name'>
58
-                                    { p.name }
59
-                                </span>
60
-                                { p.email && !HIDDEN_EMAILS.includes(p.email) && (
61
-                                    <span data-testid = 'knockingParticipant.email'>
62
-                                        { p.email }
63
-                                    </span>
64
-                                ) }
65
-                            </div>
66
-                            <button
67
-                                className = 'primary'
68
-                                data-testid = 'lobby.allow'
69
-                                onClick = { this._onRespondToParticipant(p.id, true) }
70
-                                type = 'button'>
71
-                                { t('lobby.allow') }
72
-                            </button>
73
-                            <button
74
-                                className = 'borderLess'
75
-                                data-testid = 'lobby.reject'
76
-                                onClick = { this._onRespondToParticipant(p.id, false) }
77
-                                type = 'button'>
78
-                                { t('lobby.reject') }
79
-                            </button>
80
-                        </li>
81
-                    )) }
82
-                </ul>
42
+                </div>
43
+                <NotificationWithParticipants
44
+                    approveButtonText = { t('lobby.allow') }
45
+                    onApprove = { approveKnockingParticipant }
46
+                    onReject = { rejectKnockingParticipant }
47
+                    participants = { _participants }
48
+                    rejectButtonText = { t('lobby.reject') }
49
+                    testIdPrefix = 'lobby' />
83
             </div>
50
             </div>
84
         );
51
         );
85
     }
52
     }
87
     _onRespondToParticipant: (string, boolean) => Function;
54
     _onRespondToParticipant: (string, boolean) => Function;
88
 }
55
 }
89
 
56
 
90
-/**
91
- * Maps part of the Redux state to the props of this component.
92
- *
93
- * @param {Object} state - The Redux state.
94
- * @returns {Props}
95
- */
96
-function _mapStateToProps(state: Object): $Shape<Props> {
97
-    return {
98
-        ...abstractMapStateToProps(state),
99
-        _toolboxVisible: isToolboxVisible(state)
100
-    };
101
-}
102
-
103
-export default translate(connect(_mapStateToProps)(KnockingParticipantList));
57
+export default translate(connect(abstractMapStateToProps)(KnockingParticipantList));

+ 2
- 2
react/features/notifications/actionTypes.js View File

16
  *
16
  *
17
  * {
17
  * {
18
  *     type: HIDE_NOTIFICATION,
18
  *     type: HIDE_NOTIFICATION,
19
- *     uid: number
19
+ *     uid: string
20
  * }
20
  * }
21
  */
21
  */
22
 export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION';
22
 export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION';
30
  *     component: ReactComponent,
30
  *     component: ReactComponent,
31
  *     props: Object,
31
  *     props: Object,
32
  *     timeout: number,
32
  *     timeout: number,
33
- *     uid: number
33
+ *     uid: string
34
  * }
34
  * }
35
  */
35
  */
36
 export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';
36
 export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';

+ 3
- 3
react/features/notifications/actions.js View File

33
  * removed.
33
  * removed.
34
  * @returns {{
34
  * @returns {{
35
  *     type: HIDE_NOTIFICATION,
35
  *     type: HIDE_NOTIFICATION,
36
- *     uid: number
36
+ *     uid: string
37
  * }}
37
  * }}
38
  */
38
  */
39
-export function hideNotification(uid: number) {
39
+export function hideNotification(uid: string) {
40
     return {
40
     return {
41
         type: HIDE_NOTIFICATION,
41
         type: HIDE_NOTIFICATION,
42
         uid
42
         uid
95
                 type: SHOW_NOTIFICATION,
95
                 type: SHOW_NOTIFICATION,
96
                 props,
96
                 props,
97
                 timeout,
97
                 timeout,
98
-                uid: window.Date.now()
98
+                uid: props.uid || window.Date.now().toString()
99
             });
99
             });
100
         }
100
         }
101
     };
101
     };

+ 1
- 1
react/features/notifications/components/AbstractNotification.js View File

90
     /**
90
     /**
91
      * The unique identifier for the notification.
91
      * The unique identifier for the notification.
92
      */
92
      */
93
-    uid: number
93
+    uid: string
94
 };
94
 };
95
 
95
 
96
 /**
96
 /**

+ 52
- 0
react/features/notifications/components/web/NotificationButton.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useDispatch } from 'react-redux';
5
+
6
+type Props = {
7
+
8
+    /**
9
+     * Action to be dispatched on click.
10
+     */
11
+    action: Function,
12
+
13
+    /**
14
+     * The text of the button.
15
+     */
16
+    children: React$Node,
17
+
18
+    /**
19
+     * CSS class of the button.
20
+     */
21
+    className: string,
22
+
23
+    /**
24
+     * The `data-testid` used for the button.
25
+     */
26
+    testId: string,
27
+
28
+    /**
29
+     * The participant.
30
+     */
31
+    participant: Object
32
+}
33
+
34
+/**
35
+ * Component used to display an approve/reject button.
36
+ *
37
+ * @returns {React$Element<'button'>}
38
+ */
39
+export default function({ action, children, className, testId, participant }: Props) {
40
+    const dispatch = useDispatch();
41
+    const onClick = useCallback(() => dispatch(action(participant.id)), [ dispatch, participant ]);
42
+
43
+    return (
44
+        <button
45
+            className = { className }
46
+            data-testid = { testId }
47
+            onClick = { onClick }
48
+            type = 'button'>
49
+            { children }
50
+        </button>
51
+    );
52
+}

+ 97
- 0
react/features/notifications/components/web/NotificationWithParticipants.js View File

1
+// @flow
2
+
3
+import React from 'react';
4
+
5
+import { Avatar } from '../../../base/avatar';
6
+import { HIDDEN_EMAILS } from '../../../lobby/constants';
7
+
8
+import NotificationButton from './NotificationButton';
9
+
10
+type Props = {
11
+
12
+    /**
13
+     * Text used for button which triggeres `onApprove` action.
14
+     */
15
+    approveButtonText: string,
16
+
17
+    /**
18
+     * Callback used when clicking the ok/approve button.
19
+     */
20
+    onApprove: Function,
21
+
22
+    /**
23
+     * Callback used when clicking the reject button.
24
+     */
25
+    onReject: Function,
26
+
27
+    /**
28
+     * Array of participants to be displayed.
29
+     */
30
+    participants: Array<Object>,
31
+
32
+    /**
33
+     * Text for button which triggeres the `reject` action.
34
+     */
35
+    rejectButtonText: string,
36
+
37
+
38
+    /**
39
+     * String prefix used for button `test-id`.
40
+     */
41
+     testIdPrefix: string
42
+}
43
+
44
+/**
45
+ * Component used to display a list of notifications based on a list of participants.
46
+ * This is visible only to moderators.
47
+ *
48
+ * @returns {React$Element<'div'> | null}
49
+ */
50
+export default function({
51
+    approveButtonText,
52
+    onApprove,
53
+    onReject,
54
+    participants,
55
+    testIdPrefix,
56
+    rejectButtonText
57
+}: Props): React$Element<'ul'> {
58
+    return (
59
+        <ul className = 'knocking-participants-container'>
60
+            { participants.map(p => (
61
+                <li
62
+                    className = 'knocking-participant'
63
+                    key = { p.id }>
64
+                    <Avatar
65
+                        displayName = { p.name }
66
+                        size = { 48 }
67
+                        testId = { `${testIdPrefix}.avatar` }
68
+                        url = { p.loadableAvatarUrl } />
69
+
70
+                    <div className = 'details'>
71
+                        <span data-testid = { `${testIdPrefix}.name` }>
72
+                            { p.name }
73
+                        </span>
74
+                        { p.email && !HIDDEN_EMAILS.includes(p.email) && (
75
+                            <span data-testid = { `${testIdPrefix}.email` }>
76
+                                { p.email }
77
+                            </span>
78
+                        ) }
79
+                    </div>
80
+                    { <NotificationButton
81
+                        action = { onApprove }
82
+                        className = 'primary'
83
+                        participant = { p }
84
+                        testId = { `${testIdPrefix}.allow` }>
85
+                        { approveButtonText }
86
+                    </NotificationButton> }
87
+                    { <NotificationButton
88
+                        action = { onReject }
89
+                        className = 'borderLess'
90
+                        participant = { p }
91
+                        testId = { `${testIdPrefix}.reject` }>
92
+                        { rejectButtonText }
93
+                    </NotificationButton>}
94
+                </li>
95
+            )) }
96
+        </ul>);
97
+}

+ 82
- 0
react/features/notifications/components/web/ParticipantNotificationList.js View File

1
+// @flow
2
+
3
+import React from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+
6
+import { Avatar } from '../../../base/avatar';
7
+import { HIDDEN_EMAILS } from '../../../lobby/constants';
8
+
9
+import NotificationButton from './NotificationButton';
10
+
11
+type Props = {
12
+
13
+    /**
14
+     * Callback used when clicking the ok/approve button.
15
+     */
16
+    onApprove: Function,
17
+
18
+    /**
19
+     * Callback used when clicking the reject button.
20
+     */
21
+    onReject: Function,
22
+
23
+    /**
24
+     * Array of participants to be displayed.
25
+     */
26
+    participants: Array<Object>,
27
+
28
+    /**
29
+     * String prefix used for button `test-id`.
30
+     */
31
+     testIdPrefix: string
32
+}
33
+
34
+/**
35
+ * Component used to display a list of notifications based on a list of participants.
36
+ * This is visible only to moderators.
37
+ *
38
+ * @returns {React$Element<'div'> | null}
39
+ */
40
+export default function({ onApprove, onReject, participants, testIdPrefix }: Props): React$Element<'ul'> {
41
+    const { t } = useTranslation();
42
+
43
+    return (
44
+        <ul className = 'knocking-participants-container'>
45
+            { participants.map(p => (
46
+                <li
47
+                    className = 'knocking-participant'
48
+                    key = { p.id }>
49
+                    <Avatar
50
+                        displayName = { p.name }
51
+                        size = { 48 }
52
+                        testId = { `${testIdPrefix}.avatar` }
53
+                        url = { p.loadableAvatarUrl } />
54
+
55
+                    <div className = 'details'>
56
+                        <span data-testid = { `${testIdPrefix}.name` }>
57
+                            { p.name }
58
+                        </span>
59
+                        { p.email && !HIDDEN_EMAILS.includes(p.email) && (
60
+                            <span data-testid = { `${testIdPrefix}.email` }>
61
+                                { p.email }
62
+                            </span>
63
+                        ) }
64
+                    </div>
65
+                    <NotificationButton
66
+                        action = { onApprove }
67
+                        className = 'primary'
68
+                        participant = { p }
69
+                        testId = { `${testIdPrefix}.allow` }>
70
+                        { t('lobby.allow') }
71
+                    </NotificationButton>
72
+                    <NotificationButton
73
+                        action = { onReject }
74
+                        className = 'borderLess'
75
+                        participant = { p }
76
+                        testId = { `${testIdPrefix}.reject` }>
77
+                        { t('lobby.reject') }
78
+                    </NotificationButton>
79
+                </li>
80
+            )) }
81
+        </ul>);
82
+}

+ 43
- 0
react/features/participants-pane/components/AskToUnmuteButton.js View File

1
+// @flow
2
+
3
+import React, { useCallback } from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { useDispatch } from 'react-redux';
6
+
7
+import { approveParticipant } from '../../av-moderation/actions';
8
+
9
+import { QuickActionButton } from './styled';
10
+
11
+type Props = {
12
+
13
+    /**
14
+     * Participant id.
15
+     */
16
+    id: string
17
+}
18
+
19
+/**
20
+ * Component used to display the `ask to unmute` button.
21
+ *
22
+ * @param {Object} participant - Participant reference.
23
+ * @returns {React$Element<'button'>}
24
+ */
25
+export default function({ id }: Props) {
26
+    const dispatch = useDispatch();
27
+    const { t } = useTranslation();
28
+
29
+    const askToUnmute = useCallback(() => {
30
+        dispatch(approveParticipant(id));
31
+    }, [ dispatch, id ]);
32
+
33
+    return (
34
+        <QuickActionButton
35
+            onClick = { askToUnmute }
36
+            primary = { true }
37
+            theme = {{
38
+                panePadding: 16
39
+            }}>
40
+            {t('participantsPane.actions.askUnmute')}
41
+        </QuickActionButton>
42
+    );
43
+}

+ 101
- 0
react/features/participants-pane/components/FooterContextMenu.js View File

1
+// @flow
2
+
3
+import { makeStyles } from '@material-ui/core/styles';
4
+import React, { useCallback } from 'react';
5
+import { useTranslation } from 'react-i18next';
6
+import { useDispatch, useSelector } from 'react-redux';
7
+
8
+import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions';
9
+import { isEnabled as isAvModerationEnabled } from '../../av-moderation/functions';
10
+import { openDialog } from '../../base/dialog';
11
+import { Icon, IconCheck, IconVideoOff } from '../../base/icons';
12
+import { MEDIA_TYPE } from '../../base/media';
13
+import { getLocalParticipant } from '../../base/participants';
14
+import { MuteEveryonesVideoDialog } from '../../video-menu/components';
15
+
16
+import {
17
+    ContextMenu,
18
+    ContextMenuItem
19
+} from './styled';
20
+
21
+const useStyles = makeStyles(() => {
22
+    return {
23
+        contextMenu: {
24
+            bottom: 'auto',
25
+            margin: '0',
26
+            padding: '8px 0',
27
+            right: 0,
28
+            top: '-8px',
29
+            transform: 'translateY(-100%)',
30
+            width: '238px'
31
+        },
32
+        text: {
33
+            marginLeft: '52px',
34
+            lineHeight: '40px'
35
+        },
36
+        paddedAction: {
37
+            marginLeft: '36px;'
38
+        }
39
+    };
40
+});
41
+
42
+type Props = {
43
+
44
+  /**
45
+   * Callback for the mouse leaving this item
46
+   */
47
+  onMouseLeave: Function
48
+};
49
+
50
+export const FooterContextMenu = ({ onMouseLeave }: Props) => {
51
+    const dispatch = useDispatch();
52
+    const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
53
+    const { id } = useSelector(getLocalParticipant);
54
+    const { t } = useTranslation();
55
+
56
+    const disable = useCallback(() => dispatch(requestDisableModeration()), [ dispatch ]);
57
+
58
+    const enable = useCallback(() => dispatch(requestEnableModeration()), [ dispatch ]);
59
+
60
+    const classes = useStyles();
61
+
62
+    const muteAllVideo = useCallback(
63
+        () => dispatch(openDialog(MuteEveryonesVideoDialog, { exclude: [ id ] })), [ dispatch ]);
64
+
65
+    return (
66
+        <ContextMenu
67
+            className = { classes.contextMenu }
68
+            onMouseLeave = { onMouseLeave }>
69
+            <ContextMenuItem
70
+                id = 'participants-pane-context-menu-stop-video'
71
+                onClick = { muteAllVideo }>
72
+                <Icon
73
+                    size = { 20 }
74
+                    src = { IconVideoOff } />
75
+                <span>{ t('participantsPane.actions.stopEveryonesVideo') }</span>
76
+            </ContextMenuItem>
77
+
78
+            <div className = { classes.text }>
79
+                {t('participantsPane.actions.allow')}
80
+            </div>
81
+            { isModerationEnabled ? (
82
+                <ContextMenuItem
83
+                    id = 'participants-pane-context-menu-start-moderation'
84
+                    onClick = { disable }>
85
+                    <span className = { classes.paddedAction }>
86
+                        { t('participantsPane.actions.startModeration') }
87
+                    </span>
88
+                </ContextMenuItem>
89
+            ) : (
90
+                <ContextMenuItem
91
+                    id = 'participants-pane-context-menu-stop-moderation'
92
+                    onClick = { enable }>
93
+                    <Icon
94
+                        size = { 20 }
95
+                        src = { IconCheck } />
96
+                    <span>{ t('participantsPane.actions.startModeration') }</span>
97
+                </ContextMenuItem>
98
+            )}
99
+        </ContextMenu>
100
+    );
101
+};

+ 7
- 7
react/features/participants-pane/components/LobbyParticipantItem.js View File

4
 import { useTranslation } from 'react-i18next';
4
 import { useTranslation } from 'react-i18next';
5
 import { useDispatch } from 'react-redux';
5
 import { useDispatch } from 'react-redux';
6
 
6
 
7
-import { setKnockingParticipantApproval } from '../../lobby/actions';
8
-import { ActionTrigger, MediaState } from '../constants';
7
+import { approveKnockingParticipant, rejectKnockingParticipant } from '../../lobby/actions';
8
+import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
9
 
9
 
10
 import { ParticipantItem } from './ParticipantItem';
10
 import { ParticipantItem } from './ParticipantItem';
11
 import { ParticipantActionButton } from './styled';
11
 import { ParticipantActionButton } from './styled';
20
 
20
 
21
 export const LobbyParticipantItem = ({ participant: p }: Props) => {
21
 export const LobbyParticipantItem = ({ participant: p }: Props) => {
22
     const dispatch = useDispatch();
22
     const dispatch = useDispatch();
23
-    const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true), [ dispatch ]));
24
-    const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false), [ dispatch ]));
23
+    const admit = useCallback(() => dispatch(approveKnockingParticipant(p.id), [ dispatch ]));
24
+    const reject = useCallback(() => dispatch(rejectKnockingParticipant(p.id), [ dispatch ]));
25
     const { t } = useTranslation();
25
     const { t } = useTranslation();
26
 
26
 
27
     return (
27
     return (
28
         <ParticipantItem
28
         <ParticipantItem
29
-            actionsTrigger = { ActionTrigger.Permanent }
30
-            audioMuteState = { MediaState.None }
29
+            actionsTrigger = { ACTION_TRIGGER.PERMANENT }
30
+            audioMediaState = { MEDIA_STATE.NONE }
31
             name = { p.name }
31
             name = { p.name }
32
             participant = { p }
32
             participant = { p }
33
-            videoMuteState = { MediaState.None }>
33
+            videoMuteState = { MEDIA_STATE.NONE }>
34
             <ParticipantActionButton
34
             <ParticipantActionButton
35
                 onClick = { reject }>
35
                 onClick = { reject }>
36
                 {t('lobby.reject')}
36
                 {t('lobby.reject')}

+ 34
- 15
react/features/participants-pane/components/MeetingParticipantContextMenu.js View File

10
     IconCloseCircle,
10
     IconCloseCircle,
11
     IconCrown,
11
     IconCrown,
12
     IconMessage,
12
     IconMessage,
13
+    IconMicDisabled,
13
     IconMuteEveryoneElse,
14
     IconMuteEveryoneElse,
14
     IconVideoOff
15
     IconVideoOff
15
 } from '../../base/icons';
16
 } from '../../base/icons';
16
 import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants';
17
 import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants';
17
-import { getIsParticipantVideoMuted } from '../../base/tracks';
18
+import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
18
 import { openChat } from '../../chat/actions';
19
 import { openChat } from '../../chat/actions';
19
 import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
20
 import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
20
 import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
21
 import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
30
 
31
 
31
 type Props = {
32
 type Props = {
32
 
33
 
34
+    /**
35
+     * Callback used to open a confirmation dialog for audio muting.
36
+     */
37
+    muteAudio: Function,
38
+
33
     /**
39
     /**
34
      * Target elements against which positioning calculations are made
40
      * Target elements against which positioning calculations are made
35
      */
41
      */
61
     onEnter,
67
     onEnter,
62
     onLeave,
68
     onLeave,
63
     onSelect,
69
     onSelect,
70
+    muteAudio,
64
     participant
71
     participant
65
 }: Props) => {
72
 }: Props) => {
66
     const dispatch = useDispatch();
73
     const dispatch = useDispatch();
68
     const isLocalModerator = useSelector(isLocalParticipantModerator);
75
     const isLocalModerator = useSelector(isLocalParticipantModerator);
69
     const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat'));
76
     const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat'));
70
     const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
77
     const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
78
+    const isParticipantAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
71
     const [ isHidden, setIsHidden ] = useState(true);
79
     const [ isHidden, setIsHidden ] = useState(true);
72
     const { t } = useTranslation();
80
     const { t } = useTranslation();
73
 
81
 
133
             onMouseLeave = { onLeave }>
141
             onMouseLeave = { onLeave }>
134
             <ContextMenuItemGroup>
142
             <ContextMenuItemGroup>
135
                 {isLocalModerator && (
143
                 {isLocalModerator && (
136
-                    <ContextMenuItem onClick = { muteEveryoneElse }>
137
-                        <ContextMenuIcon src = { IconMuteEveryoneElse } />
138
-                        <span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
139
-                    </ContextMenuItem>
144
+                    <>
145
+                        {!isParticipantAudioMuted
146
+                         && <ContextMenuItem onClick = { muteAudio(participant) }>
147
+                             <ContextMenuIcon src = { IconMicDisabled } />
148
+                             <span>{t('dialog.muteParticipantButton')}</span>
149
+                         </ContextMenuItem>}
150
+
151
+                        <ContextMenuItem onClick = { muteEveryoneElse }>
152
+                            <ContextMenuIcon src = { IconMuteEveryoneElse } />
153
+                            <span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
154
+                        </ContextMenuItem>
155
+                    </>
140
                 )}
156
                 )}
157
+
141
                 {isLocalModerator && (isParticipantVideoMuted || (
158
                 {isLocalModerator && (isParticipantVideoMuted || (
142
                     <ContextMenuItem onClick = { muteVideo }>
159
                     <ContextMenuItem onClick = { muteVideo }>
143
                         <ContextMenuIcon src = { IconVideoOff } />
160
                         <ContextMenuIcon src = { IconVideoOff } />
145
                     </ContextMenuItem>
162
                     </ContextMenuItem>
146
                 ))}
163
                 ))}
147
             </ContextMenuItemGroup>
164
             </ContextMenuItemGroup>
165
+
148
             <ContextMenuItemGroup>
166
             <ContextMenuItemGroup>
149
-                {isLocalModerator && !isParticipantModerator(participant) && (
150
-                    <ContextMenuItem onClick = { grantModerator }>
151
-                        <ContextMenuIcon src = { IconCrown } />
152
-                        <span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
153
-                    </ContextMenuItem>
154
-                )}
155
                 {isLocalModerator && (
167
                 {isLocalModerator && (
156
-                    <ContextMenuItem onClick = { kick }>
157
-                        <ContextMenuIcon src = { IconCloseCircle } />
158
-                        <span>{t('videothumbnail.kick')}</span>
159
-                    </ContextMenuItem>
168
+                    <>
169
+                        {!isParticipantModerator(participant)
170
+                        && <ContextMenuItem onClick = { grantModerator }>
171
+                            <ContextMenuIcon src = { IconCrown } />
172
+                            <span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
173
+                        </ContextMenuItem>}
174
+                        <ContextMenuItem onClick = { kick }>
175
+                            <ContextMenuIcon src = { IconCloseCircle } />
176
+                            <span>{t('videothumbnail.kick')}</span>
177
+                        </ContextMenuItem>
178
+                    </>
160
                 )}
179
                 )}
161
                 {isChatButtonEnabled && (
180
                 {isChatButtonEnabled && (
162
                     <ContextMenuItem onClick = { sendPrivateMessage }>
181
                     <ContextMenuItem onClick = { sendPrivateMessage }>

+ 17
- 4
react/features/participants-pane/components/MeetingParticipantItem.js View File

5
 import { useSelector } from 'react-redux';
5
 import { useSelector } from 'react-redux';
6
 
6
 
7
 import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
7
 import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
8
-import { ActionTrigger, MediaState } from '../constants';
8
+import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
9
+import { getParticipantAudioMediaState } from '../functions';
9
 
10
 
10
 import { ParticipantItem } from './ParticipantItem';
11
 import { ParticipantItem } from './ParticipantItem';
12
+import ParticipantQuickAction from './ParticipantQuickAction';
11
 import { ParticipantActionEllipsis } from './styled';
13
 import { ParticipantActionEllipsis } from './styled';
12
 
14
 
13
 type Props = {
15
 type Props = {
17
      */
19
      */
18
     isHighlighted: boolean,
20
     isHighlighted: boolean,
19
 
21
 
22
+    /**
23
+     * Callback used to open a confirmation dialog for audio muting.
24
+     */
25
+    muteAudio: Function,
26
+
20
     /**
27
     /**
21
      * Callback for the activation of this item's context menu
28
      * Callback for the activation of this item's context menu
22
      */
29
      */
37
     isHighlighted,
44
     isHighlighted,
38
     onContextMenu,
45
     onContextMenu,
39
     onLeave,
46
     onLeave,
47
+    muteAudio,
40
     participant
48
     participant
41
 }: Props) => {
49
 }: Props) => {
42
     const { t } = useTranslation();
50
     const { t } = useTranslation();
43
     const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
51
     const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
44
     const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
52
     const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
53
+    const audioMediaState = useSelector(getParticipantAudioMediaState(participant, isAudioMuted));
45
 
54
 
46
     return (
55
     return (
47
         <ParticipantItem
56
         <ParticipantItem
48
-            actionsTrigger = { ActionTrigger.Hover }
49
-            audioMuteState = { isAudioMuted ? MediaState.Muted : MediaState.Unmuted }
57
+            actionsTrigger = { ACTION_TRIGGER.HOVER }
58
+            audioMediaState = { audioMediaState }
50
             isHighlighted = { isHighlighted }
59
             isHighlighted = { isHighlighted }
51
             onLeave = { onLeave }
60
             onLeave = { onLeave }
52
             participant = { participant }
61
             participant = { participant }
53
-            videoMuteState = { isVideoMuted ? MediaState.Muted : MediaState.Unmuted }>
62
+            videoMuteState = { isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED }>
63
+            <ParticipantQuickAction
64
+                isAudioMuted = { isAudioMuted }
65
+                muteAudio = { muteAudio }
66
+                participant = { participant } />
54
             <ParticipantActionEllipsis
67
             <ParticipantActionEllipsis
55
                 aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') }
68
                 aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') }
56
                 onClick = { onContextMenu } />
69
                 onClick = { onContextMenu } />

+ 10
- 2
react/features/participants-pane/components/MeetingParticipantList.js View File

3
 import _ from 'lodash';
3
 import _ from 'lodash';
4
 import React, { useCallback, useRef, useState } from 'react';
4
 import React, { useCallback, useRef, useState } from 'react';
5
 import { useTranslation } from 'react-i18next';
5
 import { useTranslation } from 'react-i18next';
6
-import { useSelector } from 'react-redux';
6
+import { useSelector, useDispatch } from 'react-redux';
7
 
7
 
8
+import { openDialog } from '../../base/dialog';
8
 import { getParticipants } from '../../base/participants';
9
 import { getParticipants } from '../../base/participants';
10
+import MuteRemoteParticipantDialog from '../../video-menu/components/web/MuteRemoteParticipantDialog';
9
 import { findStyledAncestor, shouldRenderInviteButton } from '../functions';
11
 import { findStyledAncestor, shouldRenderInviteButton } from '../functions';
10
 
12
 
11
 import { InviteButton } from './InviteButton';
13
 import { InviteButton } from './InviteButton';
34
 const initialState = Object.freeze(Object.create(null));
36
 const initialState = Object.freeze(Object.create(null));
35
 
37
 
36
 export const MeetingParticipantList = () => {
38
 export const MeetingParticipantList = () => {
39
+    const dispatch = useDispatch();
37
     const isMouseOverMenu = useRef(false);
40
     const isMouseOverMenu = useRef(false);
38
     const participants = useSelector(getParticipants, _.isEqual);
41
     const participants = useSelector(getParticipants, _.isEqual);
39
     const showInviteButton = useSelector(shouldRenderInviteButton);
42
     const showInviteButton = useSelector(shouldRenderInviteButton);
84
         lowerMenu();
87
         lowerMenu();
85
     }, [ lowerMenu ]);
88
     }, [ lowerMenu ]);
86
 
89
 
90
+    const muteAudio = useCallback(id => () => {
91
+        dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id }));
92
+    });
93
+
87
     return (
94
     return (
88
     <>
95
     <>
89
         <Heading>{t('participantsPane.headings.participantsList', { count: participants.length })}</Heading>
96
         <Heading>{t('participantsPane.headings.participantsList', { count: participants.length })}</Heading>
93
                 <MeetingParticipantItem
100
                 <MeetingParticipantItem
94
                     isHighlighted = { raiseContext.participant === p }
101
                     isHighlighted = { raiseContext.participant === p }
95
                     key = { p.id }
102
                     key = { p.id }
103
+                    muteAudio = { muteAudio }
96
                     onContextMenu = { toggleMenu(p) }
104
                     onContextMenu = { toggleMenu(p) }
97
                     onLeave = { lowerMenu }
105
                     onLeave = { lowerMenu }
98
                     participant = { p } />
106
                     participant = { p } />
99
             ))}
107
             ))}
100
         </div>
108
         </div>
101
         <MeetingParticipantContextMenu
109
         <MeetingParticipantContextMenu
110
+            muteAudio = { muteAudio }
102
             onEnter = { menuEnter }
111
             onEnter = { menuEnter }
103
             onLeave = { menuLeave }
112
             onLeave = { menuLeave }
104
             onSelect = { lowerMenu }
113
             onSelect = { lowerMenu }
106
     </>
115
     </>
107
     );
116
     );
108
 };
117
 };
109
-

+ 28
- 23
react/features/participants-pane/components/ParticipantItem.js View File

13
     IconMicrophoneEmptySlash
13
     IconMicrophoneEmptySlash
14
 } from '../../base/icons';
14
 } from '../../base/icons';
15
 import { getParticipantDisplayNameWithId } from '../../base/participants';
15
 import { getParticipantDisplayNameWithId } from '../../base/participants';
16
-import { ActionTrigger, MediaState } from '../constants';
16
+import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../constants';
17
 
17
 
18
 import { RaisedHandIndicator } from './RaisedHandIndicator';
18
 import { RaisedHandIndicator } from './RaisedHandIndicator';
19
 import {
19
 import {
20
+    ColoredIcon,
20
     ParticipantActionsHover,
21
     ParticipantActionsHover,
21
     ParticipantActionsPermanent,
22
     ParticipantActionsPermanent,
22
     ParticipantContainer,
23
     ParticipantContainer,
30
  * Participant actions component mapping depending on trigger type.
31
  * Participant actions component mapping depending on trigger type.
31
  */
32
  */
32
 const Actions = {
33
 const Actions = {
33
-    [ActionTrigger.Hover]: ParticipantActionsHover,
34
-    [ActionTrigger.Permanent]: ParticipantActionsPermanent
34
+    [ACTION_TRIGGER.HOVER]: ParticipantActionsHover,
35
+    [ACTION_TRIGGER.PERMANENT]: ParticipantActionsPermanent
35
 };
36
 };
36
 
37
 
37
 /**
38
 /**
38
  * Icon mapping for possible participant audio states.
39
  * Icon mapping for possible participant audio states.
39
  */
40
  */
40
-const AudioStateIcons = {
41
-    [MediaState.ForceMuted]: (
42
-        <Icon
43
-            size = { 16 }
44
-            src = { IconMicrophoneEmptySlash } />
41
+const AudioStateIcons: {[MediaState]: React$Element<any> | null} = {
42
+    [MEDIA_STATE.FORCE_MUTED]: (
43
+        <ColoredIcon color = '#E04757'>
44
+            <Icon
45
+                size = { 16 }
46
+                src = { IconMicrophoneEmptySlash } />
47
+        </ColoredIcon>
45
     ),
48
     ),
46
-    [MediaState.Muted]: (
49
+    [MEDIA_STATE.MUTED]: (
47
         <Icon
50
         <Icon
48
             size = { 16 }
51
             size = { 16 }
49
             src = { IconMicrophoneEmptySlash } />
52
             src = { IconMicrophoneEmptySlash } />
50
     ),
53
     ),
51
-    [MediaState.Unmuted]: (
52
-        <Icon
53
-            size = { 16 }
54
-            src = { IconMicrophoneEmpty } />
54
+    [MEDIA_STATE.UNMUTED]: (
55
+        <ColoredIcon color = '#1EC26A'>
56
+            <Icon
57
+                size = { 16 }
58
+                src = { IconMicrophoneEmpty } />
59
+        </ColoredIcon>
55
     ),
60
     ),
56
-    [MediaState.None]: null
61
+    [MEDIA_STATE.NONE]: null
57
 };
62
 };
58
 
63
 
59
 /**
64
 /**
60
  * Icon mapping for possible participant video states.
65
  * Icon mapping for possible participant video states.
61
  */
66
  */
62
 const VideoStateIcons = {
67
 const VideoStateIcons = {
63
-    [MediaState.ForceMuted]: (
68
+    [MEDIA_STATE.FORCE_MUTED]: (
64
         <Icon
69
         <Icon
65
             size = { 16 }
70
             size = { 16 }
66
             src = { IconCameraEmptyDisabled } />
71
             src = { IconCameraEmptyDisabled } />
67
     ),
72
     ),
68
-    [MediaState.Muted]: (
73
+    [MEDIA_STATE.MUTED]: (
69
         <Icon
74
         <Icon
70
             size = { 16 }
75
             size = { 16 }
71
             src = { IconCameraEmptyDisabled } />
76
             src = { IconCameraEmptyDisabled } />
72
     ),
77
     ),
73
-    [MediaState.Unmuted]: (
78
+    [MEDIA_STATE.UNMUTED]: (
74
         <Icon
79
         <Icon
75
             size = { 16 }
80
             size = { 16 }
76
             src = { IconCameraEmpty } />
81
             src = { IconCameraEmpty } />
77
     ),
82
     ),
78
-    [MediaState.None]: null
83
+    [MEDIA_STATE.NONE]: null
79
 };
84
 };
80
 
85
 
81
 type Props = {
86
 type Props = {
88
     /**
93
     /**
89
      * Media state for audio
94
      * Media state for audio
90
      */
95
      */
91
-    audioMuteState: MediaState,
96
+    audioMediaState: MediaState,
92
 
97
 
93
     /**
98
     /**
94
      * React children
99
      * React children
125
     children,
130
     children,
126
     isHighlighted,
131
     isHighlighted,
127
     onLeave,
132
     onLeave,
128
-    actionsTrigger = ActionTrigger.Hover,
129
-    audioMuteState = MediaState.None,
130
-    videoMuteState = MediaState.None,
133
+    actionsTrigger = ACTION_TRIGGER.HOVER,
134
+    audioMediaState = MEDIA_STATE.NONE,
135
+    videoMuteState = MEDIA_STATE.NONE,
131
     name,
136
     name,
132
     participant: p
137
     participant: p
133
 }: Props) => {
138
 }: Props) => {
155
                 <ParticipantStates>
160
                 <ParticipantStates>
156
                     {p.raisedHand && <RaisedHandIndicator />}
161
                     {p.raisedHand && <RaisedHandIndicator />}
157
                     {VideoStateIcons[videoMuteState]}
162
                     {VideoStateIcons[videoMuteState]}
158
-                    {AudioStateIcons[audioMuteState]}
163
+                    {AudioStateIcons[audioMediaState]}
159
                 </ParticipantStates>
164
                 </ParticipantStates>
160
             </ParticipantContent>
165
             </ParticipantContent>
161
         </ParticipantContainer>
166
         </ParticipantContainer>

+ 59
- 0
react/features/participants-pane/components/ParticipantQuickAction.js View File

1
+// @flow
2
+
3
+import React from 'react';
4
+import { useTranslation } from 'react-i18next';
5
+import { useSelector } from 'react-redux';
6
+
7
+import { QUICK_ACTION_BUTTON } from '../constants';
8
+import { getQuickActionButtonType } from '../functions';
9
+
10
+import AskToUnmuteButton from './AskToUnmuteButton';
11
+import { QuickActionButton } from './styled';
12
+
13
+type Props = {
14
+
15
+    /**
16
+     * If audio is muted for the current participant.
17
+     */
18
+    isAudioMuted: Boolean,
19
+
20
+    /**
21
+     * Callback used to open a confirmation dialog for audio muting.
22
+     */
23
+    muteAudio: Function,
24
+
25
+    /**
26
+     * Participant.
27
+     */
28
+    participant: Object,
29
+}
30
+
31
+/**
32
+ * Component used to display mute/ask to unmute button.
33
+ *
34
+ * @param {Props} props - The props of the component.
35
+ * @returns {React$Element<'button'>}
36
+ */
37
+export default function({ isAudioMuted, muteAudio, participant }: Props) {
38
+    const buttonType = useSelector(getQuickActionButtonType(participant, isAudioMuted));
39
+    const { id } = participant;
40
+    const { t } = useTranslation();
41
+
42
+    switch (buttonType) {
43
+    case QUICK_ACTION_BUTTON.MUTE: {
44
+        return (
45
+            <QuickActionButton
46
+                onClick = { muteAudio(id) }
47
+                primary = { true }>
48
+                {t('dialog.muteParticipantButton')}
49
+            </QuickActionButton>
50
+        );
51
+    }
52
+    case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
53
+        return <AskToUnmuteButton id = { id } />;
54
+    }
55
+    default: {
56
+        return null;
57
+    }
58
+    }
59
+}

+ 38
- 8
react/features/participants-pane/components/ParticipantsPane.js View File

1
 // @flow
1
 // @flow
2
 
2
 
3
-import React, { useCallback } from 'react';
3
+import React, { useCallback, useEffect, useState } from 'react';
4
 import { useTranslation } from 'react-i18next';
4
 import { useTranslation } from 'react-i18next';
5
 import { useDispatch, useSelector } from 'react-redux';
5
 import { useDispatch, useSelector } from 'react-redux';
6
 import { ThemeProvider } from 'styled-components';
6
 import { ThemeProvider } from 'styled-components';
7
 
7
 
8
 import { openDialog } from '../../base/dialog';
8
 import { openDialog } from '../../base/dialog';
9
-import { isLocalParticipantModerator } from '../../base/participants';
9
+import {
10
+    getParticipantCount,
11
+    isEveryoneModerator,
12
+    isLocalParticipantModerator
13
+} from '../../base/participants';
10
 import { MuteEveryoneDialog } from '../../video-menu/components/';
14
 import { MuteEveryoneDialog } from '../../video-menu/components/';
11
 import { close } from '../actions';
15
 import { close } from '../actions';
12
-import { classList, getParticipantsPaneOpen } from '../functions';
16
+import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions';
13
 import theme from '../theme.json';
17
 import theme from '../theme.json';
14
 
18
 
19
+import { FooterContextMenu } from './FooterContextMenu';
15
 import { LobbyParticipantList } from './LobbyParticipantList';
20
 import { LobbyParticipantList } from './LobbyParticipantList';
16
 import { MeetingParticipantList } from './MeetingParticipantList';
21
 import { MeetingParticipantList } from './MeetingParticipantList';
17
 import {
22
 import {
20
     Container,
25
     Container,
21
     Footer,
26
     Footer,
22
     FooterButton,
27
     FooterButton,
28
+    FooterEllipsisButton,
29
+    FooterEllipsisContainer,
23
     Header
30
     Header
24
 } from './styled';
31
 } from './styled';
25
 
32
 
27
     const dispatch = useDispatch();
34
     const dispatch = useDispatch();
28
     const paneOpen = useSelector(getParticipantsPaneOpen);
35
     const paneOpen = useSelector(getParticipantsPaneOpen);
29
     const isLocalModerator = useSelector(isLocalParticipantModerator);
36
     const isLocalModerator = useSelector(isLocalParticipantModerator);
37
+    const participantsCount = useSelector(getParticipantCount);
38
+    const everyoneModerator = useSelector(isEveryoneModerator);
39
+    const showContextMenu = !everyoneModerator && participantsCount > 2;
40
+
41
+    const [ contextOpen, setContextOpen ] = useState(false);
30
     const { t } = useTranslation();
42
     const { t } = useTranslation();
31
 
43
 
32
     const closePane = useCallback(() => dispatch(close(), [ dispatch ]));
44
     const closePane = useCallback(() => dispatch(close(), [ dispatch ]));
38
     }, [ closePane ]);
50
     }, [ closePane ]);
39
     const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]);
51
     const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]);
40
 
52
 
53
+    useEffect(() => {
54
+        const handler = [ 'click', e => {
55
+            if (!findStyledAncestor(e.target, FooterEllipsisContainer)) {
56
+                setContextOpen(false);
57
+            }
58
+        } ];
59
+
60
+        window.addEventListener(...handler);
61
+
62
+        return () => window.removeEventListener(...handler);
63
+    }, [ contextOpen ]);
64
+
65
+    const toggleContext = useCallback(() => setContextOpen(!contextOpen), [ contextOpen, setContextOpen ]);
66
+
41
     return (
67
     return (
42
         <ThemeProvider theme = { theme }>
68
         <ThemeProvider theme = { theme }>
43
-            <div
44
-                className = { classList(
45
-          'participants_pane',
46
-          !paneOpen && 'participants_pane--closed'
47
-                ) }>
69
+            <div className = { classList('participants_pane', !paneOpen && 'participants_pane--closed') }>
48
                 <div className = 'participants_pane-content'>
70
                 <div className = 'participants_pane-content'>
49
                     <Header>
71
                     <Header>
50
                         <Close
72
                         <Close
64
                             <FooterButton onClick = { muteAll }>
86
                             <FooterButton onClick = { muteAll }>
65
                                 {t('participantsPane.actions.muteAll')}
87
                                 {t('participantsPane.actions.muteAll')}
66
                             </FooterButton>
88
                             </FooterButton>
89
+                            {showContextMenu && (
90
+                                <FooterEllipsisContainer>
91
+                                    <FooterEllipsisButton
92
+                                        id = 'participants-pane-context-menu'
93
+                                        onClick = { toggleContext } />
94
+                                    {contextOpen && <FooterContextMenu onMouseLeave = { toggleContext } />}
95
+                                </FooterEllipsisContainer>
96
+                            )}
67
                         </Footer>
97
                         </Footer>
68
                     )}
98
                     )}
69
                 </div>
99
                 </div>

+ 14
- 3
react/features/participants-pane/components/styled.js View File

2
 import styled from 'styled-components';
2
 import styled from 'styled-components';
3
 
3
 
4
 import { Icon, IconHorizontalPoints } from '../../base/icons';
4
 import { Icon, IconHorizontalPoints } from '../../base/icons';
5
-import { ActionTrigger } from '../constants';
5
+import { ACTION_TRIGGER } from '../constants';
6
 
6
 
7
 export const ignoredChildClassName = 'ignore-child';
7
 export const ignoredChildClassName = 'ignore-child';
8
 
8
 
21
   display: flex;
21
   display: flex;
22
   font-weight: unset;
22
   font-weight: unset;
23
   justify-content: center;
23
   justify-content: center;
24
+  min-height: 32px;
24
 
25
 
25
   &:hover {
26
   &:hover {
26
     background-color: ${
27
     background-color: ${
30
   }
31
   }
31
 `;
32
 `;
32
 
33
 
34
+export const QuickActionButton = styled(Button)`
35
+  padding: 0 12px;
36
+`;
37
+
33
 export const Container = styled.div`
38
 export const Container = styled.div`
34
   box-sizing: border-box;
39
   box-sizing: border-box;
35
   flex: 1;
40
   flex: 1;
93
   box-sizing: border-box;
98
   box-sizing: border-box;
94
   cursor: pointer;
99
   cursor: pointer;
95
   display: flex;
100
   display: flex;
96
-  height: 40px;
101
+  min-height: 40px;
97
   padding: 8px 16px;
102
   padding: 8px 16px;
98
 
103
 
99
   & > *:not(:last-child) {
104
   & > *:not(:last-child) {
185
   margin: 8px 0 ${props => props.theme.panePadding}px;
190
   margin: 8px 0 ${props => props.theme.panePadding}px;
186
 `;
191
 `;
187
 
192
 
193
+export const ColoredIcon = styled.div`
194
+  & > div > svg {
195
+    fill: ${props => props.color || '#fff'};
196
+  }
197
+`;
198
+
188
 export const ParticipantActionButton = styled(Button)`
199
 export const ParticipantActionButton = styled(Button)`
189
   height: ${props => props.theme.participantActionButtonHeight}px;
200
   height: ${props => props.theme.participantActionButtonHeight}px;
190
   padding: 6px 10px;
201
   padding: 6px 10px;
256
     background-color: #292929;
267
     background-color: #292929;
257
 
268
 
258
     & ${ParticipantActions} {
269
     & ${ParticipantActions} {
259
-      ${props => props.trigger === ActionTrigger.Hover && `
270
+      ${props => props.trigger === ACTION_TRIGGER.HOVER && `
260
         display: flex;
271
         display: flex;
261
       `}
272
       `}
262
     }
273
     }

+ 34
- 8
react/features/participants-pane/constants.js View File

1
+// @flow
2
+
1
 /**
3
 /**
2
  * Reducer key for the feature.
4
  * Reducer key for the feature.
3
  */
5
  */
4
 export const REDUCER_KEY = 'features/participants-pane';
6
 export const REDUCER_KEY = 'features/participants-pane';
5
 
7
 
8
+export type ActionTrigger = 'Hover' | 'Permanent'
9
+
6
 /**
10
 /**
7
  * Enum of possible participant action triggers.
11
  * Enum of possible participant action triggers.
8
  */
12
  */
9
-export const ActionTrigger = {
10
-    Hover: 'ActionTrigger.Hover',
11
-    Permanent: 'ActionTrigger.Permanent'
13
+export const ACTION_TRIGGER: {HOVER: ActionTrigger, PERMANENT: ActionTrigger} = {
14
+    HOVER: 'Hover',
15
+    PERMANENT: 'Permanent'
12
 };
16
 };
13
 
17
 
18
+export type MediaState = 'Muted' | 'ForceMuted' | 'Unmuted' | 'None';
19
+
14
 /**
20
 /**
15
  * Enum of possible participant media states.
21
  * Enum of possible participant media states.
16
  */
22
  */
17
-export const MediaState = {
18
-    Muted: 'MediaState.Muted',
19
-    ForceMuted: 'MediaState.ForceMuted',
20
-    Unmuted: 'MediaState.Unmuted',
21
-    None: 'MediaState.None'
23
+export const MEDIA_STATE: {
24
+    MUTED: MediaState,
25
+    FORCE_MUTED: MediaState,
26
+    UNMUTED: MediaState,
27
+    NONE: MediaState,
28
+} = {
29
+    MUTED: 'Muted',
30
+    FORCE_MUTED: 'ForceMuted',
31
+    UNMUTED: 'Unmuted',
32
+    NONE: 'None'
33
+};
34
+
35
+export type QuickActionButtonType = 'Mute' | 'AskToUnmute' | 'None';
36
+
37
+/**
38
+ * Enum of possible participant mute button states.
39
+ */
40
+export const QUICK_ACTION_BUTTON: {
41
+    MUTE: QuickActionButtonType,
42
+    ASK_TO_UNMUTE: QuickActionButtonType,
43
+    NONE: QuickActionButtonType
44
+} = {
45
+    MUTE: 'Mute',
46
+    ASK_TO_UNMUTE: 'AskToUnmute',
47
+    NONE: 'None'
22
 };
48
 };

+ 86
- 8
react/features/participants-pane/functions.js View File

1
+// @flow
1
 
2
 
3
+import {
4
+    isParticipantApproved,
5
+    isEnabledFromState,
6
+    isLocalParticipantApprovedFromState
7
+} from '../av-moderation/functions';
2
 import { getFeatureFlag, INVITE_ENABLED } from '../base/flags';
8
 import { getFeatureFlag, INVITE_ENABLED } from '../base/flags';
9
+import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
10
+import {
11
+    getParticipantCount,
12
+    isLocalParticipantModerator,
13
+    isParticipantModerator
14
+} from '../base/participants/functions';
3
 import { toState } from '../base/redux';
15
 import { toState } from '../base/redux';
4
 
16
 
5
-import { REDUCER_KEY } from './constants';
17
+import { QUICK_ACTION_BUTTON, REDUCER_KEY, MEDIA_STATE } from './constants';
6
 
18
 
7
 /**
19
 /**
8
  * Generates a class attribute value.
20
  * Generates a class attribute value.
10
  * @param {Iterable<string>} args - String iterable.
22
  * @param {Iterable<string>} args - String iterable.
11
  * @returns {string} Class attribute value.
23
  * @returns {string} Class attribute value.
12
  */
24
  */
13
-export const classList = (...args) => args.filter(Boolean).join(' ');
25
+export const classList = (...args: Array<string | boolean>) => args.filter(Boolean).join(' ');
14
 
26
 
15
 
27
 
16
 /**
28
 /**
20
  * @param {StyledComponentClass} component - Styled component reference.
32
  * @param {StyledComponentClass} component - Styled component reference.
21
  * @returns {Element|null} Ancestor.
33
  * @returns {Element|null} Ancestor.
22
  */
34
  */
23
-export const findStyledAncestor = (target, component) => {
35
+export const findStyledAncestor = (target: Object, component: any) => {
24
     if (!target || target.matches(`.${component.styledComponentId}`)) {
36
     if (!target || target.matches(`.${component.styledComponentId}`)) {
25
         return target;
37
         return target;
26
     }
38
     }
28
     return findStyledAncestor(target.parentElement, component);
40
     return findStyledAncestor(target.parentElement, component);
29
 };
41
 };
30
 
42
 
43
+/**
44
+ * Returns a selector used to determine if a participant is force muted.
45
+ *
46
+ * @param {Object} participant - The participant id.
47
+ * @param {MediaType} mediaType - The media type.
48
+ * @returns {MediaState}.
49
+ */
50
+export const isForceMuted = (participant: Object, mediaType: MediaType) => (state: Object) => {
51
+    if (getParticipantCount(state) > 2 && isEnabledFromState(mediaType, state)) {
52
+        if (participant.local) {
53
+            return !isLocalParticipantApprovedFromState(mediaType, state);
54
+        }
55
+
56
+        // moderators cannot be force muted
57
+        if (isParticipantModerator(participant)) {
58
+            return false;
59
+        }
60
+
61
+        return !isParticipantApproved(participant.id, mediaType)(state);
62
+    }
63
+
64
+    return false;
65
+};
66
+
67
+/**
68
+ * Returns a selector used to determine the audio media state (the mic icon) for a participant.
69
+ *
70
+ * @param {Object} participant - The participant.
71
+ * @param {boolean} muted - The mute state of the participant.
72
+ * @returns {MediaState}.
73
+ */
74
+export const getParticipantAudioMediaState = (participant: Object, muted: Boolean) => (state: Object) => {
75
+    if (muted) {
76
+        if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) {
77
+            return MEDIA_STATE.FORCE_MUTED;
78
+        }
79
+
80
+        return MEDIA_STATE.MUTED;
81
+    }
82
+
83
+    return MEDIA_STATE.UNMUTED;
84
+};
85
+
86
+
31
 /**
87
 /**
32
  * Get a style property from a style declaration as a float.
88
  * Get a style property from a style declaration as a float.
33
  *
89
  *
35
  * @param {string} name - Property name.
91
  * @param {string} name - Property name.
36
  * @returns {number} Float value.
92
  * @returns {number} Float value.
37
  */
93
  */
38
-export const getFloatStyleProperty = (styles, name) =>
94
+export const getFloatStyleProperty = (styles: Object, name: string) =>
39
     parseFloat(styles.getPropertyValue(name));
95
     parseFloat(styles.getPropertyValue(name));
40
 
96
 
41
 /**
97
 /**
44
  * @param {Element} element - Target element.
100
  * @param {Element} element - Target element.
45
  * @returns {number} Computed height.
101
  * @returns {number} Computed height.
46
  */
102
  */
47
-export const getComputedOuterHeight = element => {
103
+export const getComputedOuterHeight = (element: HTMLElement) => {
48
     const computedStyle = getComputedStyle(element);
104
     const computedStyle = getComputedStyle(element);
49
 
105
 
50
     return element.offsetHeight
106
     return element.offsetHeight
58
  * @param {Object} state - Global state.
114
  * @param {Object} state - Global state.
59
  * @returns {Object} Feature state.
115
  * @returns {Object} Feature state.
60
  */
116
  */
61
-const getState = state => state[REDUCER_KEY];
117
+const getState = (state: Object) => state[REDUCER_KEY];
62
 
118
 
63
 /**
119
 /**
64
  * Is the participants pane open.
120
  * Is the participants pane open.
66
  * @param {Object} state - Global state.
122
  * @param {Object} state - Global state.
67
  * @returns {boolean} Is the participants pane open.
123
  * @returns {boolean} Is the participants pane open.
68
  */
124
  */
69
-export const getParticipantsPaneOpen = state => Boolean(getState(state)?.isOpen);
125
+export const getParticipantsPaneOpen = (state: Object) => Boolean(getState(state)?.isOpen);
126
+
127
+/**
128
+ * Returns a selector used to determine the type of quick action button to be displayed for a participant.
129
+ * The button is displayed when hovering a participant from the participant list.
130
+ *
131
+ * @param {Object} participant - The participant.
132
+ * @param {boolean} isAudioMuted - If audio is muted for the participant.
133
+ * @returns {Function}
134
+ */
135
+export const getQuickActionButtonType = (participant: Object, isAudioMuted: Boolean) => (state: Object) => {
136
+    // handled only by moderators
137
+    if (isLocalParticipantModerator(state)) {
138
+        if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) {
139
+            return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
140
+        }
141
+        if (!isAudioMuted) {
142
+            return QUICK_ACTION_BUTTON.MUTE;
143
+        }
144
+    }
145
+
146
+    return QUICK_ACTION_BUTTON.NONE;
147
+};
70
 
148
 
71
 /**
149
 /**
72
  * Returns true if the invite button should be rendered.
150
  * Returns true if the invite button should be rendered.
74
  * @param {Object} state - Global state.
152
  * @param {Object} state - Global state.
75
  * @returns {boolean}
153
  * @returns {boolean}
76
  */
154
  */
77
-export const shouldRenderInviteButton = state => {
155
+export const shouldRenderInviteButton = (state: Object) => {
78
     const { disableInviteFunctions } = toState(state)['features/base/config'];
156
     const { disableInviteFunctions } = toState(state)['features/base/config'];
79
     const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
157
     const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
80
 
158
 

+ 4
- 1
react/features/participants-pane/theme.json View File

1
 {
1
 {
2
+  "colors": {
3
+    "moderationDisabled": "#E54B4B"
4
+  },
2
   "contextFontSize": 14,
5
   "contextFontSize": 14,
3
   "contextFontWeight": 400,
6
   "contextFontWeight": 400,
4
   "headerSize": 60,
7
   "headerSize": 60,
7
   "participantItemHeight": 48,
10
   "participantItemHeight": 48,
8
   "participantsPaneWidth": 315,
11
   "participantsPaneWidth": 315,
9
   "rangeInputThumbSize": 14
12
   "rangeInputThumbSize": 14
10
-}
13
+}

+ 2
- 12
react/features/toolbox/components/native/RaiseHandButton.js View File

11
 import { IconRaisedHand } from '../../../base/icons';
11
 import { IconRaisedHand } from '../../../base/icons';
12
 import {
12
 import {
13
     getLocalParticipant,
13
     getLocalParticipant,
14
-    participantUpdated
14
+    raiseHand
15
 } from '../../../base/participants';
15
 } from '../../../base/participants';
16
 import { connect } from '../../../base/redux';
16
 import { connect } from '../../../base/redux';
17
 import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
17
 import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
78
 
78
 
79
         sendAnalytics(createToolbarEvent('raise.hand', { enable }));
79
         sendAnalytics(createToolbarEvent('raise.hand', { enable }));
80
 
80
 
81
-        this.props.dispatch(participantUpdated({
82
-            // XXX Only the local participant is allowed to update without
83
-            // stating the JitsiConference instance (i.e. participant property
84
-            // `conference` for a remote participant) because the local
85
-            // participant is uniquely identified by the very fact that there is
86
-            // only one local participant.
87
-
88
-            id: this.props._localParticipant.id,
89
-            local: true,
90
-            raisedHand: enable
91
-        }));
81
+        this.props.dispatch(raiseHand(enable));
92
     }
82
     }
93
 }
83
 }
94
 
84
 

+ 3
- 12
react/features/toolbox/components/web/Toolbox.js View File

32
 import {
32
 import {
33
     getLocalParticipant,
33
     getLocalParticipant,
34
     getParticipants,
34
     getParticipants,
35
-    participantUpdated
35
+    raiseHand
36
 } from '../../../base/participants';
36
 } from '../../../base/participants';
37
 import { connect } from '../../../base/redux';
37
 import { connect } from '../../../base/redux';
38
 import { OverflowMenuItem } from '../../../base/toolbox/components';
38
 import { OverflowMenuItem } from '../../../base/toolbox/components';
522
         const { _localParticipantID, _raisedHand } = this.props;
522
         const { _localParticipantID, _raisedHand } = this.props;
523
         const newRaisedStatus = !_raisedHand;
523
         const newRaisedStatus = !_raisedHand;
524
 
524
 
525
-        this.props.dispatch(participantUpdated({
526
-            // XXX Only the local participant is allowed to update without
527
-            // stating the JitsiConference instance (i.e. participant property
528
-            // `conference` for a remote participant) because the local
529
-            // participant is uniquely identified by the very fact that there is
530
-            // only one local participant.
531
-
532
-            id: _localParticipantID,
533
-            local: true,
534
-            raisedHand: newRaisedStatus
535
-        }));
525
+        this.props.dispatch(raiseHand(newRaisedStatus));
536
 
526
 
537
         APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus);
527
         APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus);
538
     }
528
     }
1276
                     <ToolbarButton
1266
                     <ToolbarButton
1277
                         accessibilityLabel = { t('toolbar.accessibilityLabel.participants') }
1267
                         accessibilityLabel = { t('toolbar.accessibilityLabel.participants') }
1278
                         icon = { IconParticipants }
1268
                         icon = { IconParticipants }
1269
+                        key = 'participants'
1279
                         onClick = { this._onToolbarToggleParticipantsPane }
1270
                         onClick = { this._onToolbarToggleParticipantsPane }
1280
                         toggled = { this.props._participantsPaneOpen }
1271
                         toggled = { this.props._participantsPaneOpen }
1281
                         tooltip = { t('toolbar.participants') } />)
1272
                         tooltip = { t('toolbar.participants') } />)

+ 11
- 1
react/features/video-menu/actions.any.js View File

10
     sendAnalytics,
10
     sendAnalytics,
11
     VIDEO_MUTE
11
     VIDEO_MUTE
12
 } from '../analytics';
12
 } from '../analytics';
13
+import { showModeratedNotification } from '../av-moderation/actions';
14
+import { shouldShowModeratedNotification } from '../av-moderation/functions';
13
 import {
15
 import {
14
     MEDIA_TYPE,
16
     MEDIA_TYPE,
15
     setAudioMuted,
17
     setAudioMuted,
33
  * @returns {Function}
35
  * @returns {Function}
34
  */
36
  */
35
 export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
37
 export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
36
-    return (dispatch: Dispatch<any>) => {
38
+    return (dispatch: Dispatch<any>, getState: Function) => {
37
         const isAudio = mediaType === MEDIA_TYPE.AUDIO;
39
         const isAudio = mediaType === MEDIA_TYPE.AUDIO;
38
 
40
 
39
         if (!isAudio && mediaType !== MEDIA_TYPE.VIDEO) {
41
         if (!isAudio && mediaType !== MEDIA_TYPE.VIDEO) {
41
 
43
 
42
             return;
44
             return;
43
         }
45
         }
46
+
47
+        // check for A/V Moderation when trying to unmute
48
+        if (!enable && shouldShowModeratedNotification(MEDIA_TYPE.AUDIO, getState())) {
49
+            dispatch(showModeratedNotification(MEDIA_TYPE.AUDIO));
50
+
51
+            return;
52
+        }
53
+
44
         sendAnalytics(createToolbarEvent(isAudio ? AUDIO_MUTE : VIDEO_MUTE, { enable }));
54
         sendAnalytics(createToolbarEvent(isAudio ? AUDIO_MUTE : VIDEO_MUTE, { enable }));
45
         dispatch(isAudio ? setAudioMuted(enable, /* ensureTrack */ true)
55
         dispatch(isAudio ? setAudioMuted(enable, /* ensureTrack */ true)
46
             : setVideoMuted(enable, mediaType, VIDEO_MUTISM_AUTHORITY.USER, /* ensureTrack */ true));
56
             : setVideoMuted(enable, mediaType, VIDEO_MUTISM_AUTHORITY.USER, /* ensureTrack */ true));

+ 6
- 4
resources/prosody-plugins/mod_av_moderation_component.lua View File

132
                     module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
132
                     module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
133
                     return true;
133
                     return true;
134
                 else
134
                 else
135
-                    room.av_moderation = {};
136
-                    room.av_moderation_actors = {};
135
+                    if not room.av_moderation then
136
+                        room.av_moderation = {};
137
+                        room.av_moderation_actors = {};
138
+                    end
137
                     room.av_moderation[mediaType] = {};
139
                     room.av_moderation[mediaType] = {};
138
                     room.av_moderation_actors[mediaType] = occupant.nick;
140
                     room.av_moderation_actors[mediaType] = occupant.nick;
139
                 end
141
                 end
147
                     room.av_moderation_actors[mediaType] = nil;
149
                     room.av_moderation_actors[mediaType] = nil;
148
 
150
 
149
                     -- clears room.av_moderation if empty
151
                     -- clears room.av_moderation if empty
150
-                    local is_empty = false;
152
+                    local is_empty = true;
151
                     for key,_ in pairs(room.av_moderation) do
153
                     for key,_ in pairs(room.av_moderation) do
152
                         if room.av_moderation[key] then
154
                         if room.av_moderation[key] then
153
-                            is_empty = true;
155
+                            is_empty = false;
154
                         end
156
                         end
155
                     end
157
                     end
156
                     if is_empty then
158
                     if is_empty then

Loading…
Cancel
Save