From 1dc8bfa63124a868c36bfa12077132204801454b Mon Sep 17 00:00:00 2001 From: robertpin Date: Fri, 10 Sep 2021 14:05:16 +0300 Subject: [PATCH] feat(av-moderation) Updated Advanced moderation (#9875) Co-authored-by: Vlad Piersec --- css/_participants-pane.scss | 2 +- css/main.scss | 1 + css/modals/mute/_mute-dialog.scss | 19 ++ eslint | 0 lang/main.json | 52 +-- package.json | 1 + react/features/av-moderation/actionTypes.js | 30 +- react/features/av-moderation/actions.js | 60 +++- .../AudioModerationNotifications.js | 38 --- react/features/av-moderation/middleware.js | 60 ++-- .../base/icons/svg/camera-empty-disabled.svg | 2 +- react/features/base/media/middleware.js | 23 ++ .../features/base/participants/actionTypes.js | 12 + react/features/base/participants/actions.js | 28 +- react/features/base/participants/functions.js | 32 +- .../features/base/participants/middleware.js | 63 +++- react/features/base/participants/reducer.js | 10 +- react/features/base/tracks/middleware.js | 12 +- react/features/chat/actions.web.js | 22 ++ .../conference/components/web/Conference.js | 2 - react/features/notifications/actionTypes.js | 10 + react/features/notifications/actions.js | 14 + react/features/notifications/middleware.js | 39 +-- react/features/notifications/reducer.js | 9 + .../components/FooterContextMenu.js | 92 +++-- .../components/web/LobbyParticipantItem.js | 36 +- .../components/web/LobbyParticipantItems.js | 47 +++ .../components/web/LobbyParticipantList.js | 72 ---- .../components/web/LobbyParticipants.js | 134 ++++++++ .../web/MeetingParticipantContextMenu.js | 321 ++++++++++++------ .../components/web/MeetingParticipantItem.js | 82 +++-- .../components/web/MeetingParticipantItems.js | 103 ++++++ ...ticipantList.js => MeetingParticipants.js} | 99 +++--- .../components/web/ParticipantItem.js | 28 +- .../components/web/ParticipantsPane.js | 44 ++- .../participants-pane/components/web/index.js | 2 - .../components/web/styled.js | 28 +- react/features/participants-pane/constants.js | 1 + react/features/participants-pane/functions.js | 26 +- react/features/participants-pane/hooks.js | 46 +++ react/features/talk-while-muted/middleware.js | 11 +- react/features/toolbox/functions.web.js | 11 + .../AbstractGrantModeratorDialog.js | 21 +- .../components/AbstractMuteButton.js | 6 +- .../components/AbstractMuteEveryoneDialog.js | 45 ++- .../AbstractMuteEveryonesVideoDialog.js | 49 ++- .../AbstractMuteRemoteParticipantDialog.js | 4 +- ...stractMuteRemoteParticipantsVideoDialog.js | 4 +- .../native/MuteRemoteParticipantDialog.js | 32 -- .../video-menu/components/native/index.js | 1 - .../components/web/GrantModeratorDialog.js | 6 +- .../components/web/MuteEveryoneDialog.js | 39 ++- .../web/MuteEveryonesVideoDialog.js | 36 +- .../web/MuteRemoteParticipantDialog.js | 41 --- .../video-menu/components/web/index.js | 1 - 55 files changed, 1432 insertions(+), 577 deletions(-) create mode 100644 css/modals/mute/_mute-dialog.scss create mode 100644 eslint delete mode 100644 react/features/av-moderation/components/AudioModerationNotifications.js create mode 100644 react/features/participants-pane/components/web/LobbyParticipantItems.js delete mode 100644 react/features/participants-pane/components/web/LobbyParticipantList.js create mode 100644 react/features/participants-pane/components/web/LobbyParticipants.js create mode 100644 react/features/participants-pane/components/web/MeetingParticipantItems.js rename react/features/participants-pane/components/web/{MeetingParticipantList.js => MeetingParticipants.js} (64%) create mode 100644 react/features/participants-pane/hooks.js delete mode 100644 react/features/video-menu/components/native/MuteRemoteParticipantDialog.js delete mode 100644 react/features/video-menu/components/web/MuteRemoteParticipantDialog.js diff --git a/css/_participants-pane.scss b/css/_participants-pane.scss index b566956428..619044ed00 100644 --- a/css/_participants-pane.scss +++ b/css/_participants-pane.scss @@ -29,7 +29,7 @@ margin: 8px 16px 8px 0; } -@media (max-width: 375px) { +@media (max-width: 580px) { .participants_pane { height: 100vh; height: -webkit-fill-available; diff --git a/css/main.scss b/css/main.scss index 98e2ebbadc..61fb78944e 100644 --- a/css/main.scss +++ b/css/main.scss @@ -98,6 +98,7 @@ $flagsImagePath: "../images/"; @import 'country-picker'; @import 'modals/invite/invite_more'; @import 'modals/security/security'; +@import 'modals/mute/mute-dialog'; @import 'e2ee'; @import 'responsive'; @import 'drawer'; diff --git a/css/modals/mute/_mute-dialog.scss b/css/modals/mute/_mute-dialog.scss new file mode 100644 index 0000000000..7868d35f22 --- /dev/null +++ b/css/modals/mute/_mute-dialog.scss @@ -0,0 +1,19 @@ +.mute-dialog { + .separator-line { + margin: 24px 0 24px -20px; + padding: 0 20px; + width: 100%; + height: 1px; + background: #5E6D7A; + } + + .control-row { + display: flex; + justify-content: space-between; + margin-top: 15px; + + label { + font-size: 14px; + } + } +} diff --git a/eslint b/eslint new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lang/main.json b/lang/main.json index 691df014d1..56d2792b38 100644 --- a/lang/main.json +++ b/lang/main.json @@ -216,8 +216,8 @@ "embedMeeting": "Embed meeting", "error": "Error", "gracefulShutdown": "Our service is currently down for maintenance. Please try again later.", - "grantModeratorDialog": "Are you sure you want to make this participant a moderator?", - "grantModeratorTitle": "Grant moderator", + "grantModeratorDialog": "Are you sure you want to grant moderator rights to {{participantName}}?", + "grantModeratorTitle": "Grant moderator rights", "hideShareAudioHelper": "Don't show this dialog again", "IamHost": "I am the host", "incorrectRoomLockPassword": "Incorrect password", @@ -247,15 +247,19 @@ "micPermissionDeniedError": "You have not granted permission to use your microphone. You can still join the conference but others won't hear you. Use the camera button in the address bar to fix this.", "micTimeoutError": "Could not start audio source. Timeout occured!", "micUnknownError": "Cannot use microphone for an unknown reason.", + "moderationAudioLabel": "Allow attendees to unmute themselves", + "moderationVideoLabel": "Allow attendees to start their video", "muteEveryoneElseDialog": "Once muted, you won't be able to unmute them, but they can unmute themselves at any time.", "muteEveryoneElseTitle": "Mute everyone except {{whom}}?", - "muteEveryoneDialog": "Are you sure you want to mute everyone? You won't be able to unmute them, but they can unmute themselves at any time.", + "muteEveryoneDialog": "The participants can unmute themselves at any time.", + "muteEveryoneDialogModerationOn": "The participants can send a request to speak at any time.", "muteEveryoneTitle": "Mute everyone?", "muteEveryoneElsesVideoDialog": "Once the camera is disabled, you won't be able to turn it back on, but they can turn it back on at any time.", - "muteEveryoneElsesVideoTitle": "Disable everyone's camera except {{whom}}?", - "muteEveryonesVideoDialog": "Are you sure you want to disable everyone's camera? You won't be able to turn it back on, but they can turn it back on at any time.", + "muteEveryoneElsesVideoTitle": "Stop everyone's video except {{whom}}?", + "muteEveryonesVideoDialog": "The participants can turn on their video at any time.", + "muteEveryonesVideoDialogModerationOn": "The participants can send a request to turn on their video at any time.", "muteEveryonesVideoDialogOk": "Disable", - "muteEveryonesVideoTitle": "Disable everyone's camera?", + "muteEveryonesVideoTitle": "Stop everyone's video?", "muteEveryoneSelf": "yourself", "muteEveryoneStartMuted": "Everyone starts muted from now on", "muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.", @@ -263,7 +267,7 @@ "muteParticipantDialog": "Are you sure you want to mute this participant? You won't be able to unmute them, but they can unmute themselves at any time.", "muteParticipantsVideoDialog": "Are you sure you want to turn off this participant's camera? You won't be able to turn the camera back on, but they can turn it back on at any time.", "muteParticipantTitle": "Mute this participant?", - "muteParticipantsVideoButton": "Disable camera", + "muteParticipantsVideoButton": "Stop camera", "muteParticipantsVideoTitle": "Disable camera of this participant?", "muteParticipantsVideoBody": "You won't be able to turn the camera back on, but they can turn it back on at any time.", "noDropboxToken": "No valid Dropbox token", @@ -542,29 +546,30 @@ "lockRoomPasswordUppercase": "Password", "me": "me", "notify": { + "allowAction": "Allow", + "allowedUnmute": "You can unmute your microphone, start your camera or share your screen.", "connectedOneMember": "{{name}} joined the meeting", "connectedThreePlusMembers": "{{name}} and many others joined the meeting", "connectedTwoMembers": "{{first}} and {{second}} joined the meeting", "disconnected": "disconnected", "focus": "Conference focus", "focusFail": "{{component}} not available - retry in {{ms}} sec", - "grantedTo": "Moderator rights granted to {{to}}!", - "hostAskedUnmute": "The host would like you to unmute", + "hostAskedUnmute": "The moderator would like you to speak", "invitedOneMember": "{{name}} has been invited", "invitedThreePlusMembers": "{{name}} and {{count}} others have been invited", "invitedTwoMembers": "{{first}} and {{second}} have been invited", "kickParticipant": "{{kicked}} was kicked by {{kicker}}", "me": "Me", - "moderator": "Moderator rights granted!", + "moderator": "You're now a moderator", "muted": "You have started the conversation muted.", "mutedTitle": "You're muted!", - "mutedRemotelyTitle": "You have been muted by {{participantDisplayName}}!", + "mutedRemotelyTitle": "You've been muted by the moderator", "mutedRemotelyDescription": "You can always unmute when you're ready to speak. Mute back when you're done to keep noise away from the meeting.", - "videoMutedRemotelyTitle": "Your camera has been disabled by {{participantDisplayName}}!", + "videoMutedRemotelyTitle": "Your camera has been turned off by the moderator", "videoMutedRemotelyDescription": "You can always turn it on again.", "passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) removed by another participant", "passwordSetRemotely": "$t(lockRoomPasswordUppercase) set by another participant", - "raisedHand": "{{name}} would like to speak.", + "raisedHand": "Would like to speak.", "screenShareNoAudio": " Share audio box was not checked in the window selection screen.", "screenShareNoAudioTitle": "Couldn't share system audio!", "somebody": "Somebody", @@ -580,12 +585,12 @@ "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 ", "oldElectronClientDescription2": "latest build", "oldElectronClientDescription3": " now!", - "moderationInEffectDescription": "Please raise hand if you want to speak", - "moderationInEffectCSDescription": "Please raise hand if you want to share your video", - "moderationInEffectVideoDescription": "Please raise your hand if you want your video to be visible", - "moderationInEffectTitle": "The microphone is muted by the moderator", - "moderationInEffectCSTitle": "Content sharing is disabled by moderator", - "moderationInEffectVideoTitle": "The video is muted by the moderator", + "moderationInEffectDescription": "Please raise hand if you want to speak.", + "moderationInEffectCSDescription": "Please raise hand if you want to share your screen.", + "moderationInEffectVideoDescription": "Please raise your hand if you want to start your camera.", + "moderationInEffectTitle": "Your microphone is muted by the moderator", + "moderationInEffectCSTitle": "Screen sharing is blocked by the moderator", + "moderationInEffectVideoTitle": "Your camera is blocked by the moderator", "moderationRequestFromModerator": "The host would like you to unmute", "moderationRequestFromParticipant": "Wants to speak", "moderationStartedTitle": "Moderation started", @@ -605,16 +610,17 @@ }, "actions": { "allow": "Allow attendees to:", + "audioModeration": "Unmute themselves", "blockEveryoneMicCamera": "Block everyone's mic and camera", "invite": "Invite Someone", "askUnmute": "Ask to unmute", "mute": "Mute", "muteAll": "Mute all", "muteEveryoneElse": "Mute everyone else", - "startModeration": "Unmute themselves or start video", "stopEveryonesVideo": "Stop everyone's video", "stopVideo": "Stop video", - "unblockEveryoneMicCamera": "Unblock everyone's mic and camera" + "unblockEveryoneMicCamera": "Unblock everyone's mic and camera", + "videoModeration": "Start video" } }, "passwordSetRemotely": "Set by another participant", @@ -868,7 +874,7 @@ "embedMeeting": "Embed meeting", "feedback": "Leave feedback", "fullScreen": "Toggle full screen", - "grantModerator": "Grant Moderator", + "grantModerator": "Grant Moderator Rights", "hangup": "Leave the meeting", "help": "Help", "invite": "Invite people", @@ -1054,7 +1060,7 @@ "domuteOthers": "Mute everyone else", "domuteVideoOfOthers": "Disable camera of everyone else", "flip": "Flip", - "grantModerator": "Grant Moderator", + "grantModerator": "Grant Moderator Rights", "kick": "Kick out", "moderator": "Moderator", "mute": "Participant is muted", diff --git a/package.json b/package.json index aec899a34b..d1537fec83 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "base64-js": "1.3.1", "bc-css-flags": "3.0.0", "clipboard-copy": "4.0.1", + "clsx": "1.1.1", "dropbox": "10.7.0", "focus-visible": "5.1.0", "i18n-iso-countries": "6.8.0", diff --git a/react/features/av-moderation/actionTypes.js b/react/features/av-moderation/actionTypes.js index 96616497a2..3163cfc5ef 100644 --- a/react/features/av-moderation/actionTypes.js +++ b/react/features/av-moderation/actionTypes.js @@ -29,22 +29,40 @@ export const ENABLE_MODERATION = 'ENABLE_MODERATION'; /** - * The type of (redux) action which signals that A/V Moderation disable has been requested. + * The type of (redux) action which signals that Audio Moderation disable has been requested. * * { - * type: REQUEST_DISABLE_MODERATION + * type: REQUEST_DISABLE_AUDIO_MODERATION * } */ -export const REQUEST_DISABLE_MODERATION = 'REQUEST_DISABLE_MODERATION'; +export const REQUEST_DISABLE_AUDIO_MODERATION = 'REQUEST_DISABLE_AUDIO_MODERATION'; /** - * The type of (redux) action which signals that A/V Moderation enable has been requested. + * The type of (redux) action which signals that Video Moderation disable has been requested. * * { - * type: REQUEST_ENABLE_MODERATION + * type: REQUEST_DISABLE_VIDEO_MODERATION * } */ -export const REQUEST_ENABLE_MODERATION = 'REQUEST_ENABLE_MODERATION'; +export const REQUEST_DISABLE_VIDEO_MODERATION = 'REQUEST_DISABLE_VIDEO_MODERATION'; + +/** + * The type of (redux) action which signals that Audio Moderation enable has been requested. + * + * { + * type: REQUEST_ENABLE_AUDIO_MODERATION + * } + */ +export const REQUEST_ENABLE_AUDIO_MODERATION = 'REQUEST_ENABLE_AUDIO_MODERATION'; + +/** + * The type of (redux) action which signals that Video Moderation enable has been requested. + * + * { + * type: REQUEST_ENABLE_VIDEO_MODERATION + * } + */ +export const REQUEST_ENABLE_VIDEO_MODERATION = 'REQUEST_ENABLE_VIDEO_MODERATION'; /** * The type of (redux) action which signals that the local participant had been approved. diff --git a/react/features/av-moderation/actions.js b/react/features/av-moderation/actions.js index df22dc9ce6..46482e1c7b 100644 --- a/react/features/av-moderation/actions.js +++ b/react/features/av-moderation/actions.js @@ -11,9 +11,12 @@ import { LOCAL_PARTICIPANT_MODERATION_NOTIFICATION, PARTICIPANT_APPROVED, PARTICIPANT_PENDING_AUDIO, - REQUEST_DISABLE_MODERATION, - REQUEST_ENABLE_MODERATION + REQUEST_DISABLE_AUDIO_MODERATION, + REQUEST_ENABLE_AUDIO_MODERATION, + REQUEST_DISABLE_VIDEO_MODERATION, + REQUEST_ENABLE_VIDEO_MODERATION } from './actionTypes'; +import { isEnabledFromState } from './functions'; /** * Action used by moderator to approve audio and video for a participant. @@ -22,10 +25,15 @@ import { * @returns {void} */ export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => { - const { conference } = getConferenceState(getState()); + const state = getState(); + const { conference } = getConferenceState(state); - conference.avModerationApprove(MEDIA_TYPE.AUDIO, id); - conference.avModerationApprove(MEDIA_TYPE.VIDEO, id); + if (isEnabledFromState(MEDIA_TYPE.AUDIO, state)) { + conference.avModerationApprove(MEDIA_TYPE.AUDIO, id); + } + if (isEnabledFromState(MEDIA_TYPE.VIDEO, state)) { + conference.avModerationApprove(MEDIA_TYPE.VIDEO, id); + } }; /** @@ -89,28 +97,54 @@ export const enableModeration = (mediaType: MediaType, actor: Object) => { }; /** - * Requests disable of audio and video moderation. + * Requests disable of audio moderation. * * @returns {{ - * type: REQUEST_DISABLE_MODERATED_AUDIO + * type: REQUEST_DISABLE_AUDIO_MODERATION * }} */ -export const requestDisableModeration = () => { +export const requestDisableAudioModeration = () => { return { - type: REQUEST_DISABLE_MODERATION + type: REQUEST_DISABLE_AUDIO_MODERATION }; }; /** - * Requests enabled audio & video moderation. + * Requests disable of video moderation. * * @returns {{ - * type: REQUEST_ENABLE_MODERATED_AUDIO + * type: REQUEST_DISABLE_VIDEO_MODERATION + * }} + */ +export const requestDisableVideoModeration = () => { + return { + type: REQUEST_DISABLE_VIDEO_MODERATION + }; +}; + +/** + * Requests enable of audio moderation. + * + * @returns {{ + * type: REQUEST_ENABLE_AUDIO_MODERATION + * }} + */ +export const requestEnableAudioModeration = () => { + return { + type: REQUEST_ENABLE_AUDIO_MODERATION + }; +}; + +/** + * Requests enable of video moderation. + * + * @returns {{ + * type: REQUEST_ENABLE_VIDEO_MODERATION * }} */ -export const requestEnableModeration = () => { +export const requestEnableVideoModeration = () => { return { - type: REQUEST_ENABLE_MODERATION + type: REQUEST_ENABLE_VIDEO_MODERATION }; }; diff --git a/react/features/av-moderation/components/AudioModerationNotifications.js b/react/features/av-moderation/components/AudioModerationNotifications.js deleted file mode 100644 index cd15831fd2..0000000000 --- a/react/features/av-moderation/components/AudioModerationNotifications.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; - -import NotificationWithParticipants from '../../notifications/components/web/NotificationWithParticipants'; -import { - approveParticipant, - dismissPendingAudioParticipant -} from '../actions'; -import { getParticipantsAskingToAudioUnmute } from '../functions'; - - -/** - * Component used to display a list of participants who asked to be unmuted. - * This is visible only to moderators. - * - * @returns {React$Element<'ul'> | null} - */ -export default function() { - const participants = useSelector(getParticipantsAskingToAudioUnmute); - const { t } = useTranslation(); - - return participants.length - ? ( - <> -
- { t('raisedHand') } -
- - - ) : null; -} diff --git a/react/features/av-moderation/middleware.js b/react/features/av-moderation/middleware.js index 43396d0227..dd90d606d1 100644 --- a/react/features/av-moderation/middleware.js +++ b/react/features/av-moderation/middleware.js @@ -6,7 +6,6 @@ import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; import { MEDIA_TYPE } from '../base/media'; import { getLocalParticipant, - getParticipantDisplayName, getRemoteParticipants, isLocalParticipantModerator, isParticipantModerator, @@ -16,16 +15,16 @@ import { import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { hideNotification, - NOTIFICATION_TIMEOUT, showNotification } from '../notifications'; +import { muteLocal } from '../video-menu/actions.any'; import { - DISABLE_MODERATION, - ENABLE_MODERATION, LOCAL_PARTICIPANT_MODERATION_NOTIFICATION, - REQUEST_DISABLE_MODERATION, - REQUEST_ENABLE_MODERATION + REQUEST_DISABLE_AUDIO_MODERATION, + REQUEST_DISABLE_VIDEO_MODERATION, + REQUEST_ENABLE_AUDIO_MODERATION, + REQUEST_ENABLE_VIDEO_MODERATION } from './actionTypes'; import { disableModeration, @@ -47,29 +46,10 @@ const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation'; const CS_MODERATION_NOTIFICATION_ID = 'video-moderation'; MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { - const { actor, mediaType, type } = action; + const { type } = action; + const { conference } = getConferenceState(getState()); switch (type) { - case DISABLE_MODERATION: - case ENABLE_MODERATION: { - // Audio & video moderation are both enabled at the same time. - // Avoid displaying 2 different notifications. - if (mediaType === MEDIA_TYPE.VIDEO) { - const titleKey = type === ENABLE_MODERATION - ? 'notify.moderationStartedTitle' - : 'notify.moderationStoppedTitle'; - - dispatch(showNotification({ - descriptionKey: actor ? 'notify.moderationToggleDescription' : undefined, - descriptionArguments: actor ? { - participantDisplayName: getParticipantDisplayName(getState, actor.getId()) - } : undefined, - titleKey - }, NOTIFICATION_TIMEOUT)); - } - - break; - } case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: { let descriptionKey; let titleKey; @@ -78,19 +58,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { switch (action.mediaType) { case MEDIA_TYPE.AUDIO: { titleKey = 'notify.moderationInEffectTitle'; - descriptionKey = 'notify.moderationInEffectDescription'; uid = AUDIO_MODERATION_NOTIFICATION_ID; break; } case MEDIA_TYPE.VIDEO: { titleKey = 'notify.moderationInEffectVideoTitle'; - descriptionKey = 'notify.moderationInEffectVideoDescription'; uid = VIDEO_MODERATION_NOTIFICATION_ID; break; } case MEDIA_TYPE.PRESENTER: { titleKey = 'notify.moderationInEffectCSTitle'; - descriptionKey = 'notify.moderationInEffectCSDescription'; uid = CS_MODERATION_NOTIFICATION_ID; break; } @@ -110,17 +87,19 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { break; } - case REQUEST_DISABLE_MODERATION: { - const { conference } = getConferenceState(getState()); - + case REQUEST_DISABLE_AUDIO_MODERATION: { conference.disableAVModeration(MEDIA_TYPE.AUDIO); + break; + } + case REQUEST_DISABLE_VIDEO_MODERATION: { conference.disableAVModeration(MEDIA_TYPE.VIDEO); break; } - case REQUEST_ENABLE_MODERATION: { - const { conference } = getConferenceState(getState()); - + case REQUEST_ENABLE_AUDIO_MODERATION: { conference.enableAVModeration(MEDIA_TYPE.AUDIO); + break; + } + case REQUEST_ENABLE_VIDEO_MODERATION: { conference.enableAVModeration(MEDIA_TYPE.VIDEO); break; } @@ -174,11 +153,12 @@ StateListenerRegistry.register( // Audio & video moderation are both enabled at the same time. // Avoid displaying 2 different notifications. - if (mediaType === MEDIA_TYPE.VIDEO) { + if (mediaType === MEDIA_TYPE.AUDIO) { dispatch(showNotification({ - titleKey: 'notify.unmute', - descriptionKey: 'notify.hostAskedUnmute', - sticky: true + titleKey: 'notify.hostAskedUnmute', + sticky: true, + customActionNameKey: 'notify.unmute', + customActionHandler: () => dispatch(muteLocal(false, MEDIA_TYPE.AUDIO)) })); } }); diff --git a/react/features/base/icons/svg/camera-empty-disabled.svg b/react/features/base/icons/svg/camera-empty-disabled.svg index 918f4b89c3..99438f89d4 100644 --- a/react/features/base/icons/svg/camera-empty-disabled.svg +++ b/react/features/base/icons/svg/camera-empty-disabled.svg @@ -1,3 +1,3 @@ - + diff --git a/react/features/base/media/middleware.js b/react/features/base/media/middleware.js index 15f7c7dd7f..95cd35e462 100644 --- a/react/features/base/media/middleware.js +++ b/react/features/base/media/middleware.js @@ -8,12 +8,15 @@ import { sendAnalytics } from '../../analytics'; import { APP_STATE_CHANGED } from '../../mobile/background'; +import { isForceMuted } from '../../participants-pane/functions'; import { SET_AUDIO_ONLY, setAudioOnly } from '../audio-only'; import { isRoomValid, SET_ROOM } from '../conference'; +import { getLocalParticipant } from '../participants'; import { MiddlewareRegistry } from '../redux'; import { getPropertyValue } from '../settings'; import { isLocalVideoTrackDesktop, setTrackMuted, TRACK_ADDED } from '../tracks'; +import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from './actionTypes'; import { setAudioMuted, setCameraFacingMode, setVideoMuted } from './actions'; import { CAMERA_FACING_MODE, @@ -55,6 +58,26 @@ MiddlewareRegistry.register(store => next => action => { return result; } + + case SET_AUDIO_MUTED: { + const state = store.getState(); + const participant = getLocalParticipant(state); + + if (!action.muted && isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { + return; + } + break; + } + + case SET_VIDEO_MUTED: { + const state = store.getState(); + const participant = getLocalParticipant(state); + + if (!action.muted && isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) { + return; + } + break; + } } return next(action); diff --git a/react/features/base/participants/actionTypes.js b/react/features/base/participants/actionTypes.js index 056bddb166..ca27fa24c9 100644 --- a/react/features/base/participants/actionTypes.js +++ b/react/features/base/participants/actionTypes.js @@ -180,3 +180,15 @@ export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL'; * } */ export const LOCAL_PARTICIPANT_RAISE_HAND = 'LOCAL_PARTICIPANT_RAISE_HAND'; + +/** + * Updates participant in raise hand queue. + * { + * type: RAISE_HAND_UPDATED, + * participant: { + * id: string, + * raiseHand: boolean + * } + * } + */ +export const RAISE_HAND_UPDATED = 'RAISE_HAND_UPDATED'; diff --git a/react/features/base/participants/actions.js b/react/features/base/participants/actions.js index 08c74ddbdc..b2822cdb52 100644 --- a/react/features/base/participants/actions.js +++ b/react/features/base/participants/actions.js @@ -15,7 +15,8 @@ import { PARTICIPANT_LEFT, PARTICIPANT_UPDATED, PIN_PARTICIPANT, - SET_LOADABLE_AVATAR_URL + SET_LOADABLE_AVATAR_URL, + RAISE_HAND_UPDATED } from './actionTypes'; import { DISCO_REMOTE_CONTROL_FEATURE @@ -465,7 +466,7 @@ export function participantUpdated(participant = {}) { * @returns {Promise} */ export function participantMutedUs(participant, track) { - return (dispatch, getState) => { + return dispatch => { if (!participant) { return; } @@ -473,12 +474,7 @@ export function participantMutedUs(participant, track) { const isAudio = track.isAudioTrack(); dispatch(showNotification({ - descriptionKey: isAudio ? 'notify.mutedRemotelyDescription' : 'notify.videoMutedRemotelyDescription', - titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle', - titleArguments: { - participantDisplayName: - getParticipantDisplayName(getState, participant.getId()) - } + titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle' })); }; } @@ -574,3 +570,19 @@ export function raiseHand(enabled) { enabled }; } + +/** + * Update raise hand queue of participants. + * + * @param {Object} participant - Participant that updated raised hand. + * @returns {{ + * type: RAISE_HAND_UPDATED, + * participant: Object + * }} + */ +export function raiseHandUpdateQueue(participant) { + return { + type: RAISE_HAND_UPDATED, + participant + }; +} diff --git a/react/features/base/participants/functions.js b/react/features/base/participants/functions.js index c80161dc06..c3df4f2ca4 100644 --- a/react/features/base/participants/functions.js +++ b/react/features/base/participants/functions.js @@ -456,21 +456,35 @@ async function _getFirstLoadableAvatarUrl(participant, store) { export function getSortedParticipants(stateful: Object | Function) { const localParticipant = getLocalParticipant(stateful); const remoteParticipants = getRemoteParticipants(stateful); + const raisedHandParticipantIds = getRaiseHandsQueue(stateful); const items = []; const dominantSpeaker = getDominantSpeakerParticipant(stateful); + const raisedHandParticipants = []; + + raisedHandParticipantIds + .map(id => remoteParticipants.get(id) || localParticipant) + .forEach(p => { + if (p !== dominantSpeaker) { + raisedHandParticipants.push(p); + } + }); remoteParticipants.forEach(p => { - if (p !== dominantSpeaker) { + if (p !== dominantSpeaker && !raisedHandParticipantIds.find(id => p.id === id)) { items.push(p); } }); + if (!raisedHandParticipantIds.find(id => localParticipant.id === id)) { + items.push(localParticipant); + } + items.sort((a, b) => getParticipantDisplayName(stateful, a.id).localeCompare(getParticipantDisplayName(stateful, b.id)) ); - items.unshift(localParticipant); + items.unshift(...raisedHandParticipants); if (dominantSpeaker && dominantSpeaker !== localParticipant) { items.unshift(dominantSpeaker); @@ -492,3 +506,17 @@ export function getSortedParticipantIds(stateful: Object | Function): Array} + */ +export function getRaiseHandsQueue(stateful: Object | Function): Array { + const { raisedHandsQueue } = toState(stateful)['features/base/participants']; + + return raisedHandsQueue; +} diff --git a/react/features/base/participants/middleware.js b/react/features/base/participants/middleware.js index a94dea7a45..3e897a3c2c 100644 --- a/react/features/base/participants/middleware.js +++ b/react/features/base/participants/middleware.js @@ -3,8 +3,10 @@ import { batch } from 'react-redux'; import UIEvents from '../../../../service/UI/UIEvents'; +import { approveParticipant } from '../../av-moderation/actions'; import { toggleE2EE } from '../../e2ee/actions'; import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications'; +import { isForceMuted } from '../../participants-pane/functions'; import { CALLING, INVITED } from '../../presence-status'; import { RAISE_HAND_SOUND_ID } from '../../reactions/constants'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app'; @@ -15,6 +17,7 @@ import { } from '../conference'; import { getDisableRemoveRaisedHandOnFocus } from '../config/functions.any'; import { JitsiConferenceEvents } from '../lib-jitsi-meet'; +import { MEDIA_TYPE } from '../media'; import { MiddlewareRegistry, StateListenerRegistry } from '../redux'; import { playSound, registerSound, unregisterSound } from '../sounds'; @@ -27,7 +30,8 @@ import { PARTICIPANT_DISPLAY_NAME_CHANGED, PARTICIPANT_JOINED, PARTICIPANT_LEFT, - PARTICIPANT_UPDATED + PARTICIPANT_UPDATED, + RAISE_HAND_UPDATED } from './actionTypes'; import { localParticipantIdChanged, @@ -35,6 +39,7 @@ import { localParticipantLeft, participantLeft, participantUpdated, + raiseHandUpdateQueue, setLoadableAvatarUrl } from './actions'; import { @@ -48,7 +53,9 @@ import { getParticipantById, getParticipantCount, getParticipantDisplayName, - getRemoteParticipants + getRaiseHandsQueue, + getRemoteParticipants, + isLocalParticipantModerator } from './functions'; import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds'; @@ -122,6 +129,11 @@ MiddlewareRegistry.register(store => next => action => { const { enabled } = action; const localId = getLocalParticipant(store.getState())?.id; + store.dispatch(raiseHandUpdateQueue({ + id: localId, + raisedHand: enabled + })); + store.dispatch(participantUpdated({ // XXX Only the local participant is allowed to update without // stating the JitsiConference instance (i.e. participant property @@ -162,6 +174,21 @@ MiddlewareRegistry.register(store => next => action => { break; } + case RAISE_HAND_UPDATED: { + const { participant } = action; + const queue = getRaiseHandsQueue(store.getState()); + + if (participant.raisedHand) { + queue.push(participant.id); + action.queue = queue; + } else { + const filteredQueue = queue.filter(id => id !== participant.id); + + action.queue = filteredQueue; + } + break; + } + case PARTICIPANT_JOINED: { _maybePlaySounds(store, action); @@ -424,6 +451,7 @@ function _participantJoinedOrUpdated(store, next, action) { // Send an external update of the local participant's raised hand state // if a new raised hand state is defined in the action. if (typeof raisedHand !== 'undefined') { + if (local) { const { conference } = getState()['features/base/conference']; @@ -476,6 +504,7 @@ function _participantJoinedOrUpdated(store, next, action) { */ function _raiseHandUpdated({ dispatch, getState }, conference, participantId, newValue) { const raisedHand = newValue === 'true'; + const state = getState(); dispatch(participantUpdated({ conference, @@ -483,17 +512,37 @@ function _raiseHandUpdated({ dispatch, getState }, conference, participantId, ne raisedHand })); + dispatch(raiseHandUpdateQueue({ + id: participantId, + raisedHand + })); + if (typeof APP !== 'undefined') { APP.API.notifyRaiseHandUpdated(participantId, raisedHand); } + const isModerator = isLocalParticipantModerator(state); + const participant = getParticipantById(state, participantId); + let shouldDisplayAllowAction = false; + + if (isModerator) { + shouldDisplayAllowAction = isForceMuted(participant, MEDIA_TYPE.AUDIO, state) + || isForceMuted(participant, MEDIA_TYPE.VIDEO, state); + } + + const action = shouldDisplayAllowAction ? { + customActionNameKey: 'notify.allowAction', + customActionHandler: () => dispatch(approveParticipant(participantId)) + } : {}; + if (raisedHand) { dispatch(showNotification({ - titleArguments: { - name: getParticipantDisplayName(getState, participantId) - }, - titleKey: 'notify.raisedHand' - }, NOTIFICATION_TIMEOUT)); + titleKey: 'notify.somebody', + title: getParticipantDisplayName(state, participantId), + descriptionKey: 'notify.raisedHand', + raiseHandNotification: true, + ...action + }, NOTIFICATION_TIMEOUT * (shouldDisplayAllowAction ? 2 : 1))); dispatch(playSound(RAISE_HAND_SOUND_ID)); } } diff --git a/react/features/base/participants/reducer.js b/react/features/base/participants/reducer.js index cfbef3a067..82697e1953 100644 --- a/react/features/base/participants/reducer.js +++ b/react/features/base/participants/reducer.js @@ -10,6 +10,7 @@ import { PARTICIPANT_LEFT, PARTICIPANT_UPDATED, PIN_PARTICIPANT, + RAISE_HAND_UPDATED, SET_LOADABLE_AVATAR_URL } from './actionTypes'; import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants'; @@ -63,7 +64,8 @@ const DEFAULT_STATE = { remote: new Map(), sortedRemoteParticipants: new Map(), sortedRemoteScreenshares: new Map(), - speakersList: new Map() + speakersList: new Map(), + raisedHandsQueue: [] }; /** @@ -318,6 +320,12 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a return { ...state }; } + case RAISE_HAND_UPDATED: { + return { + ...state, + raisedHandsQueue: action.queue + }; + } case SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED: { const { participantIds } = action; const sortedSharesList = []; diff --git a/react/features/base/tracks/middleware.js b/react/features/base/tracks/middleware.js index a412925446..91e6caed53 100644 --- a/react/features/base/tracks/middleware.js +++ b/react/features/base/tracks/middleware.js @@ -14,7 +14,8 @@ import { SET_VIDEO_MUTED, VIDEO_MUTISM_AUTHORITY, TOGGLE_CAMERA_FACING_MODE, - toggleCameraFacingMode + toggleCameraFacingMode, + VIDEO_TYPE } from '../media'; import { MiddlewareRegistry } from '../redux'; @@ -28,6 +29,7 @@ import { import { createLocalTracksA, showNoDataFromSourceVideoError, + toggleScreensharing, trackNoDataFromSourceNotificationInfoChanged } from './actions'; import { @@ -137,9 +139,9 @@ MiddlewareRegistry.register(store => next => action => { case TOGGLE_SCREENSHARING: if (typeof APP === 'object') { - // check for A/V Moderation when trying to start screen sharing - if (action.enabled && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, store.getState())) { + if ((action.enabled || action.enabled === undefined) + && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, store.getState())) { store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER)); return; @@ -171,8 +173,10 @@ MiddlewareRegistry.register(store => next => action => { // Do not change the video mute state for local presenter tracks. if (jitsiTrack.type === MEDIA_TYPE.PRESENTER) { APP.conference.mutePresenter(muted); - } else if (jitsiTrack.isLocal()) { + } else if (jitsiTrack.isLocal() && !(jitsiTrack.videoType === VIDEO_TYPE.DESKTOP)) { APP.conference.setVideoMuteStatus(); + } else if (jitsiTrack.isLocal() && muted && jitsiTrack.videoType === VIDEO_TYPE.DESKTOP) { + store.dispatch(toggleScreensharing(false)); } else { APP.UI.setVideoMuted(participantID); } diff --git a/react/features/chat/actions.web.js b/react/features/chat/actions.web.js index 7177773443..9bfd42f4c1 100644 --- a/react/features/chat/actions.web.js +++ b/react/features/chat/actions.web.js @@ -3,6 +3,7 @@ import type { Dispatch } from 'redux'; import VideoLayout from '../../../modules/UI/videolayout/VideoLayout'; +import { getParticipantById } from '../base/participants/functions'; import { OPEN_CHAT } from './actionTypes'; import { closeChat } from './actions.any'; @@ -27,6 +28,27 @@ export function openChat(participant: Object) { }; } +/** + * Displays the chat panel for a participant identified by an id. + * + * @param {string} id - The id of the participant. + * @returns {{ + * participant: Participant, + * type: OPEN_CHAT + * }} + */ +export function openChatById(id: string) { + return function(dispatch: (Object) => Object, getState: Function) { + const participant = getParticipantById(getState(), id); + + return dispatch({ + participant, + type: OPEN_CHAT + }); + }; +} + + /** * Toggles display of the chat panel. * diff --git a/react/features/conference/components/web/Conference.js b/react/features/conference/components/web/Conference.js index 9f096bf269..a930f1e3ae 100644 --- a/react/features/conference/components/web/Conference.js +++ b/react/features/conference/components/web/Conference.js @@ -4,7 +4,6 @@ import _ from 'lodash'; import React from 'react'; import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout'; -import AudioModerationNotifications from '../../../av-moderation/components/AudioModerationNotifications'; import { getConferenceNameForTitle } from '../../../base/conference'; import { connect, disconnect } from '../../../base/connection'; import { translate } from '../../../base/i18n'; @@ -233,7 +232,6 @@ class Conference extends AbstractConference { {!_isParticipantsPaneVisible &&
-
} diff --git a/react/features/notifications/actionTypes.js b/react/features/notifications/actionTypes.js index 88ee14af58..f3429e0de9 100644 --- a/react/features/notifications/actionTypes.js +++ b/react/features/notifications/actionTypes.js @@ -45,3 +45,13 @@ export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION'; * } */ export const SET_NOTIFICATIONS_ENABLED = 'SET_NOTIFICATIONS_ENABLED'; + +/** + * The type of (redux) action which signals that raise hand notifications + * should be dismissed. + * + * { + * type: HIDE_RAISE_HAND_NOTIFICATIONS + * } + */ +export const HIDE_RAISE_HAND_NOTIFICATIONS = 'HIDE_RAISE_HAND_NOTIFICATIONS'; diff --git a/react/features/notifications/actions.js b/react/features/notifications/actions.js index a202e03794..5f2fa93bdc 100644 --- a/react/features/notifications/actions.js +++ b/react/features/notifications/actions.js @@ -9,6 +9,7 @@ import { getParticipantCount } from '../base/participants/functions'; import { CLEAR_NOTIFICATIONS, HIDE_NOTIFICATION, + HIDE_RAISE_HAND_NOTIFICATIONS, SET_NOTIFICATIONS_ENABLED, SHOW_NOTIFICATION } from './actionTypes'; @@ -48,6 +49,19 @@ export function hideNotification(uid: string) { }; } +/** + * Removes the raise hand notifications. + * + * @returns {{ + * type: HIDE_RAISE_HAND_NOTIFICATIONS + * }} + */ +export function hideRaiseHandNotifications() { + return { + type: HIDE_RAISE_HAND_NOTIFICATIONS + }; +} + /** * Stops notifications from being displayed. * diff --git a/react/features/notifications/middleware.js b/react/features/notifications/middleware.js index 518a0faf3b..c537fd0f58 100644 --- a/react/features/notifications/middleware.js +++ b/react/features/notifications/middleware.js @@ -7,12 +7,15 @@ import { PARTICIPANT_ROLE, PARTICIPANT_UPDATED, getParticipantById, - getParticipantDisplayName + getParticipantDisplayName, + getLocalParticipant } from '../base/participants'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; +import { PARTICIPANTS_PANE_OPEN } from '../participants-pane/actionTypes'; import { clearNotifications, + hideRaiseHandNotifications, showNotification, showParticipantJoinedNotification } from './actions'; @@ -42,22 +45,6 @@ MiddlewareRegistry.register(store => next => action => { )); } - if (typeof interfaceConfig === 'object' - && !interfaceConfig.DISABLE_FOCUS_INDICATOR && p.role === PARTICIPANT_ROLE.MODERATOR) { - // Do not show the notification for mobile and also when the focus indicator is disabled. - const displayName = getParticipantDisplayName(state, p.id); - - if (!p.isReplacing) { - dispatch(showNotification({ - descriptionArguments: { to: displayName || '$t(notify.somebody)' }, - descriptionKey: 'notify.grantedTo', - titleKey: 'notify.somebody', - title: displayName - }, - NOTIFICATION_TIMEOUT)); - } - } - return result; } case PARTICIPANT_LEFT: { @@ -82,30 +69,36 @@ MiddlewareRegistry.register(store => next => action => { return next(action); } case PARTICIPANT_UPDATED: { - if (typeof interfaceConfig === 'undefined' || interfaceConfig.DISABLE_FOCUS_INDICATOR) { + if (typeof interfaceConfig === 'undefined') { // Do not show the notification for mobile and also when the focus indicator is disabled. return next(action); } const { id, role } = action.participant; const state = store.getState(); + const localParticipant = getLocalParticipant(state); + + if (localParticipant.id !== id) { + return next(action); + } + const oldParticipant = getParticipantById(state, id); const oldRole = oldParticipant?.role; if (oldRole && oldRole !== role && role === PARTICIPANT_ROLE.MODERATOR) { - const displayName = getParticipantDisplayName(state, id); store.dispatch(showNotification({ - descriptionArguments: { to: displayName || '$t(notify.somebody)' }, - descriptionKey: 'notify.grantedTo', - titleKey: 'notify.somebody', - title: displayName + titleKey: 'notify.moderator' }, NOTIFICATION_TIMEOUT)); } return next(action); } + case PARTICIPANTS_PANE_OPEN: { + store.dispatch(hideRaiseHandNotifications()); + break; + } } return next(action); diff --git a/react/features/notifications/reducer.js b/react/features/notifications/reducer.js index 62380ee376..6bfd06c29a 100644 --- a/react/features/notifications/reducer.js +++ b/react/features/notifications/reducer.js @@ -5,6 +5,7 @@ import { ReducerRegistry } from '../base/redux'; import { CLEAR_NOTIFICATIONS, HIDE_NOTIFICATION, + HIDE_RAISE_HAND_NOTIFICATIONS, SET_NOTIFICATIONS_ENABLED, SHOW_NOTIFICATION } from './actionTypes'; @@ -43,6 +44,14 @@ ReducerRegistry.register('features/notifications', notification => notification.uid !== action.uid) }; + case HIDE_RAISE_HAND_NOTIFICATIONS: + return { + ...state, + notifications: state.notifications.filter( + notification => !notification.props.raiseHandNotification + ) + }; + case SET_NOTIFICATIONS_ENABLED: return { ...state, diff --git a/react/features/participants-pane/components/FooterContextMenu.js b/react/features/participants-pane/components/FooterContextMenu.js index 550f8c8260..47512fc602 100644 --- a/react/features/participants-pane/components/FooterContextMenu.js +++ b/react/features/participants-pane/components/FooterContextMenu.js @@ -1,11 +1,17 @@ // @flow import { makeStyles } from '@material-ui/core/styles'; +import clsx from 'clsx'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; -import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions'; +import { + requestDisableAudioModeration, + requestDisableVideoModeration, + requestEnableAudioModeration, + requestEnableVideoModeration +} from '../../av-moderation/actions'; import { isEnabled as isAvModerationEnabled, isSupported as isAvModerationSupported @@ -13,7 +19,10 @@ import { import { openDialog } from '../../base/dialog'; import { Icon, IconCheck, IconVideoOff } from '../../base/icons'; import { MEDIA_TYPE } from '../../base/media'; -import { getLocalParticipant } from '../../base/participants'; +import { + getParticipantCount, + isEveryoneModerator +} from '../../base/participants'; import { MuteEveryonesVideoDialog } from '../../video-menu/components'; import { @@ -33,6 +42,17 @@ const useStyles = makeStyles(() => { transform: 'translateY(-100%)', width: '283px' }, + drawer: { + width: '100%', + top: 'auto', + bottom: 0, + transform: 'none', + position: 'relative', + + '& > div': { + lineHeight: '32px' + } + }, text: { color: '#C2C2C2', padding: '10px 16px 10px 52px' @@ -45,31 +65,43 @@ const useStyles = makeStyles(() => { type Props = { - /** - * Callback for the mouse leaving this item - */ - onMouseLeave: Function + /** + * Whether the menu is displayed inside a drawer. + */ + inDrawer?: boolean, + + /** + * Callback for the mouse leaving this item. + */ + onMouseLeave?: Function }; -export const FooterContextMenu = ({ onMouseLeave }: Props) => { +export const FooterContextMenu = ({ inDrawer, onMouseLeave }: Props) => { const dispatch = useDispatch(); const isModerationSupported = useSelector(isAvModerationSupported()); - const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO)); - const { id } = useSelector(getLocalParticipant); + const allModerators = useSelector(isEveryoneModerator); + const participantCount = useSelector(getParticipantCount); + const isAudioModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO)); + const isVideoModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.VIDEO)); + const { t } = useTranslation(); - const disable = useCallback(() => dispatch(requestDisableModeration()), [ dispatch ]); + const disableAudioModeration = useCallback(() => dispatch(requestDisableAudioModeration()), [ dispatch ]); + + const disableVideoModeration = useCallback(() => dispatch(requestDisableVideoModeration()), [ dispatch ]); - const enable = useCallback(() => dispatch(requestEnableModeration()), [ dispatch ]); + const enableAudioModeration = useCallback(() => dispatch(requestEnableAudioModeration()), [ dispatch ]); + + const enableVideoModeration = useCallback(() => dispatch(requestEnableVideoModeration()), [ dispatch ]); const classes = useStyles(); const muteAllVideo = useCallback( - () => dispatch(openDialog(MuteEveryonesVideoDialog, { exclude: [ id ] })), [ dispatch ]); + () => dispatch(openDialog(MuteEveryonesVideoDialog)), [ dispatch ]); return ( { { t('participantsPane.actions.stopEveryonesVideo') } - { isModerationSupported ? ( + {isModerationSupported && (participantCount === 1 || !allModerators) ? (
{t('participantsPane.actions.allow')}
- { isModerationEnabled ? ( + { isAudioModerationEnabled ? ( + + + {t('participantsPane.actions.audioModeration') } + + + ) : ( + + + {t('participantsPane.actions.audioModeration') } + + )} + { isVideoModerationEnabled ? ( + id = 'participants-pane-context-menu-stop-video-moderation' + onClick = { disableVideoModeration }> - { t('participantsPane.actions.startModeration') } + {t('participantsPane.actions.videoModeration')} ) : ( + id = 'participants-pane-context-menu-start-video-moderation' + onClick = { enableVideoModeration }> - { t('participantsPane.actions.startModeration') } + {t('participantsPane.actions.videoModeration')} )}
diff --git a/react/features/participants-pane/components/web/LobbyParticipantItem.js b/react/features/participants-pane/components/web/LobbyParticipantItem.js index 6c0c43e6a7..45e6bbe8a3 100644 --- a/react/features/participants-pane/components/web/LobbyParticipantItem.js +++ b/react/features/participants-pane/components/web/LobbyParticipantItem.js @@ -1,27 +1,39 @@ // @flow -import React, { useCallback } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useDispatch } from 'react-redux'; -import { approveKnockingParticipant, rejectKnockingParticipant } from '../../../lobby/actions'; import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants'; +import { useLobbyActions } from '../../hooks'; import ParticipantItem from './ParticipantItem'; import { ParticipantActionButton } from './styled'; type Props = { + /** + * If an overflow drawer should be displayed. + */ + overflowDrawer: boolean, + + /** + * Callback used to open a drawer with admit/reject actions. + */ + openDrawerForParticipant: Function, + /** * Participant reference */ participant: Object }; -export const LobbyParticipantItem = ({ participant: p }: Props) => { - const dispatch = useDispatch(); - const admit = useCallback(() => dispatch(approveKnockingParticipant(p.id), [ dispatch ])); - const reject = useCallback(() => dispatch(rejectKnockingParticipant(p.id), [ dispatch ])); +export const LobbyParticipantItem = ({ + overflowDrawer, + participant: p, + openDrawerForParticipant +}: Props) => { + const { id } = p; + const [ admit ] = useLobbyActions({ participantID: id }); const { t } = useTranslation(); return ( @@ -30,14 +42,12 @@ export const LobbyParticipantItem = ({ participant: p }: Props) => { audioMediaState = { MEDIA_STATE.NONE } displayName = { p.name } local = { p.local } - participantID = { p.id } + openDrawerForParticipant = { openDrawerForParticipant } + overflowDrawer = { overflowDrawer } + participantID = { id } raisedHand = { p.raisedHand } - videoMuteState = { MEDIA_STATE.NONE } + videoMediaState = { MEDIA_STATE.NONE } youText = { t('chat.you') }> - - {t('lobby.reject')} - diff --git a/react/features/participants-pane/components/web/LobbyParticipantItems.js b/react/features/participants-pane/components/web/LobbyParticipantItems.js new file mode 100644 index 0000000000..596eb75f94 --- /dev/null +++ b/react/features/participants-pane/components/web/LobbyParticipantItems.js @@ -0,0 +1,47 @@ +// @flow + +import React from 'react'; + +import { LobbyParticipantItem } from './LobbyParticipantItem'; + +type Props = { + + /** + * Opens a drawer with actions for a knocking participant. + */ + openDrawerForParticipant: Function, + + /** + * If a drawer with actions should be displayed. + */ + overflowDrawer: boolean, + + /** + * List with the knocking participants. + */ + participants: Array +} + +/** + * Component used to display a list of knocking participants. + * + * @param {Object} props - The props of the component. + * @returns {ReactNode} + */ +function LobbyParticipantItems({ openDrawerForParticipant, overflowDrawer, participants }: Props) { + + return ( +
+ {participants.map(p => ( + ) + )} +
+ ); +} + +// Memoize the component in order to avoid rerender on drawer open/close. +export default React.memo(LobbyParticipantItems); diff --git a/react/features/participants-pane/components/web/LobbyParticipantList.js b/react/features/participants-pane/components/web/LobbyParticipantList.js deleted file mode 100644 index 961cd6c647..0000000000 --- a/react/features/participants-pane/components/web/LobbyParticipantList.js +++ /dev/null @@ -1,72 +0,0 @@ -// @flow - -import { makeStyles } from '@material-ui/core/styles'; -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSelector, useDispatch } from 'react-redux'; - -import { withPixelLineHeight } from '../../../base/styles/functions.web'; -import { admitMultiple } from '../../../lobby/actions.web'; -import { getKnockingParticipants, getLobbyEnabled } from '../../../lobby/functions'; - -import { LobbyParticipantItem } from './LobbyParticipantItem'; - -const useStyles = makeStyles(theme => { - return { - headingContainer: { - alignItems: 'center', - display: 'flex', - justifyContent: 'space-between' - }, - heading: { - ...withPixelLineHeight(theme.typography.heading7), - color: theme.palette.text02 - }, - link: { - ...withPixelLineHeight(theme.typography.labelBold), - color: theme.palette.link01, - cursor: 'pointer' - } - }; -}); - - -export const LobbyParticipantList = () => { - const lobbyEnabled = useSelector(getLobbyEnabled); - const participants = useSelector(getKnockingParticipants); - - const { t } = useTranslation(); - const classes = useStyles(); - const dispatch = useDispatch(); - const admitAll = useCallback(() => { - dispatch(admitMultiple(participants)); - }, [ dispatch, participants ]); - - if (!lobbyEnabled || !participants.length) { - return null; - } - - return ( - <> -
-
- {t('participantsPane.headings.lobby', { count: participants.length })} -
- { - participants.length > 1 && ( -
{t('lobby.admitAll')}
- ) - } -
-
- {participants.map(p => ( - ) - )} -
- - ); -}; diff --git a/react/features/participants-pane/components/web/LobbyParticipants.js b/react/features/participants-pane/components/web/LobbyParticipants.js new file mode 100644 index 0000000000..bac73d543b --- /dev/null +++ b/react/features/participants-pane/components/web/LobbyParticipants.js @@ -0,0 +1,134 @@ +// @flow + +import { makeStyles } from '@material-ui/core/styles'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector, useDispatch } from 'react-redux'; + +import { Avatar } from '../../../base/avatar'; +import { Icon, IconCheck, IconClose } from '../../../base/icons'; +import { withPixelLineHeight } from '../../../base/styles/functions.web'; +import { admitMultiple } from '../../../lobby/actions.web'; +import { getLobbyEnabled, getKnockingParticipants } from '../../../lobby/functions'; +import { Drawer, DrawerPortal } from '../../../toolbox/components/web'; +import { showOverflowDrawer } from '../../../toolbox/functions'; +import { useLobbyActions, useParticipantDrawer } from '../../hooks'; + +import LobbyParticipantItems from './LobbyParticipantItems'; + +const useStyles = makeStyles(theme => { + return { + drawerActions: { + listStyleType: 'none', + margin: 0, + padding: 0 + }, + drawerItem: { + alignItems: 'center', + color: theme.palette.text01, + display: 'flex', + padding: '12px 16px', + ...withPixelLineHeight(theme.typography.bodyShortRegularLarge), + + '&:first-child': { + marginTop: '15px' + + }, + + '&:hover': { + cursor: 'pointer', + background: theme.palette.action02 + } + }, + icon: { + marginRight: 16 + }, + headingContainer: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between' + }, + heading: { + ...withPixelLineHeight(theme.typography.heading7), + color: theme.palette.text02 + }, + link: { + ...withPixelLineHeight(theme.typography.labelBold), + color: theme.palette.link01, + cursor: 'pointer' + } + }; +}); + +/** + * Component used to display a list of participants waiting in the lobby. + * + * @returns {ReactNode} + */ +export default function LobbyParticipants() { + const lobbyEnabled = useSelector(getLobbyEnabled); + const participants = useSelector(getKnockingParticipants); + const { t } = useTranslation(); + const classes = useStyles(); + const dispatch = useDispatch(); + const admitAll = useCallback(() => { + dispatch(admitMultiple(participants)); + }, [ dispatch, participants ]); + const overflowDrawer = useSelector(showOverflowDrawer); + const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer(); + const [ admit, reject ] = useLobbyActions(drawerParticipant, closeDrawer); + + if (!lobbyEnabled || !participants.length) { + return null; + } + + return ( + <> +
+
+ {t('participantsPane.headings.lobby', { count: participants.length })} +
+
{t('lobby.admitAll')}
+
+ + + +
    +
  • + + { drawerParticipant && drawerParticipant.displayName } +
  • +
  • + + { t('lobby.admit') } +
  • +
  • + + { t('lobby.reject')} +
  • +
+
+
+ + ); +} diff --git a/react/features/participants-pane/components/web/MeetingParticipantContextMenu.js b/react/features/participants-pane/components/web/MeetingParticipantContextMenu.js index 4981703f1c..c871a09243 100644 --- a/react/features/participants-pane/components/web/MeetingParticipantContextMenu.js +++ b/react/features/participants-pane/components/web/MeetingParticipantContextMenu.js @@ -1,7 +1,8 @@ // @flow - +import { withStyles } from '@material-ui/core/styles'; import React, { Component } from 'react'; +import { Avatar } from '../../../base/avatar'; import { isToolbarButtonEnabled } from '../../../base/config/functions.web'; import { openDialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; @@ -21,10 +22,13 @@ import { isParticipantModerator } from '../../../base/participants'; import { connect } from '../../../base/redux'; +import { withPixelLineHeight } from '../../../base/styles/functions.web'; import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks'; -import { openChat } from '../../../chat/actions'; -import { stopSharedVideo } from '../../../shared-video/actions.any'; +import { openChatById } from '../../../chat/actions'; +import { setVolume } from '../../../filmstrip/actions.web'; +import { Drawer, DrawerPortal } from '../../../toolbox/components/web'; import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu'; +import { VolumeSlider } from '../../../video-menu/components/web'; import MuteRemoteParticipantsVideoDialog from '../../../video-menu/components/web/MuteRemoteParticipantsVideoDialog'; import { getComputedOuterHeight } from '../../functions'; @@ -73,11 +77,33 @@ type Props = { */ _participant: Object, + /** + * A value between 0 and 1 indicating the volume of the participant's + * audio element. + */ + _volume: ?number, + + /** + * Closes a drawer if open. + */ + closeDrawer: Function, + + /** + * An object containing the CSS classes. + */ + classes?: {[ key: string]: string}, + /** * The dispatch function from redux. */ dispatch: Function, + /** + * The participant for which the drawer is open. + * It contains the displayName & participantID. + */ + drawerParticipant: Object, + /** * Callback used to open a confirmation dialog for audio muting. */ @@ -108,6 +134,12 @@ type Props = { */ participantID: string, + /** + * True if an overflow drawer should be displayed. + */ + overflowDrawer: boolean, + + /** * The translate function. */ @@ -122,6 +154,25 @@ type State = { isHidden: boolean }; +const styles = theme => { + return { + drawer: { + '& > div': { + ...withPixelLineHeight(theme.typography.bodyShortRegularLarge), + lineHeight: '32px', + + '& svg': { + fill: theme.palette.icon01 + } + }, + '&:first-child': { + marginTop: 15 + } + } + }; +}; + + /** * Implements the MeetingParticipantContextMenu component. */ @@ -146,13 +197,27 @@ class MeetingParticipantContextMenu extends Component { this._containerRef = React.createRef(); + this._getCurrentParticipantId = this._getCurrentParticipantId.bind(this); this._onGrantModerator = this._onGrantModerator.bind(this); this._onKick = this._onKick.bind(this); this._onMuteEveryoneElse = this._onMuteEveryoneElse.bind(this); this._onMuteVideo = this._onMuteVideo.bind(this); this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this); - this._onStopSharedVideo = this._onStopSharedVideo.bind(this); this._position = this._position.bind(this); + this._onVolumeChange = this._onVolumeChange.bind(this); + } + + _getCurrentParticipantId: () => string; + + /** + * Returns the participant id for the item we want to operate. + * + * @returns {void} + */ + _getCurrentParticipantId() { + const { _participant, drawerParticipant, overflowDrawer } = this.props; + + return overflowDrawer ? drawerParticipant?.participantID : _participant?.id; } _onGrantModerator: () => void; @@ -163,10 +228,8 @@ class MeetingParticipantContextMenu extends Component { * @returns {void} */ _onGrantModerator() { - const { _participant, dispatch } = this.props; - - dispatch(openDialog(GrantModeratorDialog, { - participantID: _participant?.id + this.props.dispatch(openDialog(GrantModeratorDialog, { + participantID: this._getCurrentParticipantId() })); } @@ -178,10 +241,8 @@ class MeetingParticipantContextMenu extends Component { * @returns {void} */ _onKick() { - const { _participant, dispatch } = this.props; - - dispatch(openDialog(KickRemoteParticipantDialog, { - participantID: _participant?.id + this.props.dispatch(openDialog(KickRemoteParticipantDialog, { + participantID: this._getCurrentParticipantId() })); } @@ -195,7 +256,7 @@ class MeetingParticipantContextMenu extends Component { _onStopSharedVideo() { const { dispatch } = this.props; - dispatch(stopSharedVideo()); + dispatch(this._onStopSharedVideo()); } _onMuteEveryoneElse: () => void; @@ -206,10 +267,8 @@ class MeetingParticipantContextMenu extends Component { * @returns {void} */ _onMuteEveryoneElse() { - const { _participant, dispatch } = this.props; - - dispatch(openDialog(MuteEveryoneDialog, { - exclude: [ _participant?.id ] + this.props.dispatch(openDialog(MuteEveryoneDialog, { + exclude: [ this._getCurrentParticipantId() ] })); } @@ -221,10 +280,8 @@ class MeetingParticipantContextMenu extends Component { * @returns {void} */ _onMuteVideo() { - const { _participant, dispatch } = this.props; - - dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { - participantID: _participant?.id + this.props.dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { + participantID: this._getCurrentParticipantId() })); } @@ -236,9 +293,10 @@ class MeetingParticipantContextMenu extends Component { * @returns {void} */ _onSendPrivateMessage() { - const { _participant, dispatch } = this.props; + const { closeDrawer, dispatch, overflowDrawer } = this.props; - dispatch(openChat(_participant)); + dispatch(openChatById(this._getCurrentParticipantId())); + overflowDrawer && closeDrawer(); } _position: () => void; @@ -270,6 +328,21 @@ class MeetingParticipantContextMenu extends Component { } } + _onVolumeChange: (number) => void; + + /** + * Handles volume changes. + * + * @param {number} value - The new value for the volume. + * @returns {void} + */ + _onVolumeChange(value) { + const { _participant, dispatch } = this.props; + const { id } = _participant; + + dispatch(setVolume(id, value)); + } + /** * Implements React Component's componentDidMount. * @@ -306,9 +379,14 @@ class MeetingParticipantContextMenu extends Component { _isParticipantAudioMuted, _localVideoOwner, _participant, + _volume = 1, + classes, + closeDrawer, + drawerParticipant, onEnter, onLeave, onSelect, + overflowDrawer, muteAudio, t } = this.props; @@ -317,90 +395,116 @@ class MeetingParticipantContextMenu extends Component { return null; } - return ( - - { - !_participant.isFakeParticipant && ( - <> - + const actions + = _participant.isFakeParticipant ? ( + <> + {_localVideoOwner && ( + + + {t('toolbar.stopSharedVideo')} + + )} + + ) : ( + <> + {_isLocalModerator && ( + + <> { - _isLocalModerator && ( - <> - { - !_isParticipantAudioMuted - && - - {t('dialog.muteParticipantButton')} - - } - - - - {t('toolbar.accessibilityLabel.muteEveryoneElse')} - - - ) + !_isParticipantAudioMuted && overflowDrawer + && + + {t('dialog.muteParticipantButton')} + } - { - _isLocalModerator && ( - _isParticipantVideoMuted || ( - - - {t('participantsPane.actions.stopVideo')} - - ) - ) - } - + + + {t('toolbar.accessibilityLabel.muteEveryoneElse')} + + + + { + _isParticipantVideoMuted || ( + + + {t('participantsPane.actions.stopVideo')} + + ) + } + + )} + + + { + _isLocalModerator && ( + <> + { + !_isParticipantModerator && ( + + + {t('toolbar.accessibilityLabel.grantModerator')} + + ) + } + + + { t('videothumbnail.kick') } + + + ) + } + { + _isChatButtonEnabled && ( + + + {t('toolbar.accessibilityLabel.privateMessage')} + + ) + } + + { overflowDrawer && typeof _volume === 'number' && !isNaN(_volume) + && + + + } + + ); + return ( + <> + { !overflowDrawer + && + { actions } + } + + + +
- { - _isLocalModerator && ( - <> - { - !_isParticipantModerator && ( - - - {t('toolbar.accessibilityLabel.grantModerator')} - - ) - } - - - { t('videothumbnail.kick') } - - - ) - } - { - _isChatButtonEnabled && ( - - - {t('toolbar.accessibilityLabel.privateMessage')} - - ) - } + + + { drawerParticipant && drawerParticipant.displayName } + - - ) - } - - { - _participant.isFakeParticipant && _localVideoOwner && ( - - - {t('toolbar.stopSharedVideo')} - - ) - } - + { actions } +
+
+
+ ); } } @@ -414,10 +518,12 @@ class MeetingParticipantContextMenu extends Component { * @returns {Props} */ function _mapStateToProps(state, ownProps): Object { - const { participantID } = ownProps; + const { participantID, overflowDrawer, drawerParticipant } = ownProps; const { ownerId } = state['features/shared-video']; const localParticipantId = getLocalParticipant(state).id; - const participant = getParticipantByIdOrUndefined(state, participantID); + + const participant = getParticipantByIdOrUndefined(state, + overflowDrawer ? drawerParticipant?.participantID : participantID); const _isLocalModerator = isLocalParticipantModerator(state); const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state); @@ -425,6 +531,10 @@ function _mapStateToProps(state, ownProps): Object { const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state); const _isParticipantModerator = isParticipantModerator(participant); + const { participantsVolume } = state['features/filmstrip']; + const id = participant?.id; + const isLocal = participant?.local ?? true; + return { _isLocalModerator, _isChatButtonEnabled, @@ -432,8 +542,9 @@ function _mapStateToProps(state, ownProps): Object { _isParticipantVideoMuted, _isParticipantAudioMuted, _localVideoOwner: Boolean(ownerId === localParticipantId), - _participant: participant + _participant: participant, + _volume: isLocal ? undefined : id ? participantsVolume[id] : undefined }; } -export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu)); +export default withStyles(styles)(translate(connect(_mapStateToProps)(MeetingParticipantContextMenu))); diff --git a/react/features/participants-pane/components/web/MeetingParticipantItem.js b/react/features/participants-pane/components/web/MeetingParticipantItem.js index bf32226f8f..94f5293e59 100644 --- a/react/features/participants-pane/components/web/MeetingParticipantItem.js +++ b/react/features/participants-pane/components/web/MeetingParticipantItem.js @@ -9,8 +9,12 @@ import { } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks'; -import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../../constants'; -import { getParticipantAudioMediaState, getQuickActionButtonType } from '../../functions'; +import { ACTION_TRIGGER, type MediaState } from '../../constants'; +import { + getParticipantAudioMediaState, + getParticipantVideoMediaState, + getQuickActionButtonType +} from '../../functions'; import ParticipantQuickAction from '../ParticipantQuickAction'; import ParticipantItem from './ParticipantItem'; @@ -24,19 +28,20 @@ type Props = { _audioMediaState: MediaState, /** - * The display name of the participant. + * Media state for video. */ - _displayName: string, + _videoMediaState: MediaState, + /** - * True if the participant is video muted. + * The display name of the participant. */ - _isVideoMuted: boolean, + _displayName: string, /** * True if the participant is the local participant. */ - _local: boolean, + _local: Boolean, /** * Shared video local participant owner. @@ -96,6 +101,17 @@ type Props = { */ onLeave: Function, + /** + * Callback used to open an actions drawer for a participant. + */ + openDrawerForParticipant: Function, + + /** + * True if an overflow drawer should be displayed. + */ + overflowDrawer: boolean, + + /** * The aria-label for the ellipsis action. */ @@ -120,20 +136,22 @@ type Props = { */ function MeetingParticipantItem({ _audioMediaState, + _videoMediaState, _displayName, - _isVideoMuted, - _localVideoOwner, _local, + _localVideoOwner, _participant, _participantID, _quickActionButtonType, _raisedHand, askUnmuteText, isHighlighted, - onContextMenu, - onLeave, muteAudio, muteParticipantButtonText, + onContextMenu, + onLeave, + openDrawerForParticipant, + overflowDrawer, participantActionEllipsisLabel, youText }: Props) { @@ -145,32 +163,32 @@ function MeetingParticipantItem({ isHighlighted = { isHighlighted } local = { _local } onLeave = { onLeave } + openDrawerForParticipant = { openDrawerForParticipant } + overflowDrawer = { overflowDrawer } participantID = { _participantID } raisedHand = { _raisedHand } - videoMuteState = { _isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED } + videoMediaState = { _videoMediaState } youText = { youText }> - { - !_participant.isFakeParticipant && ( - <> - - - - ) - } - { - _participant.isFakeParticipant && _localVideoOwner && ( + + {!overflowDrawer && !_participant.isFakeParticipant + && <> + - ) + } + + {!overflowDrawer && _localVideoOwner && _participant.isFakeParticipant && ( + + )} ); } @@ -193,13 +211,13 @@ function _mapStateToProps(state, ownProps): Object { const _isAudioMuted = isParticipantAudioMuted(participant, state); const _isVideoMuted = isParticipantVideoMuted(participant, state); const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state); + const _videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state); const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, state); return { _audioMediaState, + _videoMediaState, _displayName: getParticipantDisplayName(state, participant?.id), - _isAudioMuted, - _isVideoMuted, _local: Boolean(participant?.local), _localVideoOwner: Boolean(ownerId === localParticipantId), _participant: participant, diff --git a/react/features/participants-pane/components/web/MeetingParticipantItems.js b/react/features/participants-pane/components/web/MeetingParticipantItems.js new file mode 100644 index 0000000000..19cb3fa2c5 --- /dev/null +++ b/react/features/participants-pane/components/web/MeetingParticipantItems.js @@ -0,0 +1,103 @@ +// @flow + +import React from 'react'; + +import MeetingParticipantItem from './MeetingParticipantItem'; + +type Props = { + + /** + * The translated ask unmute text for the qiuck action buttons. + */ + askUnmuteText: string, + + /** + * Callback for the mouse leaving this item + */ + lowerMenu: Function, + + /** + * Callback for the activation of this item's context menu + */ + toggleMenu: Function, + + /** + * Callback used to open a confirmation dialog for audio muting. + */ + muteAudio: Function, + + /** + * The translated text for the mute participant button. + */ + muteParticipantButtonText: string, + + /** + * The meeting participants. + */ + participantIds: Array, + + /** + * Callback used to open an actions drawer for a participant. + */ + openDrawerForParticipant: Function, + + /** + * True if an overflow drawer should be displayed. + */ + overflowDrawer: boolean, + + /** + * The if of the participant for which the context menu should be open. + */ + raiseContextId?: string, + + /** + * The aria-label for the ellipsis action. + */ + participantActionEllipsisLabel: string, + + /** + * The translated "you" text. + */ + youText: string +} + +/** + * Component used to display a list of meeting participants. + * + * @returns {ReactNode} + */ +function MeetingParticipantItems({ + askUnmuteText, + lowerMenu, + toggleMenu, + muteAudio, + muteParticipantButtonText, + participantIds, + openDrawerForParticipant, + overflowDrawer, + raiseContextId, + participantActionEllipsisLabel, + youText +}) { + const renderParticipant = id => ( + + ); + + return participantIds.map(renderParticipant); +} + +// Memoize the component in order to avoid rerender on drawer open/close. +export default React.memo(MeetingParticipantItems); diff --git a/react/features/participants-pane/components/web/MeetingParticipantList.js b/react/features/participants-pane/components/web/MeetingParticipants.js similarity index 64% rename from react/features/participants-pane/components/web/MeetingParticipantList.js rename to react/features/participants-pane/components/web/MeetingParticipants.js index 274e5e3c59..b9d330e9c6 100644 --- a/react/features/participants-pane/components/web/MeetingParticipantList.js +++ b/react/features/participants-pane/components/web/MeetingParticipants.js @@ -5,36 +5,38 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { isToolbarButtonEnabled } from '../../../base/config/functions.web'; -import { openDialog } from '../../../base/dialog'; +import { MEDIA_TYPE } from '../../../base/media'; import { getParticipantCountWithFake, getSortedParticipantIds } from '../../../base/participants'; import { connect } from '../../../base/redux'; -import MuteRemoteParticipantDialog from '../../../video-menu/components/web/MuteRemoteParticipantDialog'; +import { showOverflowDrawer } from '../../../toolbox/functions'; +import { muteRemote } from '../../../video-menu/actions.any'; import { findStyledAncestor, shouldRenderInviteButton } from '../../functions'; +import { useParticipantDrawer } from '../../hooks'; import { InviteButton } from './InviteButton'; import MeetingParticipantContextMenu from './MeetingParticipantContextMenu'; -import MeetingParticipantItem from './MeetingParticipantItem'; +import MeetingParticipantItems from './MeetingParticipantItems'; import { Heading, ParticipantContainer } from './styled'; type NullProto = { - [key: string]: any, - __proto__: null + [key: string]: any, + __proto__: null }; type RaiseContext = NullProto | {| - /** - * Target elements against which positioning calculations are made. - */ - offsetTarget?: HTMLElement, + /** + * Target elements against which positioning calculations are made. + */ + offsetTarget?: HTMLElement, - /** - * The ID of the participant. - */ - participantID?: String, + /** + * The ID of the participant. + */ + participantID ?: string, |}; const initialState = Object.freeze(Object.create(null)); @@ -49,11 +51,11 @@ const initialState = Object.freeze(Object.create(null)); * * @returns {ReactNode} - The component. */ -function MeetingParticipantList({ participantsCount, showInviteButton, sortedParticipantIds = [] }) { +function MeetingParticipants({ participantsCount, showInviteButton, overflowDrawer, sortedParticipantIds = [] }) { const dispatch = useDispatch(); const isMouseOverMenu = useRef(false); - const [ raiseContext, setRaiseContext ] = useState(initialState); + const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState); const { t } = useTranslation(); const lowerMenu = useCallback(() => { @@ -101,8 +103,9 @@ function MeetingParticipantList({ participantsCount, showInviteButton, sortedPar }, [ lowerMenu ]); const muteAudio = useCallback(id => () => { - dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id })); - }); + dispatch(muteRemote(id, MEDIA_TYPE.AUDIO)); + }, [ dispatch ]); + const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer(); // FIXME: // It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is @@ -115,34 +118,35 @@ function MeetingParticipantList({ participantsCount, showInviteButton, sortedPar const askUnmuteText = t('participantsPane.actions.askUnmute'); const muteParticipantButtonText = t('dialog.muteParticipantButton'); - const renderParticipant = id => ( - - ); - return ( - <> - {t('participantsPane.headings.participantsList', { count: participantsCount })} - {showInviteButton && } -
- {sortedParticipantIds.map(renderParticipant)} -
- - + <> + {t('participantsPane.headings.participantsList', { count: participantsCount })} + {showInviteButton && } +
+ +
+ + ); } @@ -163,11 +167,14 @@ function _mapStateToProps(state): Object { const showInviteButton = shouldRenderInviteButton(state) && isToolbarButtonEnabled('invite', state); + const overflowDrawer = showOverflowDrawer(state); + return { sortedParticipantIds, participantsCount, - showInviteButton + showInviteButton, + overflowDrawer }; } -export default connect(_mapStateToProps)(MeetingParticipantList); +export default connect(_mapStateToProps)(MeetingParticipants); diff --git a/react/features/participants-pane/components/web/ParticipantItem.js b/react/features/participants-pane/components/web/ParticipantItem.js index ae1ce759c8..3c3052f66a 100644 --- a/react/features/participants-pane/components/web/ParticipantItem.js +++ b/react/features/participants-pane/components/web/ParticipantItem.js @@ -1,6 +1,6 @@ // @flow -import React, { type Node } from 'react'; +import React, { type Node, useCallback } from 'react'; import { Avatar } from '../../../base/avatar'; import { @@ -61,13 +61,23 @@ type Props = { /** * True if the participant is local. */ - local: boolean, + local: Boolean, + + /** + * Opens a drawer with participant actions. + */ + openDrawerForParticipant: Function, /** * Callback for when the mouse leaves this component */ onLeave?: Function, + /** + * If an overflow drawer can be opened. + */ + overflowDrawer?: boolean, + /** * The ID of the participant. */ @@ -81,7 +91,7 @@ type Props = { /** * Media state for video */ - videoMuteState: MediaState, + videoMediaState: MediaState, /** * The translated "you" text. @@ -101,20 +111,28 @@ export default function ParticipantItem({ onLeave, actionsTrigger = ACTION_TRIGGER.HOVER, audioMediaState = MEDIA_STATE.NONE, - videoMuteState = MEDIA_STATE.NONE, + videoMediaState = MEDIA_STATE.NONE, displayName, participantID, local, + openDrawerForParticipant, + overflowDrawer, raisedHand, youText }: Props) { const ParticipantActions = Actions[actionsTrigger]; + const onClick = useCallback( + () => openDrawerForParticipant({ + participantID, + displayName + })); return ( } { raisedHand && } - { VideoStateIcons[videoMuteState] } + { VideoStateIcons[videoMediaState] } { AudioStateIcons[audioMediaState] } diff --git a/react/features/participants-pane/components/web/ParticipantsPane.js b/react/features/participants-pane/components/web/ParticipantsPane.js index c86c341579..e1055cbabf 100644 --- a/react/features/participants-pane/components/web/ParticipantsPane.js +++ b/react/features/participants-pane/components/web/ParticipantsPane.js @@ -7,14 +7,16 @@ import { openDialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; import { isLocalParticipantModerator } from '../../../base/participants'; import { connect } from '../../../base/redux'; +import { Drawer, DrawerPortal } from '../../../toolbox/components/web'; +import { showOverflowDrawer } from '../../../toolbox/functions'; import { MuteEveryoneDialog } from '../../../video-menu/components/'; import { close } from '../../actions'; import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../../functions'; import theme from '../../theme.json'; import { FooterContextMenu } from '../FooterContextMenu'; -import { LobbyParticipantList } from './LobbyParticipantList'; -import MeetingParticipantList from './MeetingParticipantList'; +import LobbyParticipants from './LobbyParticipants'; +import MeetingParticipants from './MeetingParticipants'; import { AntiCollapse, Close, @@ -31,6 +33,11 @@ import { */ type Props = { + /** + * Whether to display the context menu as a drawer. + */ + _overflowDrawer: boolean, + /** * Is the participants pane open. */ @@ -81,6 +88,7 @@ class ParticipantsPane extends Component { // Bind event handlers so they are only bound once per instance. this._onClosePane = this._onClosePane.bind(this); + this._onDrawerClose = this._onDrawerClose.bind(this); this._onKeyPress = this._onKeyPress.bind(this); this._onMuteAll = this._onMuteAll.bind(this); this._onToggleContext = this._onToggleContext.bind(this); @@ -113,10 +121,12 @@ class ParticipantsPane extends Component { */ render() { const { + _overflowDrawer, _paneOpen, _showFooter, t } = this.props; + const { contextOpen } = this.state; // when the pane is not open optimize to not // execute the MeetingParticipantList render for large list of participants @@ -137,9 +147,9 @@ class ParticipantsPane extends Component { tabIndex = { 0 } /> - + - + {_showFooter && (
@@ -150,12 +160,19 @@ class ParticipantsPane extends Component { - {this.state.contextOpen + {this.state.contextOpen && !_overflowDrawer && }
)} + + + + + ); @@ -173,6 +190,20 @@ class ParticipantsPane extends Component { this.props.dispatch(close()); } + _onDrawerClose: () => void + + /** + * Callback for closing the drawer. + * + * @private + * @returns {void} + */ + _onDrawerClose() { + this.setState({ + contextOpen: false + }); + } + _onKeyPress: (Object) => void; /** @@ -228,6 +259,8 @@ class ParticipantsPane extends Component { }); } } + + } /** @@ -245,6 +278,7 @@ function _mapStateToProps(state: Object) { const isPaneOpen = getParticipantsPaneOpen(state); return { + _overflowDrawer: showOverflowDrawer(state), _paneOpen: isPaneOpen, _showFooter: isPaneOpen && isLocalParticipantModerator(state) }; diff --git a/react/features/participants-pane/components/web/index.js b/react/features/participants-pane/components/web/index.js index eff0bc7643..78c66ceadc 100644 --- a/react/features/participants-pane/components/web/index.js +++ b/react/features/participants-pane/components/web/index.js @@ -1,7 +1,5 @@ export * from './InviteButton'; export * from './LobbyParticipantItem'; -export * from './LobbyParticipantList'; -export * from './MeetingParticipantList'; export { default as ParticipantsPane } from './ParticipantsPane'; export * from '../ParticipantsPaneButton'; export * from './RaisedHandIndicator'; diff --git a/react/features/participants-pane/components/web/styled.js b/react/features/participants-pane/components/web/styled.js index e23eece028..74a34c773b 100644 --- a/react/features/participants-pane/components/web/styled.js +++ b/react/features/participants-pane/components/web/styled.js @@ -4,6 +4,8 @@ import styled from 'styled-components'; import { Icon, IconHorizontalPoints } from '../../../base/icons'; import { ACTION_TRIGGER } from '../../constants'; +const MD_BREAKPOINT = '580px'; + export const ignoredChildClassName = 'ignore-child'; export const AntiCollapse = styled.br` @@ -89,7 +91,7 @@ export const ContextMenuIcon = styled(Icon).attrs({ size: 20 })` & > svg { - fill: #a4b8d1; + fill: #ffffff; } `; @@ -162,6 +164,12 @@ export const FooterButton = styled(Button)` height: 40px; font-size: 15px; padding: 0 16px; + + @media (max-width: ${MD_BREAKPOINT}) { + font-size: 16px; + height: 48px; + min-width: 48px; + } `; export const FooterEllipsisButton = styled(FooterButton).attrs({ @@ -188,6 +196,10 @@ export const Heading = styled.div` font-size: 15px; line-height: 24px; margin: 8px 0 ${props => props.theme.panePadding}px; + + @media (max-width: ${MD_BREAKPOINT}) { + font-size: 16px; + } `; export const ParticipantActionButton = styled(Button)` @@ -275,6 +287,11 @@ export const ParticipantContainer = styled.div` padding-left: ${props => props.theme.panePadding}px; position: relative; + @media (max-width: ${MD_BREAKPOINT}) { + font-size: 16px; + height: 64px; + } + &:hover { ${ParticipantStates} { ${props => !props.local && 'display: none'}; @@ -293,6 +310,10 @@ export const ParticipantContainer = styled.div` & ${ParticipantContent} { box-shadow: none; } + + & ${ParticipantStates} { + display: none; + } ${props => !props.isHighlighted && '}'} `; @@ -306,6 +327,11 @@ export const ParticipantInviteButton = styled(Button).attrs({ & > *:not(:last-child) { margin-right: 8px; } + + @media (max-width: ${MD_BREAKPOINT}) { + font-size: 16px; + height: 48px; + } `; export const ParticipantName = styled.div` diff --git a/react/features/participants-pane/constants.js b/react/features/participants-pane/constants.js index 4957b71d85..d865e372ce 100644 --- a/react/features/participants-pane/constants.js +++ b/react/features/participants-pane/constants.js @@ -94,6 +94,7 @@ export const AudioStateIcons: {[MediaState]: React$Element | null} = { export const VideoStateIcons = { [MEDIA_STATE.FORCE_MUTED]: ( ), diff --git a/react/features/participants-pane/functions.js b/react/features/participants-pane/functions.js index 7358f6763d..8f1915e450 100644 --- a/react/features/participants-pane/functions.js +++ b/react/features/participants-pane/functions.js @@ -71,17 +71,39 @@ export function isForceMuted(participant: Object, mediaType: MediaType, state: O * @param {Object} participant - The participant. * @param {boolean} muted - The mute state of the participant. * @param {Object} state - The redux state. + * @param {boolean} ignoreDominantSpeaker - Whether to ignore the dominant speaker state. * @returns {MediaState} */ export function getParticipantAudioMediaState(participant: Object, muted: Boolean, state: Object) { const dominantSpeaker = getDominantSpeakerParticipant(state); + if (muted) { + if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { + return MEDIA_STATE.FORCE_MUTED; + } + + return MEDIA_STATE.MUTED; + } + if (participant === dominantSpeaker) { return MEDIA_STATE.DOMINANT_SPEAKER; } + return MEDIA_STATE.UNMUTED; +} + +/** + * Determines the video media state (the mic icon) for a participant. + * + * @param {Object} participant - The participant. + * @param {boolean} muted - The mute state of the participant. + * @param {Object} state - The redux state. + * @param {boolean} ignoreDominantSpeaker - Whether to ignore the dominant speaker state. + * @returns {MediaState} + */ +export function getParticipantVideoMediaState(participant: Object, muted: Boolean, state: Object) { if (muted) { - if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { + if (isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) { return MEDIA_STATE.FORCE_MUTED; } @@ -144,7 +166,7 @@ export const getParticipantsPaneOpen = (state: Object) => Boolean(getState(state export function getQuickActionButtonType(participant: Object, isAudioMuted: Boolean, state: Object) { // handled only by moderators if (isLocalParticipantModerator(state)) { - if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { + if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state) || isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) { return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE; } if (!isAudioMuted) { diff --git a/react/features/participants-pane/hooks.js b/react/features/participants-pane/hooks.js new file mode 100644 index 0000000000..e255757137 --- /dev/null +++ b/react/features/participants-pane/hooks.js @@ -0,0 +1,46 @@ +import { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; + +import { approveKnockingParticipant, rejectKnockingParticipant } from '../lobby/actions'; + +/** + * Hook used to create admit/reject lobby actions. + * + * @param {Object} participant - The participant for which the actions are created. + * @param {Function} closeDrawer - Callback for closing the drawer. + * @returns {Array} + */ +export function useLobbyActions(participant, closeDrawer) { + const dispatch = useDispatch(); + + return [ + useCallback(e => { + e.stopPropagation(); + dispatch(approveKnockingParticipant(participant && participant.participantID)); + closeDrawer && closeDrawer(); + }, [ dispatch, closeDrawer ]), + + useCallback(() => { + dispatch(rejectKnockingParticipant(participant && participant.participantID)); + closeDrawer && closeDrawer(); + }, [ dispatch, closeDrawer ]) + ]; +} + +/** + * Hook used to create actions & state for opening a drawer. + * + * @returns {Array} + */ +export function useParticipantDrawer() { + const [ drawerParticipant, openDrawerForParticipant ] = useState(null); + const closeDrawer = useCallback(() => { + openDrawerForParticipant(null); + }); + + return [ + drawerParticipant, + closeDrawer, + openDrawerForParticipant + ]; +} diff --git a/react/features/talk-while-muted/middleware.js b/react/features/talk-while-muted/middleware.js index c203c50573..06c31160c0 100644 --- a/react/features/talk-while-muted/middleware.js +++ b/react/features/talk-while-muted/middleware.js @@ -3,13 +3,15 @@ import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; import { CONFERENCE_JOINED } from '../base/conference'; import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; -import { setAudioMuted } from '../base/media'; +import { MEDIA_TYPE, setAudioMuted } from '../base/media'; +import { getLocalParticipant, raiseHand } from '../base/participants'; import { MiddlewareRegistry } from '../base/redux'; import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { hideNotification, showNotification } from '../notifications'; +import { isForceMuted } from '../participants-pane/functions'; import { setCurrentNotificationUid } from './actions'; import { TALK_WHILE_MUTED_SOUND_ID } from './constants'; @@ -41,10 +43,13 @@ MiddlewareRegistry.register(store => next => action => { }); conference.on( JitsiConferenceEvents.TALK_WHILE_MUTED, async () => { + const state = getState(); + const local = getLocalParticipant(state); + const forceMuted = isForceMuted(local, MEDIA_TYPE.AUDIO, state); const notification = await dispatch(showNotification({ titleKey: 'toolbar.talkWhileMutedPopup', - customActionNameKey: 'notify.unmute', - customActionHandler: () => dispatch(setAudioMuted(false)) + customActionNameKey: forceMuted ? 'notify.raiseHandAction' : 'notify.unmute', + customActionHandler: () => dispatch(forceMuted ? raiseHand(true) : setAudioMuted(false)) })); const { soundsTalkWhileMuted } = getState()['features/base/settings']; diff --git a/react/features/toolbox/functions.web.js b/react/features/toolbox/functions.web.js index 28ea635a0e..271afa9c78 100644 --- a/react/features/toolbox/functions.web.js +++ b/react/features/toolbox/functions.web.js @@ -80,3 +80,14 @@ export function isVideoSettingsButtonDisabled(state: Object) { export function isVideoMuteButtonDisabled(state: Object) { return !hasAvailableDevices(state, 'videoInput'); } + +/** + * If an overflow drawer should be displayed or not. + * This is usually done for mobile devices or on narrow screens. + * + * @param {Object} state - The state from the Redux store. + * @returns {boolean} + */ +export function showOverflowDrawer(state: Object) { + return state['features/toolbox'].overflowDrawer; +} diff --git a/react/features/video-menu/components/AbstractGrantModeratorDialog.js b/react/features/video-menu/components/AbstractGrantModeratorDialog.js index 74c7064fd8..9318257b68 100644 --- a/react/features/video-menu/components/AbstractGrantModeratorDialog.js +++ b/react/features/video-menu/components/AbstractGrantModeratorDialog.js @@ -6,7 +6,7 @@ import { createRemoteVideoMenuButtonEvent, sendAnalytics } from '../../analytics'; -import { grantModerator } from '../../base/participants'; +import { getParticipantById, grantModerator } from '../../base/participants'; type Props = { @@ -20,6 +20,11 @@ type Props = { */ participantID: string, + /** + * The name of the remote participant to be granted moderator rights. + */ + participantName: string, + /** * Function to translate i18n labels. */ @@ -64,3 +69,17 @@ export default class AbstractGrantModeratorDialog return true; } } + +/** + * Maps (parts of) the Redux state to the associated {@code AbstractMuteEveryoneDialog}'s props. + * + * @param {Object} state - The redux state. + * @param {Object} ownProps - The properties explicitly passed to the component. + * @returns {Props} + */ +export function abstractMapStateToProps(state: Object, ownProps: Props) { + + return { + participantName: getParticipantById(state, ownProps.participantID).name + }; +} diff --git a/react/features/video-menu/components/AbstractMuteButton.js b/react/features/video-menu/components/AbstractMuteButton.js index 5b9e73c90a..d7f8e81af7 100644 --- a/react/features/video-menu/components/AbstractMuteButton.js +++ b/react/features/video-menu/components/AbstractMuteButton.js @@ -4,13 +4,11 @@ import { createRemoteVideoMenuButtonEvent, sendAnalytics } from '../../analytics'; -import { openDialog } from '../../base/dialog'; import { IconMicDisabled } from '../../base/icons'; import { MEDIA_TYPE } from '../../base/media'; import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components'; import { isRemoteTrackMuted } from '../../base/tracks'; - -import { MuteRemoteParticipantDialog } from '.'; +import { muteRemote } from '../actions.any'; export type Props = AbstractButtonProps & { @@ -61,7 +59,7 @@ export default class AbstractMuteButton extends AbstractButton { 'participant_id': participantID })); - dispatch(openDialog(MuteRemoteParticipantDialog, { participantID })); + dispatch(muteRemote(participantID, MEDIA_TYPE.AUDIO)); } /** diff --git a/react/features/video-menu/components/AbstractMuteEveryoneDialog.js b/react/features/video-menu/components/AbstractMuteEveryoneDialog.js index 919dd7bddb..bf9848d011 100644 --- a/react/features/video-menu/components/AbstractMuteEveryoneDialog.js +++ b/react/features/video-menu/components/AbstractMuteEveryoneDialog.js @@ -2,6 +2,8 @@ import React from 'react'; +import { requestDisableAudioModeration, requestEnableAudioModeration } from '../../av-moderation/actions'; +import { isEnabledFromState } from '../../av-moderation/functions'; import { Dialog } from '../../base/dialog'; import { MEDIA_TYPE } from '../../base/media'; import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants'; @@ -19,7 +21,14 @@ export type Props = AbstractProps & { content: string, exclude: Array, - title: string + title: string, + showAdvancedModerationToggle: boolean, + isAudioModerationEnabled: boolean +}; + +type State = { + audioModerationEnabled: boolean, + content: string }; /** @@ -29,12 +38,33 @@ export type Props = AbstractProps & { * * @extends AbstractMuteRemoteParticipantDialog */ -export default class AbstractMuteEveryoneDialog extends AbstractMuteRemoteParticipantDialog

{ +export default class AbstractMuteEveryoneDialog extends AbstractMuteRemoteParticipantDialog { static defaultProps = { exclude: [], muteLocal: false }; + /** + * Initializes a new {@code AbstractMuteRemoteParticipantDialog} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: P) { + super(props); + + this.state = { + audioModerationEnabled: props.isAudioModerationEnabled, + content: props.content || props.t(props.isAudioModerationEnabled + ? 'dialog.muteEveryoneDialogModerationOn' : 'dialog.muteEveryoneDialog' + ) + }; + + // Bind event handlers so they are only bound once per instance. + this._onSubmit = this._onSubmit.bind(this); + this._onToggleModeration = this._onToggleModeration.bind(this); + } + /** * Implements React's {@link Component#render()}. * @@ -59,6 +89,8 @@ export default class AbstractMuteEveryoneDialog extends AbstractMuteRe _onSubmit: () => boolean; + _onToggleModeration: () => void; + /** * Callback to be invoked when the value of this dialog is submitted. * @@ -71,6 +103,11 @@ export default class AbstractMuteEveryoneDialog extends AbstractMuteRe } = this.props; dispatch(muteAllParticipants(exclude, MEDIA_TYPE.AUDIO)); + if (this.state.audioModerationEnabled) { + dispatch(requestEnableAudioModeration()); + } else { + dispatch(requestDisableAudioModeration()); + } return true; } @@ -97,7 +134,7 @@ export function abstractMapStateToProps(state: Object, ownProps: Props) { content: t('dialog.muteEveryoneElseDialog'), title: t('dialog.muteEveryoneElseTitle', { whom }) } : { - content: t('dialog.muteEveryoneDialog'), - title: t('dialog.muteEveryoneTitle') + title: t('dialog.muteEveryoneTitle'), + isAudioModerationEnabled: isEnabledFromState(MEDIA_TYPE.AUDIO, state) }; } diff --git a/react/features/video-menu/components/AbstractMuteEveryonesVideoDialog.js b/react/features/video-menu/components/AbstractMuteEveryonesVideoDialog.js index 20e5aec88f..b7185736a1 100644 --- a/react/features/video-menu/components/AbstractMuteEveryonesVideoDialog.js +++ b/react/features/video-menu/components/AbstractMuteEveryonesVideoDialog.js @@ -2,6 +2,8 @@ import React from 'react'; +import { requestDisableVideoModeration, requestEnableVideoModeration } from '../../av-moderation/actions'; +import { isEnabledFromState } from '../../av-moderation/functions'; import { Dialog } from '../../base/dialog'; import { MEDIA_TYPE } from '../../base/media'; import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants'; @@ -19,7 +21,14 @@ export type Props = AbstractProps & { content: string, exclude: Array, - title: string + title: string, + showAdvancedModerationToggle: boolean, + isVideoModerationEnabled: boolean +}; + +type State = { + moderationEnabled: boolean; + content: string; }; /** @@ -29,12 +38,34 @@ export type Props = AbstractProps & { * * @extends AbstractMuteRemoteParticipantsVideoDialog */ -export default class AbstractMuteEveryonesVideoDialog extends AbstractMuteRemoteParticipantsVideoDialog

{ +export default class AbstractMuteEveryonesVideoDialog + extends AbstractMuteRemoteParticipantsVideoDialog { static defaultProps = { exclude: [], muteLocal: false }; + /** + * Initializes a new {@code AbstractMuteRemoteParticipantsVideoDialog} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props: P) { + super(props); + + this.state = { + moderationEnabled: props.isVideoModerationEnabled, + content: props.content || props.t(props.isVideoModerationEnabled + ? 'dialog.muteEveryonesVideoDialogModerationOn' : 'dialog.muteEveryonesVideoDialog' + ) + }; + + // Bind event handlers so they are only bound once per instance. + this._onSubmit = this._onSubmit.bind(this); + this._onToggleModeration = this._onToggleModeration.bind(this); + } + /** * Implements React's {@link Component#render()}. * @@ -59,6 +90,8 @@ export default class AbstractMuteEveryonesVideoDialog extends Abstract _onSubmit: () => boolean; + _onToggleModeration: () => void; + /** * Callback to be invoked when the value of this dialog is submitted. * @@ -71,6 +104,11 @@ export default class AbstractMuteEveryonesVideoDialog extends Abstract } = this.props; dispatch(muteAllParticipants(exclude, MEDIA_TYPE.VIDEO)); + if (this.state.moderationEnabled) { + dispatch(requestEnableVideoModeration()); + } else { + dispatch(requestDisableVideoModeration()); + } return true; } @@ -84,7 +122,8 @@ export default class AbstractMuteEveryonesVideoDialog extends Abstract * @returns {Props} */ export function abstractMapStateToProps(state: Object, ownProps: Props) { - const { exclude, t } = ownProps; + const { exclude = [], t } = ownProps; + const isVideoModerationEnabled = isEnabledFromState(MEDIA_TYPE.VIDEO, state); const whom = exclude // eslint-disable-next-line no-confusing-arrow @@ -97,7 +136,7 @@ export function abstractMapStateToProps(state: Object, ownProps: Props) { content: t('dialog.muteEveryoneElsesVideoDialog'), title: t('dialog.muteEveryoneElsesVideoTitle', { whom }) } : { - content: t('dialog.muteEveryonesVideoDialog'), - title: t('dialog.muteEveryonesVideoTitle') + title: t('dialog.muteEveryonesVideoTitle'), + isVideoModerationEnabled }; } diff --git a/react/features/video-menu/components/AbstractMuteRemoteParticipantDialog.js b/react/features/video-menu/components/AbstractMuteRemoteParticipantDialog.js index b7322a2227..af70110d1d 100644 --- a/react/features/video-menu/components/AbstractMuteRemoteParticipantDialog.js +++ b/react/features/video-menu/components/AbstractMuteRemoteParticipantDialog.js @@ -32,8 +32,8 @@ export type Props = { * * @extends Component */ -export default class AbstractMuteRemoteParticipantDialog - extends Component

{ +export default class AbstractMuteRemoteParticipantDialog + extends Component { /** * Initializes a new {@code AbstractMuteRemoteParticipantDialog} instance. * diff --git a/react/features/video-menu/components/AbstractMuteRemoteParticipantsVideoDialog.js b/react/features/video-menu/components/AbstractMuteRemoteParticipantsVideoDialog.js index d32db1375f..7218477b39 100644 --- a/react/features/video-menu/components/AbstractMuteRemoteParticipantsVideoDialog.js +++ b/react/features/video-menu/components/AbstractMuteRemoteParticipantsVideoDialog.js @@ -32,8 +32,8 @@ export type Props = { * * @extends Component */ -export default class AbstractMuteRemoteParticipantsVideoDialog - extends Component

{ +export default class AbstractMuteRemoteParticipantsVideoDialog + extends Component { /** * Initializes a new {@code AbstractMuteRemoteParticipantsVideoDialog} instance. * diff --git a/react/features/video-menu/components/native/MuteRemoteParticipantDialog.js b/react/features/video-menu/components/native/MuteRemoteParticipantDialog.js deleted file mode 100644 index 727dc857c3..0000000000 --- a/react/features/video-menu/components/native/MuteRemoteParticipantDialog.js +++ /dev/null @@ -1,32 +0,0 @@ -// @flow - -import React from 'react'; - -import { ConfirmDialog } from '../../../base/dialog'; -import { translate } from '../../../base/i18n'; -import { connect } from '../../../base/redux'; -import AbstractMuteRemoteParticipantDialog - from '../AbstractMuteRemoteParticipantDialog'; - -/** - * Dialog to confirm a remote participant mute action. - */ -class MuteRemoteParticipantDialog extends AbstractMuteRemoteParticipantDialog { - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - return ( - - ); - } - - _onSubmit: () => boolean; -} - -export default translate(connect()(MuteRemoteParticipantDialog)); diff --git a/react/features/video-menu/components/native/index.js b/react/features/video-menu/components/native/index.js index 64ca607e37..6565a3c609 100644 --- a/react/features/video-menu/components/native/index.js +++ b/react/features/video-menu/components/native/index.js @@ -5,7 +5,6 @@ export { default as GrantModeratorDialog } from './GrantModeratorDialog'; export { default as KickRemoteParticipantDialog } from './KickRemoteParticipantDialog'; export { default as MuteEveryoneDialog } from './MuteEveryoneDialog'; export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog'; -export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog'; export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog'; export { default as RemoteVideoMenu } from './RemoteVideoMenu'; export { default as SharedVideoMenu } from './SharedVideoMenu'; diff --git a/react/features/video-menu/components/web/GrantModeratorDialog.js b/react/features/video-menu/components/web/GrantModeratorDialog.js index b99275ebe8..8f990b9e26 100644 --- a/react/features/video-menu/components/web/GrantModeratorDialog.js +++ b/react/features/video-menu/components/web/GrantModeratorDialog.js @@ -6,7 +6,7 @@ import { Dialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; import { connect } from '../../../base/redux'; import AbstractGrantModeratorDialog - from '../AbstractGrantModeratorDialog'; +, { abstractMapStateToProps } from '../AbstractGrantModeratorDialog'; /** * Dialog to confirm a grant moderator action. @@ -26,7 +26,7 @@ class GrantModeratorDialog extends AbstractGrantModeratorDialog { titleKey = 'dialog.grantModeratorTitle' width = 'small'>

- { this.props.t('dialog.grantModeratorDialog') } + { this.props.t('dialog.grantModeratorDialog', { participantName: this.props.participantName }) }
); @@ -35,4 +35,4 @@ class GrantModeratorDialog extends AbstractGrantModeratorDialog { _onSubmit: () => boolean; } -export default translate(connect()(GrantModeratorDialog)); +export default translate(connect(abstractMapStateToProps)(GrantModeratorDialog)); diff --git a/react/features/video-menu/components/web/MuteEveryoneDialog.js b/react/features/video-menu/components/web/MuteEveryoneDialog.js index 7bc878bf03..281b436d87 100644 --- a/react/features/video-menu/components/web/MuteEveryoneDialog.js +++ b/react/features/video-menu/components/web/MuteEveryoneDialog.js @@ -4,8 +4,10 @@ import React from 'react'; import { Dialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; +import { Switch } from '../../../base/react'; import { connect } from '../../../base/redux'; -import AbstractMuteEveryoneDialog, { abstractMapStateToProps, type Props } from '../AbstractMuteEveryoneDialog'; +import AbstractMuteEveryoneDialog, { abstractMapStateToProps, type Props } + from '../AbstractMuteEveryoneDialog'; /** * A React Component with the contents for a dialog that asks for confirmation @@ -14,6 +16,23 @@ import AbstractMuteEveryoneDialog, { abstractMapStateToProps, type Props } from * @extends AbstractMuteEveryoneDialog */ class MuteEveryoneDialog extends AbstractMuteEveryoneDialog { + + /** + * Toggles advanced moderation switch. + * + * @returns {void} + */ + _onToggleModeration() { + this.setState(state => { + return { + audioModerationEnabled: !state.audioModerationEnabled, + content: this.props.t(state.audioModerationEnabled + ? 'dialog.muteEveryoneDialog' : 'dialog.muteEveryoneDialogModerationOn' + ) + }; + }); + } + /** * Implements React's {@link Component#render()}. * @@ -27,8 +46,22 @@ class MuteEveryoneDialog extends AbstractMuteEveryoneDialog { onSubmit = { this._onSubmit } titleString = { this.props.title } width = 'small'> -
- { this.props.content } +
+ { this.state.content } + {this.props.exclude.length === 0 && ( + <> +
+
+ + +
+ + )}
); diff --git a/react/features/video-menu/components/web/MuteEveryonesVideoDialog.js b/react/features/video-menu/components/web/MuteEveryonesVideoDialog.js index 592c595407..e4bc7d37c5 100644 --- a/react/features/video-menu/components/web/MuteEveryonesVideoDialog.js +++ b/react/features/video-menu/components/web/MuteEveryonesVideoDialog.js @@ -4,6 +4,7 @@ import React from 'react'; import { Dialog } from '../../../base/dialog'; import { translate } from '../../../base/i18n'; +import { Switch } from '../../../base/react'; import { connect } from '../../../base/redux'; import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props } from '../AbstractMuteEveryonesVideoDialog'; @@ -15,6 +16,23 @@ import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props } * @extends AbstractMuteEveryonesVideoDialog */ class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog { + + /** + * Toggles advanced moderation switch. + * + * @returns {void} + */ + _onToggleModeration() { + this.setState(state => { + return { + moderationEnabled: !state.moderationEnabled, + content: this.props.t(state.moderationEnabled + ? 'dialog.muteEveryonesVideoDialog' : 'dialog.muteEveryonesVideoDialogModerationOn' + ) + }; + }); + } + /** * Implements React's {@link Component#render()}. * @@ -28,8 +46,22 @@ class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog { onSubmit = { this._onSubmit } titleString = { this.props.title } width = 'small'> -
- { this.props.content } +
+ {this.state.content} + {this.props.exclude.length === 0 && ( + <> +
+
+ + +
+ + )}
); diff --git a/react/features/video-menu/components/web/MuteRemoteParticipantDialog.js b/react/features/video-menu/components/web/MuteRemoteParticipantDialog.js deleted file mode 100644 index e1aef92ea4..0000000000 --- a/react/features/video-menu/components/web/MuteRemoteParticipantDialog.js +++ /dev/null @@ -1,41 +0,0 @@ -/* @flow */ - -import React from 'react'; - -import { Dialog } from '../../../base/dialog'; -import { translate } from '../../../base/i18n'; -import { connect } from '../../../base/redux'; -import AbstractMuteRemoteParticipantDialog - from '../AbstractMuteRemoteParticipantDialog'; - -/** - * A React Component with the contents for a dialog that asks for confirmation - * from the user before muting a remote participant. - * - * @extends Component - */ -class MuteRemoteParticipantDialog extends AbstractMuteRemoteParticipantDialog { - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - return ( - -
- { this.props.t('dialog.muteParticipantBody') } -
-
- ); - } - - _onSubmit: () => boolean; -} - -export default translate(connect()(MuteRemoteParticipantDialog)); diff --git a/react/features/video-menu/components/web/index.js b/react/features/video-menu/components/web/index.js index 48e6eb4e19..a700adfa61 100644 --- a/react/features/video-menu/components/web/index.js +++ b/react/features/video-menu/components/web/index.js @@ -11,7 +11,6 @@ export { default as MuteEveryoneDialog } from './MuteEveryoneDialog'; export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog'; export { default as MuteEveryoneElseButton } from './MuteEveryoneElseButton'; export { default as MuteEveryoneElsesVideoButton } from './MuteEveryoneElsesVideoButton'; -export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog'; export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog'; export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton'; export { REMOTE_CONTROL_MENU_STATES, default as RemoteControlButton } from './RemoteControlButton';