Browse Source

fix(av-moderation) Advanced moderation improvements (#9935)

* Update moderation in effect notifications

Only display one notification for each media type. Display notification for keyboard shortcuts as well

* Update muted remotely notification

Display name of moderator in the notification

* Fix indentation on moderation menu

* Update text for video moderation

* Added moderator label in participant pane

* Update microphone icon in participant list

For participants that speak, or are noisy, but aren't dominant speaker, the icon in the participant list will look the same as the dominant speaker icon but will not change their position in the list

* Added sound for asked to unmute notification

* Code review changes

* Code review changes

Use simple var instead of function for audio media state

* Move constants to constants file

* Moved constants from notifications to av-moderation
master
robertpin 3 years ago
parent
commit
ab366b9d94
No account linked to committer's email address

+ 15
- 2
conference.js View File

@@ -24,6 +24,8 @@ import {
24 24
     redirectToStaticPage,
25 25
     reloadWithStoredParams
26 26
 } from './react/features/app/actions';
27
+import { showModeratedNotification } from './react/features/av-moderation/actions';
28
+import { shouldShowModeratedNotification } from './react/features/av-moderation/functions';
27 29
 import {
28 30
     AVATAR_URL_COMMAND,
29 31
     EMAIL_COMMAND,
@@ -120,7 +122,7 @@ import {
120 122
     maybeOpenFeedbackDialog,
121 123
     submitFeedback
122 124
 } from './react/features/feedback';
123
-import { showNotification } from './react/features/notifications';
125
+import { isModerationNotificationDisplayed, showNotification } from './react/features/notifications';
124 126
 import { mediaPermissionPromptVisibilityChanged, toggleSlowGUMOverlay } from './react/features/overlay';
125 127
 import { suspendDetected } from './react/features/power-monitor';
126 128
 import {
@@ -871,13 +873,24 @@ export default {
871 873
      * dialogs in case of media permissions error.
872 874
      */
873 875
     muteAudio(mute, showUI = true) {
876
+        const state = APP.store.getState();
877
+
874 878
         if (!mute
875
-                && isUserInteractionRequiredForUnmute(APP.store.getState())) {
879
+            && isUserInteractionRequiredForUnmute(state)) {
876 880
             logger.error('Unmuting audio requires user interaction');
877 881
 
878 882
             return;
879 883
         }
880 884
 
885
+        // check for A/V Moderation when trying to unmute
886
+        if (!mute && shouldShowModeratedNotification(MEDIA_TYPE.AUDIO, state)) {
887
+            if (!isModerationNotificationDisplayed(MEDIA_TYPE.AUDIO, state)) {
888
+                APP.store.dispatch(showModeratedNotification(MEDIA_TYPE.AUDIO));
889
+            }
890
+
891
+            return;
892
+        }
893
+
881 894
         // Not ready to modify track's state yet
882 895
         if (!this._localTracksInitialized) {
883 896
             // This will only modify base/media.audio.muted which is then synced

+ 1
- 0
config.js View File

@@ -737,6 +737,7 @@ var config = {
737 737
 
738 738
     // Array<string> of disabled sounds.
739 739
     // Possible values:
740
+    // - 'ASKED_TO_UNMUTE_SOUND'
740 741
     // - 'E2EE_OFF_SOUND'
741 742
     // - 'E2EE_ON_SOUND'
742 743
     // - 'INCOMING_MSG_SOUND'

+ 3
- 3
lang/main.json View File

@@ -566,9 +566,9 @@
566 566
         "moderator": "You're now a moderator",
567 567
         "muted": "You have started the conversation muted.",
568 568
         "mutedTitle": "You're muted!",
569
-        "mutedRemotelyTitle": "You've been muted by the moderator",
569
+        "mutedRemotelyTitle": "You've been muted by {{moderator}}",
570 570
         "mutedRemotelyDescription": "You can always unmute when you're ready to speak. Mute back when you're done to keep noise away from the meeting.",
571
-        "videoMutedRemotelyTitle": "Your camera has been turned off by the moderator",
571
+        "videoMutedRemotelyTitle": "Your camera has been turned off by {{moderator}}",
572 572
         "videoMutedRemotelyDescription": "You can always turn it on again.",
573 573
         "passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) removed by another participant",
574 574
         "passwordSetRemotely": "$t(lockRoomPasswordUppercase) set by another participant",
@@ -623,7 +623,7 @@
623 623
             "stopEveryonesVideo": "Stop everyone's video",
624 624
             "stopVideo": "Stop video",
625 625
             "unblockEveryoneMicCamera": "Unblock everyone's mic and camera",
626
-            "videoModeration": "Start video"
626
+            "videoModeration": "Start their video"
627 627
         }
628 628
     },
629 629
     "passwordSetRemotely": "Set by another participant",

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

@@ -17,3 +17,15 @@ export const MEDIA_TYPE_TO_PENDING_STORE_KEY: {[key: MediaType]: string} = {
17 17
     [MEDIA_TYPE.AUDIO]: 'pendingAudio',
18 18
     [MEDIA_TYPE.VIDEO]: 'pendingVideo'
19 19
 };
20
+
21
+export const ASKED_TO_UNMUTE_SOUND_ID = 'ASKED_TO_UNMUTE_SOUND';
22
+
23
+export const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation';
24
+export const VIDEO_MODERATION_NOTIFICATION_ID = 'video-moderation';
25
+export const CS_MODERATION_NOTIFICATION_ID = 'screensharing-moderation';
26
+
27
+export const MODERATION_NOTIFICATIONS = {
28
+    [MEDIA_TYPE.AUDIO]: AUDIO_MODERATION_NOTIFICATION_ID,
29
+    [MEDIA_TYPE.VIDEO]: VIDEO_MODERATION_NOTIFICATION_ID,
30
+    [MEDIA_TYPE.PRESENTER]: CS_MODERATION_NOTIFICATION_ID
31
+};

+ 17
- 4
react/features/av-moderation/middleware.js View File

@@ -1,6 +1,7 @@
1 1
 // @flow
2 2
 import { batch } from 'react-redux';
3 3
 
4
+import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app';
4 5
 import { getConferenceState } from '../base/conference';
5 6
 import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
6 7
 import { MEDIA_TYPE } from '../base/media';
@@ -13,6 +14,7 @@ import {
13 14
     raiseHand
14 15
 } from '../base/participants';
15 16
 import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
17
+import { playSound, registerSound, unregisterSound } from '../base/sounds';
16 18
 import {
17 19
     hideNotification,
18 20
     showNotification
@@ -35,21 +37,31 @@ import {
35 37
     participantApproved,
36 38
     participantPendingAudio
37 39
 } from './actions';
40
+import {
41
+    ASKED_TO_UNMUTE_SOUND_ID, AUDIO_MODERATION_NOTIFICATION_ID,
42
+    CS_MODERATION_NOTIFICATION_ID,
43
+    VIDEO_MODERATION_NOTIFICATION_ID
44
+} from './constants';
38 45
 import {
39 46
     isEnabledFromState,
40 47
     isParticipantApproved,
41 48
     isParticipantPending
42 49
 } from './functions';
43
-
44
-const VIDEO_MODERATION_NOTIFICATION_ID = 'video-moderation';
45
-const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation';
46
-const CS_MODERATION_NOTIFICATION_ID = 'video-moderation';
50
+import { ASKED_TO_UNMUTE_FILE } from './sounds';
47 51
 
48 52
 MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
49 53
     const { type } = action;
50 54
     const { conference } = getConferenceState(getState());
51 55
 
52 56
     switch (type) {
57
+    case APP_WILL_MOUNT: {
58
+        dispatch(registerSound(ASKED_TO_UNMUTE_SOUND_ID, ASKED_TO_UNMUTE_FILE));
59
+        break;
60
+    }
61
+    case APP_WILL_UNMOUNT: {
62
+        dispatch(unregisterSound(ASKED_TO_UNMUTE_SOUND_ID));
63
+        break;
64
+    }
53 65
     case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: {
54 66
         let descriptionKey;
55 67
         let titleKey;
@@ -160,6 +172,7 @@ StateListenerRegistry.register(
160 172
                         customActionNameKey: 'notify.unmute',
161 173
                         customActionHandler: () => dispatch(muteLocal(false, MEDIA_TYPE.AUDIO))
162 174
                     }));
175
+                    dispatch(playSound(ASKED_TO_UNMUTE_SOUND_ID));
163 176
                 }
164 177
             });
165 178
 

+ 6
- 0
react/features/av-moderation/sounds.js View File

@@ -0,0 +1,6 @@
1
+/**
2
+ * The name of the bundled audio file which will be played for the raise hand sound.
3
+ *
4
+ * @type {string}
5
+ */
6
+export const ASKED_TO_UNMUTE_FILE = 'asked-unmute.mp3';

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

@@ -4,6 +4,7 @@ import type { Dispatch } from 'redux';
4 4
 
5 5
 import { showModeratedNotification } from '../../av-moderation/actions';
6 6
 import { shouldShowModeratedNotification } from '../../av-moderation/functions';
7
+import { isModerationNotificationDisplayed } from '../../notifications';
7 8
 
8 9
 import {
9 10
     SET_AUDIO_MUTED,
@@ -113,7 +114,9 @@ export function setVideoMuted(
113 114
 
114 115
         // check for A/V Moderation when trying to unmute
115 116
         if (!muted && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, state)) {
116
-            ensureTrack && dispatch(showModeratedNotification(MEDIA_TYPE.VIDEO));
117
+            if (!isModerationNotificationDisplayed(MEDIA_TYPE.VIDEO, state)) {
118
+                ensureTrack && dispatch(showModeratedNotification(MEDIA_TYPE.VIDEO));
119
+            }
117 120
 
118 121
             return;
119 122
         }

+ 5
- 2
react/features/base/participants/actions.js View File

@@ -466,7 +466,7 @@ export function participantUpdated(participant = {}) {
466 466
  * @returns {Promise}
467 467
  */
468 468
 export function participantMutedUs(participant, track) {
469
-    return dispatch => {
469
+    return (dispatch, getState) => {
470 470
         if (!participant) {
471 471
             return;
472 472
         }
@@ -474,7 +474,10 @@ export function participantMutedUs(participant, track) {
474 474
         const isAudio = track.isAudioTrack();
475 475
 
476 476
         dispatch(showNotification({
477
-            titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle'
477
+            titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle',
478
+            titleArguments: {
479
+                moderator: getParticipantDisplayName(getState, participant.getId())
480
+            }
478 481
         }));
479 482
     };
480 483
 }

+ 4
- 2
react/features/base/tracks/middleware.js View File

@@ -3,7 +3,7 @@
3 3
 import UIEvents from '../../../../service/UI/UIEvents';
4 4
 import { showModeratedNotification } from '../../av-moderation/actions';
5 5
 import { shouldShowModeratedNotification } from '../../av-moderation/functions';
6
-import { hideNotification } from '../../notifications';
6
+import { hideNotification, isModerationNotificationDisplayed } from '../../notifications';
7 7
 import { isPrejoinPageVisible } from '../../prejoin/functions';
8 8
 import { getAvailableDevices } from '../devices/actions';
9 9
 import {
@@ -142,7 +142,9 @@ MiddlewareRegistry.register(store => next => action => {
142 142
             // check for A/V Moderation when trying to start screen sharing
143 143
             if ((action.enabled || action.enabled === undefined)
144 144
                 && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, store.getState())) {
145
-                store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER));
145
+                if (!isModerationNotificationDisplayed(MEDIA_TYPE.PRESENTER, store.getState())) {
146
+                    store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER));
147
+                }
146 148
 
147 149
                 return;
148 150
             }

+ 17
- 0
react/features/notifications/functions.js View File

@@ -1,5 +1,7 @@
1 1
 // @flow
2 2
 
3
+import { MODERATION_NOTIFICATIONS } from '../av-moderation/constants';
4
+import { MEDIA_TYPE } from '../base/media';
3 5
 import { toState } from '../base/redux';
4 6
 
5 7
 declare var interfaceConfig: Object;
@@ -26,3 +28,18 @@ export function areThereNotifications(stateful: Object | Function) {
26 28
 export function joinLeaveNotificationsDisabled() {
27 29
     return Boolean(typeof interfaceConfig !== 'undefined' && interfaceConfig?.DISABLE_JOIN_LEAVE_NOTIFICATIONS);
28 30
 }
31
+
32
+/**
33
+ * Returns whether or not the moderation notification for the given type is displayed.
34
+ *
35
+ * @param {MEDIA_TYPE} mediaType - The media type to check.
36
+ * @param {Object | Function} stateful - The redux store state.
37
+ * @returns {boolean}
38
+ */
39
+export function isModerationNotificationDisplayed(mediaType: MEDIA_TYPE, stateful: Object | Function) {
40
+    const state = toState(stateful);
41
+
42
+    const { notifications } = state['features/notifications'];
43
+
44
+    return Boolean(notifications.find(n => n.uid === MODERATION_NOTIFICATIONS[mediaType]));
45
+}

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

@@ -55,10 +55,10 @@ const useStyles = makeStyles(() => {
55 55
         },
56 56
         text: {
57 57
             color: '#C2C2C2',
58
-            padding: '10px 16px 10px 52px'
58
+            padding: '10px 16px'
59 59
         },
60 60
         paddedAction: {
61
-            marginLeft: '36px;'
61
+            marginLeft: '36px'
62 62
         }
63 63
     };
64 64
 });

+ 59
- 6
react/features/participants-pane/components/web/MeetingParticipantItem.js View File

@@ -1,15 +1,23 @@
1 1
 // @flow
2 2
 
3
-import React from 'react';
3
+import React, { useCallback, useEffect, useState } from 'react';
4 4
 
5
+import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
6
+import { MEDIA_TYPE } from '../../../base/media';
5 7
 import {
6 8
     getLocalParticipant,
7 9
     getParticipantByIdOrUndefined,
8
-    getParticipantDisplayName
10
+    getParticipantDisplayName,
11
+    isParticipantModerator
9 12
 } from '../../../base/participants';
10 13
 import { connect } from '../../../base/redux';
11
-import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
12
-import { ACTION_TRIGGER, type MediaState } from '../../constants';
14
+import {
15
+    getLocalAudioTrack,
16
+    getTrackByMediaTypeAndParticipant,
17
+    isParticipantAudioMuted,
18
+    isParticipantVideoMuted
19
+} from '../../../base/tracks';
20
+import { ACTION_TRIGGER, type MediaState, MEDIA_STATE } from '../../constants';
13 21
 import {
14 22
     getParticipantAudioMediaState,
15 23
     getParticipantVideoMediaState,
@@ -27,6 +35,11 @@ type Props = {
27 35
      */
28 36
     _audioMediaState: MediaState,
29 37
 
38
+    /**
39
+     * The audio track related to the participant.
40
+     */
41
+    _audioTrack: ?Object,
42
+
30 43
     /**
31 44
      * Media state for video.
32 45
      */
@@ -136,6 +149,7 @@ type Props = {
136 149
  */
137 150
 function MeetingParticipantItem({
138 151
     _audioMediaState,
152
+    _audioTrack,
139 153
     _videoMediaState,
140 154
     _displayName,
141 155
     _local,
@@ -155,12 +169,46 @@ function MeetingParticipantItem({
155 169
     participantActionEllipsisLabel,
156 170
     youText
157 171
 }: Props) {
172
+
173
+    const [ hasAudioLevels, setHasAudioLevel ] = useState(false);
174
+    const [ registeredEvent, setRegisteredEvent ] = useState(false);
175
+
176
+    const _updateAudioLevel = useCallback(level => {
177
+        const audioLevel = typeof level === 'number' && !isNaN(level)
178
+            ? level : 0;
179
+
180
+        setHasAudioLevel(audioLevel > 0.009);
181
+    }, []);
182
+
183
+    useEffect(() => {
184
+        if (_audioTrack && !registeredEvent) {
185
+            const { jitsiTrack } = _audioTrack;
186
+
187
+            if (jitsiTrack) {
188
+                jitsiTrack.on(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, _updateAudioLevel);
189
+                setRegisteredEvent(true);
190
+            }
191
+        }
192
+
193
+        return () => {
194
+            if (_audioTrack && registeredEvent) {
195
+                const { jitsiTrack } = _audioTrack;
196
+
197
+                jitsiTrack && jitsiTrack.off(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, _updateAudioLevel);
198
+            }
199
+        };
200
+    }, [ _audioTrack ]);
201
+
202
+    const audioMediaState = _audioMediaState === MEDIA_STATE.UNMUTED && hasAudioLevels
203
+        ? MEDIA_STATE.DOMINANT_SPEAKER : _audioMediaState;
204
+
158 205
     return (
159 206
         <ParticipantItem
160 207
             actionsTrigger = { ACTION_TRIGGER.HOVER }
161
-            audioMediaState = { _audioMediaState }
208
+            audioMediaState = { audioMediaState }
162 209
             displayName = { _displayName }
163 210
             isHighlighted = { isHighlighted }
211
+            isModerator = { isParticipantModerator(_participant) }
164 212
             local = { _local }
165 213
             onLeave = { onLeave }
166 214
             openDrawerForParticipant = { openDrawerForParticipant }
@@ -181,7 +229,7 @@ function MeetingParticipantItem({
181 229
                     <ParticipantActionEllipsis
182 230
                         aria-label = { participantActionEllipsisLabel }
183 231
                         onClick = { onContextMenu } />
184
-                 </>
232
+                </>
185 233
             }
186 234
 
187 235
             {!overflowDrawer && _localVideoOwner && _participant?.isFakeParticipant && (
@@ -214,8 +262,13 @@ function _mapStateToProps(state, ownProps): Object {
214 262
     const _videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state);
215 263
     const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, state);
216 264
 
265
+    const tracks = state['features/base/tracks'];
266
+    const _audioTrack = participantID === localParticipantId
267
+        ? getLocalAudioTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, participantID);
268
+
217 269
     return {
218 270
         _audioMediaState,
271
+        _audioTrack,
219 272
         _videoMediaState,
220 273
         _displayName: getParticipantDisplayName(state, participant?.id),
221 274
         _local: Boolean(participant?.local),

+ 29
- 7
react/features/participants-pane/components/web/ParticipantItem.js View File

@@ -3,6 +3,7 @@
3 3
 import React, { type Node, useCallback } from 'react';
4 4
 
5 5
 import { Avatar } from '../../../base/avatar';
6
+import { translate } from '../../../base/i18n';
6 7
 import {
7 8
     ACTION_TRIGGER,
8 9
     AudioStateIcons,
@@ -14,10 +15,12 @@ import {
14 15
 
15 16
 import { RaisedHandIndicator } from './RaisedHandIndicator';
16 17
 import {
18
+    ModeratorLabel,
17 19
     ParticipantActionsHover,
18 20
     ParticipantActionsPermanent,
19 21
     ParticipantContainer,
20 22
     ParticipantContent,
23
+    ParticipantDetailsContainer,
21 24
     ParticipantName,
22 25
     ParticipantNameContainer,
23 26
     ParticipantStates
@@ -58,6 +61,11 @@ type Props = {
58 61
      */
59 62
     isHighlighted?: boolean,
60 63
 
64
+    /**
65
+     * Whether or not the participant is a moderator.
66
+     */
67
+    isModerator: boolean,
68
+
61 69
     /**
62 70
      * True if the participant is local.
63 71
      */
@@ -93,6 +101,11 @@ type Props = {
93 101
      */
94 102
     videoMediaState: MediaState,
95 103
 
104
+    /**
105
+     * Invoked to obtain translated strings.
106
+     */
107
+    t: Function,
108
+
96 109
     /**
97 110
      * The translated "you" text.
98 111
      */
@@ -105,9 +118,10 @@ type Props = {
105 118
  * @param {Props} props - The props of the component.
106 119
  * @returns {ReactNode}
107 120
  */
108
-export default function ParticipantItem({
121
+function ParticipantItem({
109 122
     children,
110 123
     isHighlighted,
124
+    isModerator,
111 125
     onLeave,
112 126
     actionsTrigger = ACTION_TRIGGER.HOVER,
113 127
     audioMediaState = MEDIA_STATE.NONE,
@@ -118,6 +132,7 @@ export default function ParticipantItem({
118 132
     openDrawerForParticipant,
119 133
     overflowDrawer,
120 134
     raisedHand,
135
+    t,
121 136
     youText
122 137
 }: Props) {
123 138
     const ParticipantActions = Actions[actionsTrigger];
@@ -140,12 +155,17 @@ export default function ParticipantItem({
140 155
                 participantId = { participantID }
141 156
                 size = { 32 } />
142 157
             <ParticipantContent>
143
-                <ParticipantNameContainer>
144
-                    <ParticipantName>
145
-                        { displayName }
146
-                    </ParticipantName>
147
-                    { local ? <span>&nbsp;({ youText })</span> : null }
148
-                </ParticipantNameContainer>
158
+                <ParticipantDetailsContainer>
159
+                    <ParticipantNameContainer>
160
+                        <ParticipantName>
161
+                            { displayName }
162
+                        </ParticipantName>
163
+                        { local ? <span>&nbsp;({ youText })</span> : null }
164
+                    </ParticipantNameContainer>
165
+                    {isModerator && <ModeratorLabel>
166
+                        {t('videothumbnail.moderator')}
167
+                    </ModeratorLabel>}
168
+                </ParticipantDetailsContainer>
149 169
                 { !local && <ParticipantActions children = { children } /> }
150 170
                 <ParticipantStates>
151 171
                     { raisedHand && <RaisedHandIndicator /> }
@@ -156,3 +176,5 @@ export default function ParticipantItem({
156 176
         </ParticipantContainer>
157 177
     );
158 178
 }
179
+
180
+export default translate(ParticipantItem);

+ 15
- 0
react/features/participants-pane/components/web/styled.js View File

@@ -282,6 +282,7 @@ export const ParticipantContainer = styled.div`
282 282
   color: white;
283 283
   display: flex;
284 284
   font-size: 13px;
285
+  font-weight: normal;
285 286
   height: ${props => props.theme.participantItemHeight}px;
286 287
   margin: 0 -${props => props.theme.panePadding}px;
287 288
   padding-left: ${props => props.theme.panePadding}px;
@@ -341,10 +342,24 @@ export const ParticipantName = styled.div`
341 342
 `;
342 343
 
343 344
 export const ParticipantNameContainer = styled.div`
345
+  display: flex;
346
+  flex: 1;
347
+  overflow: hidden;
348
+`;
349
+
350
+export const ModeratorLabel = styled.div`
351
+  font-size: 12px;
352
+  line-height: 16px;
353
+  color: #858585;
354
+`;
355
+
356
+export const ParticipantDetailsContainer = styled.div`
344 357
   display: flex;
345 358
   flex: 1;
346 359
   margin-right: 8px;
347 360
   overflow: hidden;
361
+  flex-direction: column;
362
+  justify-content: flex-start;
348 363
 `;
349 364
 
350 365
 export const RaisedHandIndicatorBackground = styled.div`

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

@@ -23,6 +23,7 @@ import {
23 23
     getRemoteParticipants,
24 24
     muteRemoteParticipant
25 25
 } from '../base/participants';
26
+import { isModerationNotificationDisplayed } from '../notifications';
26 27
 
27 28
 declare var APP: Object;
28 29
 
@@ -47,7 +48,9 @@ export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
47 48
 
48 49
         // check for A/V Moderation when trying to unmute
49 50
         if (!enable && shouldShowModeratedNotification(MEDIA_TYPE.AUDIO, getState())) {
50
-            dispatch(showModeratedNotification(MEDIA_TYPE.AUDIO));
51
+            if (!isModerationNotificationDisplayed(MEDIA_TYPE.AUDIO, getState())) {
52
+                dispatch(showModeratedNotification(MEDIA_TYPE.AUDIO));
53
+            }
51 54
 
52 55
             return;
53 56
         }

BIN
sounds/asked-unmute.mp3 View File


Loading…
Cancel
Save