Pārlūkot izejas kodu

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 gadus atpakaļ
vecāks
revīzija
64ae9c7953
Revīzijas autora e-pasta adrese nav piesaistīta nevienam kontam
44 mainītis faili ar 1642 papildinājumiem un 197 dzēšanām
  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 Parādīt failu

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

+ 17
- 0
lang/main.json Parādīt failu

@@ -521,6 +521,7 @@
521 521
         "focus": "Conference focus",
522 522
         "focusFail": "{{component}} not available - retry in {{ms}} sec",
523 523
         "grantedTo": "Moderator rights granted to {{to}}!",
524
+        "hostAskedUnmute": "The host would like you to unmute",
524 525
         "invitedOneMember": "{{name}} has been invited",
525 526
         "invitedThreePlusMembers": "{{name}} and {{count}} others have been invited",
526 527
         "invitedTwoMembers": "{{first}} and {{second}} have been invited",
@@ -551,6 +552,18 @@
551 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 553
         "oldElectronClientDescription2": "latest build",
553 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 567
         "groupTitle": "Notifications"
555 568
     },
556 569
     "participantsPane": {
@@ -560,8 +573,12 @@
560 573
             "participantsList": "Meeting participants ({{count}})"
561 574
         },
562 575
         "actions": {
576
+            "allow": "Allow attendees to:",
563 577
             "invite": "Invite Someone",
578
+            "askUnmute": "Ask to unmute",
564 579
             "muteAll": "Mute all",
580
+            "startModeration": "Unmute themselves or start video",
581
+            "stopEveryonesVideo": "Stop everyone's video",
565 582
             "stopVideo": "Stop video"
566 583
         }
567 584
     },

+ 3
- 9
modules/API/API.js Parādīt failu

@@ -20,9 +20,9 @@ import { MEDIA_TYPE } from '../../react/features/base/media';
20 20
 import {
21 21
     getLocalParticipant,
22 22
     getParticipantById,
23
-    participantUpdated,
24 23
     pinParticipant,
25
-    kickParticipant
24
+    kickParticipant,
25
+    raiseHand
26 26
 } from '../../react/features/base/participants';
27 27
 import { updateSettings } from '../../react/features/base/settings';
28 28
 import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks';
@@ -205,13 +205,7 @@ function initCommands() {
205 205
             const { raisedHand } = localParticipant;
206 206
 
207 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 Parādīt failu

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

+ 1
- 0
react/features/app/reducers.web.js Parādīt failu

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

+ 87
- 0
react/features/av-moderation/actionTypes.js Parādīt failu

@@ -0,0 +1,87 @@
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 Parādīt failu

@@ -0,0 +1,173 @@
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 Parādīt failu

@@ -0,0 +1,35 @@
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 Parādīt failu

@@ -0,0 +1,19 @@
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 Parādīt failu

@@ -0,0 +1,115 @@
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 Parādīt failu

@@ -0,0 +1,190 @@
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 Parādīt failu

@@ -0,0 +1,134 @@
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 Parādīt failu

@@ -2,6 +2,9 @@
2 2
 
3 3
 import type { Dispatch } from 'redux';
4 4
 
5
+import { showModeratedNotification } from '../../av-moderation/actions';
6
+import { shouldShowModeratedNotification } from '../../av-moderation/functions';
7
+
5 8
 import {
6 9
     SET_AUDIO_MUTED,
7 10
     SET_AUDIO_AVAILABLE,
@@ -12,8 +15,8 @@ import {
12 15
     TOGGLE_CAMERA_FACING_MODE
13 16
 } from './actionTypes';
14 17
 import {
15
-    CAMERA_FACING_MODE,
16 18
     MEDIA_TYPE,
19
+    type MediaType,
17 20
     VIDEO_MUTISM_AUTHORITY
18 21
 } from './constants';
19 22
 
@@ -64,7 +67,7 @@ export function setAudioMuted(muted: boolean, ensureTrack: boolean = false) {
64 67
  *     cameraFacingMode: CAMERA_FACING_MODE
65 68
  * }}
66 69
  */
67
-export function setCameraFacingMode(cameraFacingMode: CAMERA_FACING_MODE) {
70
+export function setCameraFacingMode(cameraFacingMode: string) {
68 71
     return {
69 72
         type: SET_CAMERA_FACING_MODE,
70 73
         cameraFacingMode
@@ -102,11 +105,20 @@ export function setVideoAvailable(available: boolean) {
102 105
  */
103 106
 export function setVideoMuted(
104 107
         muted: boolean,
105
-        mediaType: MEDIA_TYPE = MEDIA_TYPE.VIDEO,
108
+        mediaType: MediaType = MEDIA_TYPE.VIDEO,
106 109
         authority: number = VIDEO_MUTISM_AUTHORITY.USER,
107 110
         ensureTrack: boolean = false) {
108 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 123
         // eslint-disable-next-line no-bitwise
112 124
         const newValue = muted ? oldValue | authority : oldValue & ~authority;

+ 6
- 1
react/features/base/media/constants.js Parādīt failu

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

+ 7
- 0
react/features/base/participants/actionTypes.js Parādīt failu

@@ -171,3 +171,10 @@ export const HIDDEN_PARTICIPANT_LEFT = 'HIDDEN_PARTICIPANT_LEFT';
171 171
  */
172 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 Parādīt failu

@@ -7,6 +7,7 @@ import {
7 7
     HIDDEN_PARTICIPANT_LEFT,
8 8
     GRANT_MODERATOR,
9 9
     KICK_PARTICIPANT,
10
+    LOCAL_PARTICIPANT_RAISE_HAND,
10 11
     MUTE_REMOTE_PARTICIPANT,
11 12
     PARTICIPANT_ID_CHANGED,
12 13
     PARTICIPANT_JOINED,
@@ -555,3 +556,18 @@ export function setLoadableAvatarUrl(participantId, url) {
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 Parādīt failu

@@ -18,6 +18,7 @@ import {
18 18
     DOMINANT_SPEAKER_CHANGED,
19 19
     GRANT_MODERATOR,
20 20
     KICK_PARTICIPANT,
21
+    LOCAL_PARTICIPANT_RAISE_HAND,
21 22
     MUTE_REMOTE_PARTICIPANT,
22 23
     PARTICIPANT_DISPLAY_NAME_CHANGED,
23 24
     PARTICIPANT_JOINED,
@@ -110,6 +111,29 @@ MiddlewareRegistry.register(store => next => action => {
110 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 137
     case MUTE_REMOTE_PARTICIPANT: {
114 138
         const { conference } = store.getState()['features/base/conference'];
115 139
 

+ 10
- 0
react/features/base/tracks/middleware.js Parādīt failu

@@ -1,6 +1,8 @@
1 1
 // @flow
2 2
 
3 3
 import UIEvents from '../../../../service/UI/UIEvents';
4
+import { showModeratedNotification } from '../../av-moderation/actions';
5
+import { shouldShowModeratedNotification } from '../../av-moderation/functions';
4 6
 import { hideNotification } from '../../notifications';
5 7
 import { isPrejoinPageVisible } from '../../prejoin/functions';
6 8
 import { getAvailableDevices } from '../devices/actions';
@@ -135,6 +137,14 @@ MiddlewareRegistry.register(store => next => action => {
135 137
 
136 138
     case TOGGLE_SCREENSHARING:
137 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 148
             APP.UI.emitEvent(UIEvents.TOGGLE_SCREENSHARING, action.audioOnly);
139 149
         }
140 150
         break;

+ 6
- 1
react/features/conference/components/web/Conference.js Parādīt failu

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

+ 28
- 0
react/features/lobby/actions.web.js Parādīt failu

@@ -145,6 +145,34 @@ export function admitMultiple(participants: Array<Object>) {
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 177
  * Action to set the knocking state of the participant.
150 178
  *

+ 15
- 61
react/features/lobby/components/web/KnockingParticipantList.js Parādīt failu

@@ -2,11 +2,10 @@
2 2
 
3 3
 import React from 'react';
4 4
 
5
-import { Avatar } from '../../../base/avatar';
6 5
 import { translate } from '../../../base/i18n';
7 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 9
 import AbstractKnockingParticipantList, {
11 10
     mapStateToProps as abstractMapStateToProps,
12 11
     type Props as AbstractProps
@@ -17,7 +16,7 @@ type Props = AbstractProps & {
17 16
     /**
18 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,56 +29,24 @@ class KnockingParticipantList extends AbstractKnockingParticipantList<Props> {
30 29
      * @inheritdoc
31 30
      */
32 31
     render() {
33
-        const { _participants, _toolboxVisible, _visible, t } = this.props;
32
+        const { _participants, _visible, t } = this.props;
34 33
 
35 34
         if (!_visible) {
36 35
             return null;
37 36
         }
38 37
 
39 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 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 50
             </div>
84 51
         );
85 52
     }
@@ -87,17 +54,4 @@ class KnockingParticipantList extends AbstractKnockingParticipantList<Props> {
87 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 Parādīt failu

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

+ 3
- 3
react/features/notifications/actions.js Parādīt failu

@@ -33,10 +33,10 @@ export function clearNotifications() {
33 33
  * removed.
34 34
  * @returns {{
35 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 40
     return {
41 41
         type: HIDE_NOTIFICATION,
42 42
         uid
@@ -95,7 +95,7 @@ export function showNotification(props: Object = {}, timeout: ?number) {
95 95
                 type: SHOW_NOTIFICATION,
96 96
                 props,
97 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 Parādīt failu

@@ -90,7 +90,7 @@ export type Props = {
90 90
     /**
91 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 Parādīt failu

@@ -0,0 +1,52 @@
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 Parādīt failu

@@ -0,0 +1,97 @@
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 Parādīt failu

@@ -0,0 +1,82 @@
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 Parādīt failu

@@ -0,0 +1,43 @@
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 Parādīt failu

@@ -0,0 +1,101 @@
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 Parādīt failu

@@ -4,8 +4,8 @@ import React, { useCallback } from 'react';
4 4
 import { useTranslation } from 'react-i18next';
5 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 10
 import { ParticipantItem } from './ParticipantItem';
11 11
 import { ParticipantActionButton } from './styled';
@@ -20,17 +20,17 @@ type Props = {
20 20
 
21 21
 export const LobbyParticipantItem = ({ participant: p }: Props) => {
22 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 25
     const { t } = useTranslation();
26 26
 
27 27
     return (
28 28
         <ParticipantItem
29
-            actionsTrigger = { ActionTrigger.Permanent }
30
-            audioMuteState = { MediaState.None }
29
+            actionsTrigger = { ACTION_TRIGGER.PERMANENT }
30
+            audioMediaState = { MEDIA_STATE.NONE }
31 31
             name = { p.name }
32 32
             participant = { p }
33
-            videoMuteState = { MediaState.None }>
33
+            videoMuteState = { MEDIA_STATE.NONE }>
34 34
             <ParticipantActionButton
35 35
                 onClick = { reject }>
36 36
                 {t('lobby.reject')}

+ 34
- 15
react/features/participants-pane/components/MeetingParticipantContextMenu.js Parādīt failu

@@ -10,11 +10,12 @@ import {
10 10
     IconCloseCircle,
11 11
     IconCrown,
12 12
     IconMessage,
13
+    IconMicDisabled,
13 14
     IconMuteEveryoneElse,
14 15
     IconVideoOff
15 16
 } from '../../base/icons';
16 17
 import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants';
17
-import { getIsParticipantVideoMuted } from '../../base/tracks';
18
+import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
18 19
 import { openChat } from '../../chat/actions';
19 20
 import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
20 21
 import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
@@ -30,6 +31,11 @@ import {
30 31
 
31 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 40
      * Target elements against which positioning calculations are made
35 41
      */
@@ -61,6 +67,7 @@ export const MeetingParticipantContextMenu = ({
61 67
     onEnter,
62 68
     onLeave,
63 69
     onSelect,
70
+    muteAudio,
64 71
     participant
65 72
 }: Props) => {
66 73
     const dispatch = useDispatch();
@@ -68,6 +75,7 @@ export const MeetingParticipantContextMenu = ({
68 75
     const isLocalModerator = useSelector(isLocalParticipantModerator);
69 76
     const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat'));
70 77
     const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
78
+    const isParticipantAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
71 79
     const [ isHidden, setIsHidden ] = useState(true);
72 80
     const { t } = useTranslation();
73 81
 
@@ -133,11 +141,20 @@ export const MeetingParticipantContextMenu = ({
133 141
             onMouseLeave = { onLeave }>
134 142
             <ContextMenuItemGroup>
135 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 158
                 {isLocalModerator && (isParticipantVideoMuted || (
142 159
                     <ContextMenuItem onClick = { muteVideo }>
143 160
                         <ContextMenuIcon src = { IconVideoOff } />
@@ -145,18 +162,20 @@ export const MeetingParticipantContextMenu = ({
145 162
                     </ContextMenuItem>
146 163
                 ))}
147 164
             </ContextMenuItemGroup>
165
+
148 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 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 180
                 {isChatButtonEnabled && (
162 181
                     <ContextMenuItem onClick = { sendPrivateMessage }>

+ 17
- 4
react/features/participants-pane/components/MeetingParticipantItem.js Parādīt failu

@@ -5,9 +5,11 @@ import { useTranslation } from 'react-i18next';
5 5
 import { useSelector } from 'react-redux';
6 6
 
7 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 11
 import { ParticipantItem } from './ParticipantItem';
12
+import ParticipantQuickAction from './ParticipantQuickAction';
11 13
 import { ParticipantActionEllipsis } from './styled';
12 14
 
13 15
 type Props = {
@@ -17,6 +19,11 @@ type Props = {
17 19
      */
18 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 28
      * Callback for the activation of this item's context menu
22 29
      */
@@ -37,20 +44,26 @@ export const MeetingParticipantItem = ({
37 44
     isHighlighted,
38 45
     onContextMenu,
39 46
     onLeave,
47
+    muteAudio,
40 48
     participant
41 49
 }: Props) => {
42 50
     const { t } = useTranslation();
43 51
     const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
44 52
     const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
53
+    const audioMediaState = useSelector(getParticipantAudioMediaState(participant, isAudioMuted));
45 54
 
46 55
     return (
47 56
         <ParticipantItem
48
-            actionsTrigger = { ActionTrigger.Hover }
49
-            audioMuteState = { isAudioMuted ? MediaState.Muted : MediaState.Unmuted }
57
+            actionsTrigger = { ACTION_TRIGGER.HOVER }
58
+            audioMediaState = { audioMediaState }
50 59
             isHighlighted = { isHighlighted }
51 60
             onLeave = { onLeave }
52 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 67
             <ParticipantActionEllipsis
55 68
                 aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') }
56 69
                 onClick = { onContextMenu } />

+ 10
- 2
react/features/participants-pane/components/MeetingParticipantList.js Parādīt failu

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

+ 28
- 23
react/features/participants-pane/components/ParticipantItem.js Parādīt failu

@@ -13,10 +13,11 @@ import {
13 13
     IconMicrophoneEmptySlash
14 14
 } from '../../base/icons';
15 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 18
 import { RaisedHandIndicator } from './RaisedHandIndicator';
19 19
 import {
20
+    ColoredIcon,
20 21
     ParticipantActionsHover,
21 22
     ParticipantActionsPermanent,
22 23
     ParticipantContainer,
@@ -30,52 +31,56 @@ import {
30 31
  * Participant actions component mapping depending on trigger type.
31 32
  */
32 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 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 50
         <Icon
48 51
             size = { 16 }
49 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 65
  * Icon mapping for possible participant video states.
61 66
  */
62 67
 const VideoStateIcons = {
63
-    [MediaState.ForceMuted]: (
68
+    [MEDIA_STATE.FORCE_MUTED]: (
64 69
         <Icon
65 70
             size = { 16 }
66 71
             src = { IconCameraEmptyDisabled } />
67 72
     ),
68
-    [MediaState.Muted]: (
73
+    [MEDIA_STATE.MUTED]: (
69 74
         <Icon
70 75
             size = { 16 }
71 76
             src = { IconCameraEmptyDisabled } />
72 77
     ),
73
-    [MediaState.Unmuted]: (
78
+    [MEDIA_STATE.UNMUTED]: (
74 79
         <Icon
75 80
             size = { 16 }
76 81
             src = { IconCameraEmpty } />
77 82
     ),
78
-    [MediaState.None]: null
83
+    [MEDIA_STATE.NONE]: null
79 84
 };
80 85
 
81 86
 type Props = {
@@ -88,7 +93,7 @@ type Props = {
88 93
     /**
89 94
      * Media state for audio
90 95
      */
91
-    audioMuteState: MediaState,
96
+    audioMediaState: MediaState,
92 97
 
93 98
     /**
94 99
      * React children
@@ -125,9 +130,9 @@ export const ParticipantItem = ({
125 130
     children,
126 131
     isHighlighted,
127 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 136
     name,
132 137
     participant: p
133 138
 }: Props) => {
@@ -155,7 +160,7 @@ export const ParticipantItem = ({
155 160
                 <ParticipantStates>
156 161
                     {p.raisedHand && <RaisedHandIndicator />}
157 162
                     {VideoStateIcons[videoMuteState]}
158
-                    {AudioStateIcons[audioMuteState]}
163
+                    {AudioStateIcons[audioMediaState]}
159 164
                 </ParticipantStates>
160 165
             </ParticipantContent>
161 166
         </ParticipantContainer>

+ 59
- 0
react/features/participants-pane/components/ParticipantQuickAction.js Parādīt failu

@@ -0,0 +1,59 @@
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 Parādīt failu

@@ -1,17 +1,22 @@
1 1
 // @flow
2 2
 
3
-import React, { useCallback } from 'react';
3
+import React, { useCallback, useEffect, useState } from 'react';
4 4
 import { useTranslation } from 'react-i18next';
5 5
 import { useDispatch, useSelector } from 'react-redux';
6 6
 import { ThemeProvider } from 'styled-components';
7 7
 
8 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 14
 import { MuteEveryoneDialog } from '../../video-menu/components/';
11 15
 import { close } from '../actions';
12
-import { classList, getParticipantsPaneOpen } from '../functions';
16
+import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions';
13 17
 import theme from '../theme.json';
14 18
 
19
+import { FooterContextMenu } from './FooterContextMenu';
15 20
 import { LobbyParticipantList } from './LobbyParticipantList';
16 21
 import { MeetingParticipantList } from './MeetingParticipantList';
17 22
 import {
@@ -20,6 +25,8 @@ import {
20 25
     Container,
21 26
     Footer,
22 27
     FooterButton,
28
+    FooterEllipsisButton,
29
+    FooterEllipsisContainer,
23 30
     Header
24 31
 } from './styled';
25 32
 
@@ -27,6 +34,11 @@ export const ParticipantsPane = () => {
27 34
     const dispatch = useDispatch();
28 35
     const paneOpen = useSelector(getParticipantsPaneOpen);
29 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 42
     const { t } = useTranslation();
31 43
 
32 44
     const closePane = useCallback(() => dispatch(close(), [ dispatch ]));
@@ -38,13 +50,23 @@ export const ParticipantsPane = () => {
38 50
     }, [ closePane ]);
39 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 67
     return (
42 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 70
                 <div className = 'participants_pane-content'>
49 71
                     <Header>
50 72
                         <Close
@@ -64,6 +86,14 @@ export const ParticipantsPane = () => {
64 86
                             <FooterButton onClick = { muteAll }>
65 87
                                 {t('participantsPane.actions.muteAll')}
66 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 97
                         </Footer>
68 98
                     )}
69 99
                 </div>

+ 14
- 3
react/features/participants-pane/components/styled.js Parādīt failu

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

+ 34
- 8
react/features/participants-pane/constants.js Parādīt failu

@@ -1,22 +1,48 @@
1
+// @flow
2
+
1 3
 /**
2 4
  * Reducer key for the feature.
3 5
  */
4 6
 export const REDUCER_KEY = 'features/participants-pane';
5 7
 
8
+export type ActionTrigger = 'Hover' | 'Permanent'
9
+
6 10
 /**
7 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 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 Parādīt failu

@@ -1,8 +1,20 @@
1
+// @flow
1 2
 
3
+import {
4
+    isParticipantApproved,
5
+    isEnabledFromState,
6
+    isLocalParticipantApprovedFromState
7
+} from '../av-moderation/functions';
2 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 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 20
  * Generates a class attribute value.
@@ -10,7 +22,7 @@ import { REDUCER_KEY } from './constants';
10 22
  * @param {Iterable<string>} args - String iterable.
11 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,7 +32,7 @@ export const classList = (...args) => args.filter(Boolean).join(' ');
20 32
  * @param {StyledComponentClass} component - Styled component reference.
21 33
  * @returns {Element|null} Ancestor.
22 34
  */
23
-export const findStyledAncestor = (target, component) => {
35
+export const findStyledAncestor = (target: Object, component: any) => {
24 36
     if (!target || target.matches(`.${component.styledComponentId}`)) {
25 37
         return target;
26 38
     }
@@ -28,6 +40,50 @@ export const findStyledAncestor = (target, component) => {
28 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 88
  * Get a style property from a style declaration as a float.
33 89
  *
@@ -35,7 +91,7 @@ export const findStyledAncestor = (target, component) => {
35 91
  * @param {string} name - Property name.
36 92
  * @returns {number} Float value.
37 93
  */
38
-export const getFloatStyleProperty = (styles, name) =>
94
+export const getFloatStyleProperty = (styles: Object, name: string) =>
39 95
     parseFloat(styles.getPropertyValue(name));
40 96
 
41 97
 /**
@@ -44,7 +100,7 @@ export const getFloatStyleProperty = (styles, name) =>
44 100
  * @param {Element} element - Target element.
45 101
  * @returns {number} Computed height.
46 102
  */
47
-export const getComputedOuterHeight = element => {
103
+export const getComputedOuterHeight = (element: HTMLElement) => {
48 104
     const computedStyle = getComputedStyle(element);
49 105
 
50 106
     return element.offsetHeight
@@ -58,7 +114,7 @@ export const getComputedOuterHeight = element => {
58 114
  * @param {Object} state - Global state.
59 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 120
  * Is the participants pane open.
@@ -66,7 +122,29 @@ const getState = state => state[REDUCER_KEY];
66 122
  * @param {Object} state - Global state.
67 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 150
  * Returns true if the invite button should be rendered.
@@ -74,7 +152,7 @@ export const getParticipantsPaneOpen = state => Boolean(getState(state)?.isOpen)
74 152
  * @param {Object} state - Global state.
75 153
  * @returns {boolean}
76 154
  */
77
-export const shouldRenderInviteButton = state => {
155
+export const shouldRenderInviteButton = (state: Object) => {
78 156
     const { disableInviteFunctions } = toState(state)['features/base/config'];
79 157
     const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);
80 158
 

+ 4
- 1
react/features/participants-pane/theme.json Parādīt failu

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

+ 2
- 12
react/features/toolbox/components/native/RaiseHandButton.js Parādīt failu

@@ -11,7 +11,7 @@ import { translate } from '../../../base/i18n';
11 11
 import { IconRaisedHand } from '../../../base/icons';
12 12
 import {
13 13
     getLocalParticipant,
14
-    participantUpdated
14
+    raiseHand
15 15
 } from '../../../base/participants';
16 16
 import { connect } from '../../../base/redux';
17 17
 import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
@@ -78,17 +78,7 @@ class RaiseHandButton extends AbstractButton<Props, *> {
78 78
 
79 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 Parādīt failu

@@ -32,7 +32,7 @@ import JitsiMeetJS from '../../../base/lib-jitsi-meet';
32 32
 import {
33 33
     getLocalParticipant,
34 34
     getParticipants,
35
-    participantUpdated
35
+    raiseHand
36 36
 } from '../../../base/participants';
37 37
 import { connect } from '../../../base/redux';
38 38
 import { OverflowMenuItem } from '../../../base/toolbox/components';
@@ -522,17 +522,7 @@ class Toolbox extends Component<Props> {
522 522
         const { _localParticipantID, _raisedHand } = this.props;
523 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 527
         APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus);
538 528
     }
@@ -1276,6 +1266,7 @@ class Toolbox extends Component<Props> {
1276 1266
                     <ToolbarButton
1277 1267
                         accessibilityLabel = { t('toolbar.accessibilityLabel.participants') }
1278 1268
                         icon = { IconParticipants }
1269
+                        key = 'participants'
1279 1270
                         onClick = { this._onToolbarToggleParticipantsPane }
1280 1271
                         toggled = { this.props._participantsPaneOpen }
1281 1272
                         tooltip = { t('toolbar.participants') } />)

+ 11
- 1
react/features/video-menu/actions.any.js Parādīt failu

@@ -10,6 +10,8 @@ import {
10 10
     sendAnalytics,
11 11
     VIDEO_MUTE
12 12
 } from '../analytics';
13
+import { showModeratedNotification } from '../av-moderation/actions';
14
+import { shouldShowModeratedNotification } from '../av-moderation/functions';
13 15
 import {
14 16
     MEDIA_TYPE,
15 17
     setAudioMuted,
@@ -33,7 +35,7 @@ const logger = getLogger(__filename);
33 35
  * @returns {Function}
34 36
  */
35 37
 export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
36
-    return (dispatch: Dispatch<any>) => {
38
+    return (dispatch: Dispatch<any>, getState: Function) => {
37 39
         const isAudio = mediaType === MEDIA_TYPE.AUDIO;
38 40
 
39 41
         if (!isAudio && mediaType !== MEDIA_TYPE.VIDEO) {
@@ -41,6 +43,14 @@ export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
41 43
 
42 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 54
         sendAnalytics(createToolbarEvent(isAudio ? AUDIO_MUTE : VIDEO_MUTE, { enable }));
45 55
         dispatch(isAudio ? setAudioMuted(enable, /* ensureTrack */ true)
46 56
             : setVideoMuted(enable, mediaType, VIDEO_MUTISM_AUTHORITY.USER, /* ensureTrack */ true));

+ 6
- 4
resources/prosody-plugins/mod_av_moderation_component.lua Parādīt failu

@@ -132,8 +132,10 @@ function on_message(event)
132 132
                     module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
133 133
                     return true;
134 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 139
                     room.av_moderation[mediaType] = {};
138 140
                     room.av_moderation_actors[mediaType] = occupant.nick;
139 141
                 end
@@ -147,10 +149,10 @@ function on_message(event)
147 149
                     room.av_moderation_actors[mediaType] = nil;
148 150
 
149 151
                     -- clears room.av_moderation if empty
150
-                    local is_empty = false;
152
+                    local is_empty = true;
151 153
                     for key,_ in pairs(room.av_moderation) do
152 154
                         if room.av_moderation[key] then
153
-                            is_empty = true;
155
+                            is_empty = false;
154 156
                         end
155 157
                     end
156 158
                     if is_empty then

Notiek ielāde…
Atcelt
Saglabāt