feat(av-moderation) Updated Advanced moderation (#9875)

Co-authored-by: Vlad Piersec <vlad.piersec@8x8.com>
This commit is contained in:
robertpin 2021-09-10 14:05:16 +03:00 committed by GitHub
parent f2e2d52cfd
commit 1dc8bfa631
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 1435 additions and 580 deletions

View File

@ -29,7 +29,7 @@
margin: 8px 16px 8px 0; margin: 8px 16px 8px 0;
} }
@media (max-width: 375px) { @media (max-width: 580px) {
.participants_pane { .participants_pane {
height: 100vh; height: 100vh;
height: -webkit-fill-available; height: -webkit-fill-available;

View File

@ -98,6 +98,7 @@ $flagsImagePath: "../images/";
@import 'country-picker'; @import 'country-picker';
@import 'modals/invite/invite_more'; @import 'modals/invite/invite_more';
@import 'modals/security/security'; @import 'modals/security/security';
@import 'modals/mute/mute-dialog';
@import 'e2ee'; @import 'e2ee';
@import 'responsive'; @import 'responsive';
@import 'drawer'; @import 'drawer';

View File

@ -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;
}
}
}

0
eslint Normal file
View File

View File

@ -216,8 +216,8 @@
"embedMeeting": "Embed meeting", "embedMeeting": "Embed meeting",
"error": "Error", "error": "Error",
"gracefulShutdown": "Our service is currently down for maintenance. Please try again later.", "gracefulShutdown": "Our service is currently down for maintenance. Please try again later.",
"grantModeratorDialog": "Are you sure you want to make this participant a moderator?", "grantModeratorDialog": "Are you sure you want to grant moderator rights to {{participantName}}?",
"grantModeratorTitle": "Grant moderator", "grantModeratorTitle": "Grant moderator rights",
"hideShareAudioHelper": "Don't show this dialog again", "hideShareAudioHelper": "Don't show this dialog again",
"IamHost": "I am the host", "IamHost": "I am the host",
"incorrectRoomLockPassword": "Incorrect password", "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.", "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!", "micTimeoutError": "Could not start audio source. Timeout occured!",
"micUnknownError": "Cannot use microphone for an unknown reason.", "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.", "muteEveryoneElseDialog": "Once muted, you won't be able to unmute them, but they can unmute themselves at any time.",
"muteEveryoneElseTitle": "Mute everyone except {{whom}}?", "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?", "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.", "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}}?", "muteEveryoneElsesVideoTitle": "Stop everyone's video 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.", "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", "muteEveryonesVideoDialogOk": "Disable",
"muteEveryonesVideoTitle": "Disable everyone's camera?", "muteEveryonesVideoTitle": "Stop everyone's video?",
"muteEveryoneSelf": "yourself", "muteEveryoneSelf": "yourself",
"muteEveryoneStartMuted": "Everyone starts muted from now on", "muteEveryoneStartMuted": "Everyone starts muted from now on",
"muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.", "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.", "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.", "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?", "muteParticipantTitle": "Mute this participant?",
"muteParticipantsVideoButton": "Disable camera", "muteParticipantsVideoButton": "Stop camera",
"muteParticipantsVideoTitle": "Disable camera of this participant?", "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.", "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", "noDropboxToken": "No valid Dropbox token",
@ -542,29 +546,30 @@
"lockRoomPasswordUppercase": "Password", "lockRoomPasswordUppercase": "Password",
"me": "me", "me": "me",
"notify": { "notify": {
"allowAction": "Allow",
"allowedUnmute": "You can unmute your microphone, start your camera or share your screen.",
"connectedOneMember": "{{name}} joined the meeting", "connectedOneMember": "{{name}} joined the meeting",
"connectedThreePlusMembers": "{{name}} and many others joined the meeting", "connectedThreePlusMembers": "{{name}} and many others joined the meeting",
"connectedTwoMembers": "{{first}} and {{second}} joined the meeting", "connectedTwoMembers": "{{first}} and {{second}} joined the meeting",
"disconnected": "disconnected", "disconnected": "disconnected",
"focus": "Conference focus", "focus": "Conference focus",
"focusFail": "{{component}} not available - retry in {{ms}} sec", "focusFail": "{{component}} not available - retry in {{ms}} sec",
"grantedTo": "Moderator rights granted to {{to}}!", "hostAskedUnmute": "The moderator would like you to speak",
"hostAskedUnmute": "The host would like you to unmute",
"invitedOneMember": "{{name}} has been invited", "invitedOneMember": "{{name}} has been invited",
"invitedThreePlusMembers": "{{name}} and {{count}} others have been invited", "invitedThreePlusMembers": "{{name}} and {{count}} others have been invited",
"invitedTwoMembers": "{{first}} and {{second}} have been invited", "invitedTwoMembers": "{{first}} and {{second}} have been invited",
"kickParticipant": "{{kicked}} was kicked by {{kicker}}", "kickParticipant": "{{kicked}} was kicked by {{kicker}}",
"me": "Me", "me": "Me",
"moderator": "Moderator rights granted!", "moderator": "You're now a moderator",
"muted": "You have started the conversation muted.", "muted": "You have started the conversation muted.",
"mutedTitle": "You're 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.", "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.", "videoMutedRemotelyDescription": "You can always turn it on again.",
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) removed by another participant", "passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) removed by another participant",
"passwordSetRemotely": "$t(lockRoomPasswordUppercase) set 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.", "screenShareNoAudio": " Share audio box was not checked in the window selection screen.",
"screenShareNoAudioTitle": "Couldn't share system audio!", "screenShareNoAudioTitle": "Couldn't share system audio!",
"somebody": "Somebody", "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 ", "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", "oldElectronClientDescription2": "latest build",
"oldElectronClientDescription3": " now!", "oldElectronClientDescription3": " now!",
"moderationInEffectDescription": "Please raise hand if you want to speak", "moderationInEffectDescription": "Please raise hand if you want to speak.",
"moderationInEffectCSDescription": "Please raise hand if you want to share your video", "moderationInEffectCSDescription": "Please raise hand if you want to share your screen.",
"moderationInEffectVideoDescription": "Please raise your hand if you want your video to be visible", "moderationInEffectVideoDescription": "Please raise your hand if you want to start your camera.",
"moderationInEffectTitle": "The microphone is muted by the moderator", "moderationInEffectTitle": "Your microphone is muted by the moderator",
"moderationInEffectCSTitle": "Content sharing is disabled by moderator", "moderationInEffectCSTitle": "Screen sharing is blocked by the moderator",
"moderationInEffectVideoTitle": "The video is muted by the moderator", "moderationInEffectVideoTitle": "Your camera is blocked by the moderator",
"moderationRequestFromModerator": "The host would like you to unmute", "moderationRequestFromModerator": "The host would like you to unmute",
"moderationRequestFromParticipant": "Wants to speak", "moderationRequestFromParticipant": "Wants to speak",
"moderationStartedTitle": "Moderation started", "moderationStartedTitle": "Moderation started",
@ -605,16 +610,17 @@
}, },
"actions": { "actions": {
"allow": "Allow attendees to:", "allow": "Allow attendees to:",
"audioModeration": "Unmute themselves",
"blockEveryoneMicCamera": "Block everyone's mic and camera", "blockEveryoneMicCamera": "Block everyone's mic and camera",
"invite": "Invite Someone", "invite": "Invite Someone",
"askUnmute": "Ask to unmute", "askUnmute": "Ask to unmute",
"mute": "Mute", "mute": "Mute",
"muteAll": "Mute all", "muteAll": "Mute all",
"muteEveryoneElse": "Mute everyone else", "muteEveryoneElse": "Mute everyone else",
"startModeration": "Unmute themselves or start video",
"stopEveryonesVideo": "Stop everyone's video", "stopEveryonesVideo": "Stop everyone's video",
"stopVideo": "Stop 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", "passwordSetRemotely": "Set by another participant",
@ -868,7 +874,7 @@
"embedMeeting": "Embed meeting", "embedMeeting": "Embed meeting",
"feedback": "Leave feedback", "feedback": "Leave feedback",
"fullScreen": "Toggle full screen", "fullScreen": "Toggle full screen",
"grantModerator": "Grant Moderator", "grantModerator": "Grant Moderator Rights",
"hangup": "Leave the meeting", "hangup": "Leave the meeting",
"help": "Help", "help": "Help",
"invite": "Invite people", "invite": "Invite people",
@ -1054,7 +1060,7 @@
"domuteOthers": "Mute everyone else", "domuteOthers": "Mute everyone else",
"domuteVideoOfOthers": "Disable camera of everyone else", "domuteVideoOfOthers": "Disable camera of everyone else",
"flip": "Flip", "flip": "Flip",
"grantModerator": "Grant Moderator", "grantModerator": "Grant Moderator Rights",
"kick": "Kick out", "kick": "Kick out",
"moderator": "Moderator", "moderator": "Moderator",
"mute": "Participant is muted", "mute": "Participant is muted",

View File

@ -47,6 +47,7 @@
"base64-js": "1.3.1", "base64-js": "1.3.1",
"bc-css-flags": "3.0.0", "bc-css-flags": "3.0.0",
"clipboard-copy": "4.0.1", "clipboard-copy": "4.0.1",
"clsx": "1.1.1",
"dropbox": "10.7.0", "dropbox": "10.7.0",
"focus-visible": "5.1.0", "focus-visible": "5.1.0",
"i18n-iso-countries": "6.8.0", "i18n-iso-countries": "6.8.0",

View File

@ -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. * The type of (redux) action which signals that the local participant had been approved.

View File

@ -11,9 +11,12 @@ import {
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION, LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
PARTICIPANT_APPROVED, PARTICIPANT_APPROVED,
PARTICIPANT_PENDING_AUDIO, PARTICIPANT_PENDING_AUDIO,
REQUEST_DISABLE_MODERATION, REQUEST_DISABLE_AUDIO_MODERATION,
REQUEST_ENABLE_MODERATION REQUEST_ENABLE_AUDIO_MODERATION,
REQUEST_DISABLE_VIDEO_MODERATION,
REQUEST_ENABLE_VIDEO_MODERATION
} from './actionTypes'; } from './actionTypes';
import { isEnabledFromState } from './functions';
/** /**
* Action used by moderator to approve audio and video for a participant. * Action used by moderator to approve audio and video for a participant.
@ -22,10 +25,15 @@ import {
* @returns {void} * @returns {void}
*/ */
export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => { export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => {
const { conference } = getConferenceState(getState()); const state = getState();
const { conference } = getConferenceState(state);
if (isEnabledFromState(MEDIA_TYPE.AUDIO, state)) {
conference.avModerationApprove(MEDIA_TYPE.AUDIO, id); conference.avModerationApprove(MEDIA_TYPE.AUDIO, id);
}
if (isEnabledFromState(MEDIA_TYPE.VIDEO, state)) {
conference.avModerationApprove(MEDIA_TYPE.VIDEO, id); 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 {{ * @returns {{
* type: REQUEST_DISABLE_MODERATED_AUDIO * type: REQUEST_DISABLE_AUDIO_MODERATION
* }} * }}
*/ */
export const requestDisableModeration = () => { export const requestDisableAudioModeration = () => {
return { return {
type: REQUEST_DISABLE_MODERATION type: REQUEST_DISABLE_AUDIO_MODERATION
}; };
}; };
/** /**
* Requests enabled audio & video moderation. * Requests disable of video moderation.
* *
* @returns {{ * @returns {{
* type: REQUEST_ENABLE_MODERATED_AUDIO * type: REQUEST_DISABLE_VIDEO_MODERATION
* }} * }}
*/ */
export const requestEnableModeration = () => { export const requestDisableVideoModeration = () => {
return { return {
type: REQUEST_ENABLE_MODERATION 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 requestEnableVideoModeration = () => {
return {
type: REQUEST_ENABLE_VIDEO_MODERATION
}; };
}; };

View File

@ -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
? (
<>
<div className = 'title'>
{ t('raisedHand') }
</div>
<NotificationWithParticipants
approveButtonText = { t('notify.unmute') }
onApprove = { approveParticipant }
onReject = { dismissPendingAudioParticipant }
participants = { participants }
rejectButtonText = { t('dialog.dismiss') }
testIdPrefix = 'avModeration' />
</>
) : null;
}

View File

@ -6,7 +6,6 @@ import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../base/media'; import { MEDIA_TYPE } from '../base/media';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipantDisplayName,
getRemoteParticipants, getRemoteParticipants,
isLocalParticipantModerator, isLocalParticipantModerator,
isParticipantModerator, isParticipantModerator,
@ -16,16 +15,16 @@ import {
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { import {
hideNotification, hideNotification,
NOTIFICATION_TIMEOUT,
showNotification showNotification
} from '../notifications'; } from '../notifications';
import { muteLocal } from '../video-menu/actions.any';
import { import {
DISABLE_MODERATION,
ENABLE_MODERATION,
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION, LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
REQUEST_DISABLE_MODERATION, REQUEST_DISABLE_AUDIO_MODERATION,
REQUEST_ENABLE_MODERATION REQUEST_DISABLE_VIDEO_MODERATION,
REQUEST_ENABLE_AUDIO_MODERATION,
REQUEST_ENABLE_VIDEO_MODERATION
} from './actionTypes'; } from './actionTypes';
import { import {
disableModeration, disableModeration,
@ -47,29 +46,10 @@ const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation';
const CS_MODERATION_NOTIFICATION_ID = 'video-moderation'; const CS_MODERATION_NOTIFICATION_ID = 'video-moderation';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const { actor, mediaType, type } = action; const { type } = action;
const { conference } = getConferenceState(getState());
switch (type) { 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: { case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: {
let descriptionKey; let descriptionKey;
let titleKey; let titleKey;
@ -78,19 +58,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.mediaType) { switch (action.mediaType) {
case MEDIA_TYPE.AUDIO: { case MEDIA_TYPE.AUDIO: {
titleKey = 'notify.moderationInEffectTitle'; titleKey = 'notify.moderationInEffectTitle';
descriptionKey = 'notify.moderationInEffectDescription';
uid = AUDIO_MODERATION_NOTIFICATION_ID; uid = AUDIO_MODERATION_NOTIFICATION_ID;
break; break;
} }
case MEDIA_TYPE.VIDEO: { case MEDIA_TYPE.VIDEO: {
titleKey = 'notify.moderationInEffectVideoTitle'; titleKey = 'notify.moderationInEffectVideoTitle';
descriptionKey = 'notify.moderationInEffectVideoDescription';
uid = VIDEO_MODERATION_NOTIFICATION_ID; uid = VIDEO_MODERATION_NOTIFICATION_ID;
break; break;
} }
case MEDIA_TYPE.PRESENTER: { case MEDIA_TYPE.PRESENTER: {
titleKey = 'notify.moderationInEffectCSTitle'; titleKey = 'notify.moderationInEffectCSTitle';
descriptionKey = 'notify.moderationInEffectCSDescription';
uid = CS_MODERATION_NOTIFICATION_ID; uid = CS_MODERATION_NOTIFICATION_ID;
break; break;
} }
@ -110,17 +87,19 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break; break;
} }
case REQUEST_DISABLE_MODERATION: { case REQUEST_DISABLE_AUDIO_MODERATION: {
const { conference } = getConferenceState(getState());
conference.disableAVModeration(MEDIA_TYPE.AUDIO); conference.disableAVModeration(MEDIA_TYPE.AUDIO);
break;
}
case REQUEST_DISABLE_VIDEO_MODERATION: {
conference.disableAVModeration(MEDIA_TYPE.VIDEO); conference.disableAVModeration(MEDIA_TYPE.VIDEO);
break; break;
} }
case REQUEST_ENABLE_MODERATION: { case REQUEST_ENABLE_AUDIO_MODERATION: {
const { conference } = getConferenceState(getState());
conference.enableAVModeration(MEDIA_TYPE.AUDIO); conference.enableAVModeration(MEDIA_TYPE.AUDIO);
break;
}
case REQUEST_ENABLE_VIDEO_MODERATION: {
conference.enableAVModeration(MEDIA_TYPE.VIDEO); conference.enableAVModeration(MEDIA_TYPE.VIDEO);
break; break;
} }
@ -174,11 +153,12 @@ StateListenerRegistry.register(
// Audio & video moderation are both enabled at the same time. // Audio & video moderation are both enabled at the same time.
// Avoid displaying 2 different notifications. // Avoid displaying 2 different notifications.
if (mediaType === MEDIA_TYPE.VIDEO) { if (mediaType === MEDIA_TYPE.AUDIO) {
dispatch(showNotification({ dispatch(showNotification({
titleKey: 'notify.unmute', titleKey: 'notify.hostAskedUnmute',
descriptionKey: 'notify.hostAskedUnmute', sticky: true,
sticky: true customActionNameKey: 'notify.unmute',
customActionHandler: () => dispatch(muteLocal(false, MEDIA_TYPE.AUDIO))
})); }));
} }
}); });

View File

@ -1,3 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.84074 5.49992H6.81762L3.42398 2.10629C3.06002 1.74232 2.47001 1.74223 2.10617 2.10608C1.74232 2.46992 1.74241 3.05993 2.10638 3.42389L4.1824 5.49992H3.66668C2.65415 5.49992 1.83334 6.32073 1.83334 7.33325V14.6666C1.83334 15.6791 2.65415 16.4999 3.66668 16.4999H13.75C14.154 16.4999 14.5274 16.3693 14.8304 16.1479L18.576 19.8936C18.94 20.2575 19.53 20.2576 19.8939 19.8938C20.2577 19.5299 20.2576 18.9399 19.8936 18.5759L15.5833 14.2656V14.2425L13.75 12.4092V12.4323L8.65095 7.33325H8.67407L6.84074 5.49992ZM13.75 9.77398V9.16659V7.33325H11.3093L9.47595 5.49992H13.75C14.7625 5.49992 15.5833 6.32073 15.5833 7.33325V8.11897L18.7952 6.28361C19.2348 6.03243 19.7947 6.18515 20.0459 6.62471C20.125 6.76321 20.1667 6.91998 20.1667 7.0795V14.9203C20.1667 15.2643 19.9772 15.5641 19.6969 15.7209L15.9614 11.9853L18.3333 13.3408V8.65908L15.5833 10.2305V11.6073L13.75 9.77398ZM3.66668 7.33325H6.01574L13.3491 14.6666H3.66668V7.33325Z" /> <path fill-rule="evenodd" clip-rule="evenodd" d="M6.84074 5.49992H6.81762L3.42398 2.10629C3.06002 1.74232 2.47001 1.74223 2.10617 2.10608C1.74232 2.46992 1.74241 3.05993 2.10638 3.42389L4.1824 5.49992H3.66668C2.65415 5.49992 1.83334 6.32073 1.83334 7.33325V14.6666C1.83334 15.6791 2.65415 16.4999 3.66668 16.4999H13.75C14.154 16.4999 14.5274 16.3693 14.8304 16.1479L18.576 19.8936C18.94 20.2575 19.53 20.2576 19.8939 19.8938C20.2577 19.5299 20.2576 18.9399 19.8936 18.5759L15.5833 14.2656V14.2425L13.75 12.4092V12.4323L8.65095 7.33325H8.67407L6.84074 5.49992ZM13.75 9.77398V9.16659V7.33325H11.3093L9.47595 5.49992H13.75C14.7625 5.49992 15.5833 6.32073 15.5833 7.33325V8.11897L18.7952 6.28361C19.2348 6.03243 19.7947 6.18515 20.0459 6.62471C20.125 6.76321 20.1667 6.91998 20.1667 7.0795V14.9203C20.1667 15.2643 19.9772 15.5641 19.6969 15.7209L15.9614 11.9853L18.3333 13.3408V8.65908L15.5833 10.2305V11.6073L13.75 9.77398ZM3.66668 7.33325H6.01574L13.3491 14.6666H3.66668V7.33325Z" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -8,12 +8,15 @@ import {
sendAnalytics sendAnalytics
} from '../../analytics'; } from '../../analytics';
import { APP_STATE_CHANGED } from '../../mobile/background'; import { APP_STATE_CHANGED } from '../../mobile/background';
import { isForceMuted } from '../../participants-pane/functions';
import { SET_AUDIO_ONLY, setAudioOnly } from '../audio-only'; import { SET_AUDIO_ONLY, setAudioOnly } from '../audio-only';
import { isRoomValid, SET_ROOM } from '../conference'; import { isRoomValid, SET_ROOM } from '../conference';
import { getLocalParticipant } from '../participants';
import { MiddlewareRegistry } from '../redux'; import { MiddlewareRegistry } from '../redux';
import { getPropertyValue } from '../settings'; import { getPropertyValue } from '../settings';
import { isLocalVideoTrackDesktop, setTrackMuted, TRACK_ADDED } from '../tracks'; import { isLocalVideoTrackDesktop, setTrackMuted, TRACK_ADDED } from '../tracks';
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from './actionTypes';
import { setAudioMuted, setCameraFacingMode, setVideoMuted } from './actions'; import { setAudioMuted, setCameraFacingMode, setVideoMuted } from './actions';
import { import {
CAMERA_FACING_MODE, CAMERA_FACING_MODE,
@ -55,6 +58,26 @@ MiddlewareRegistry.register(store => next => action => {
return result; 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); return next(action);

View File

@ -180,3 +180,15 @@ export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL';
* } * }
*/ */
export const LOCAL_PARTICIPANT_RAISE_HAND = 'LOCAL_PARTICIPANT_RAISE_HAND'; 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';

View File

@ -15,7 +15,8 @@ import {
PARTICIPANT_LEFT, PARTICIPANT_LEFT,
PARTICIPANT_UPDATED, PARTICIPANT_UPDATED,
PIN_PARTICIPANT, PIN_PARTICIPANT,
SET_LOADABLE_AVATAR_URL SET_LOADABLE_AVATAR_URL,
RAISE_HAND_UPDATED
} from './actionTypes'; } from './actionTypes';
import { import {
DISCO_REMOTE_CONTROL_FEATURE DISCO_REMOTE_CONTROL_FEATURE
@ -465,7 +466,7 @@ export function participantUpdated(participant = {}) {
* @returns {Promise} * @returns {Promise}
*/ */
export function participantMutedUs(participant, track) { export function participantMutedUs(participant, track) {
return (dispatch, getState) => { return dispatch => {
if (!participant) { if (!participant) {
return; return;
} }
@ -473,12 +474,7 @@ export function participantMutedUs(participant, track) {
const isAudio = track.isAudioTrack(); const isAudio = track.isAudioTrack();
dispatch(showNotification({ dispatch(showNotification({
descriptionKey: isAudio ? 'notify.mutedRemotelyDescription' : 'notify.videoMutedRemotelyDescription', titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle'
titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle',
titleArguments: {
participantDisplayName:
getParticipantDisplayName(getState, participant.getId())
}
})); }));
}; };
} }
@ -574,3 +570,19 @@ export function raiseHand(enabled) {
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
};
}

View File

@ -456,21 +456,35 @@ async function _getFirstLoadableAvatarUrl(participant, store) {
export function getSortedParticipants(stateful: Object | Function) { export function getSortedParticipants(stateful: Object | Function) {
const localParticipant = getLocalParticipant(stateful); const localParticipant = getLocalParticipant(stateful);
const remoteParticipants = getRemoteParticipants(stateful); const remoteParticipants = getRemoteParticipants(stateful);
const raisedHandParticipantIds = getRaiseHandsQueue(stateful);
const items = []; const items = [];
const dominantSpeaker = getDominantSpeakerParticipant(stateful); const dominantSpeaker = getDominantSpeakerParticipant(stateful);
const raisedHandParticipants = [];
raisedHandParticipantIds
.map(id => remoteParticipants.get(id) || localParticipant)
.forEach(p => {
if (p !== dominantSpeaker) {
raisedHandParticipants.push(p);
}
});
remoteParticipants.forEach(p => { remoteParticipants.forEach(p => {
if (p !== dominantSpeaker) { if (p !== dominantSpeaker && !raisedHandParticipantIds.find(id => p.id === id)) {
items.push(p); items.push(p);
} }
}); });
if (!raisedHandParticipantIds.find(id => localParticipant.id === id)) {
items.push(localParticipant);
}
items.sort((a, b) => items.sort((a, b) =>
getParticipantDisplayName(stateful, a.id).localeCompare(getParticipantDisplayName(stateful, b.id)) getParticipantDisplayName(stateful, a.id).localeCompare(getParticipantDisplayName(stateful, b.id))
); );
items.unshift(localParticipant); items.unshift(...raisedHandParticipants);
if (dominantSpeaker && dominantSpeaker !== localParticipant) { if (dominantSpeaker && dominantSpeaker !== localParticipant) {
items.unshift(dominantSpeaker); items.unshift(dominantSpeaker);
@ -492,3 +506,17 @@ export function getSortedParticipantIds(stateful: Object | Function): Array<stri
return participantIds; return participantIds;
} }
/**
* Get the participants queue with raised hands.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state
* features/base/participants.
* @returns {Array<string>}
*/
export function getRaiseHandsQueue(stateful: Object | Function): Array<string> {
const { raisedHandsQueue } = toState(stateful)['features/base/participants'];
return raisedHandsQueue;
}

View File

@ -3,8 +3,10 @@
import { batch } from 'react-redux'; import { batch } from 'react-redux';
import UIEvents from '../../../../service/UI/UIEvents'; import UIEvents from '../../../../service/UI/UIEvents';
import { approveParticipant } from '../../av-moderation/actions';
import { toggleE2EE } from '../../e2ee/actions'; import { toggleE2EE } from '../../e2ee/actions';
import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications'; import { NOTIFICATION_TIMEOUT, showNotification } from '../../notifications';
import { isForceMuted } from '../../participants-pane/functions';
import { CALLING, INVITED } from '../../presence-status'; import { CALLING, INVITED } from '../../presence-status';
import { RAISE_HAND_SOUND_ID } from '../../reactions/constants'; import { RAISE_HAND_SOUND_ID } from '../../reactions/constants';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../app';
@ -15,6 +17,7 @@ import {
} from '../conference'; } from '../conference';
import { getDisableRemoveRaisedHandOnFocus } from '../config/functions.any'; import { getDisableRemoveRaisedHandOnFocus } from '../config/functions.any';
import { JitsiConferenceEvents } from '../lib-jitsi-meet'; import { JitsiConferenceEvents } from '../lib-jitsi-meet';
import { MEDIA_TYPE } from '../media';
import { MiddlewareRegistry, StateListenerRegistry } from '../redux'; import { MiddlewareRegistry, StateListenerRegistry } from '../redux';
import { playSound, registerSound, unregisterSound } from '../sounds'; import { playSound, registerSound, unregisterSound } from '../sounds';
@ -27,7 +30,8 @@ import {
PARTICIPANT_DISPLAY_NAME_CHANGED, PARTICIPANT_DISPLAY_NAME_CHANGED,
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
PARTICIPANT_LEFT, PARTICIPANT_LEFT,
PARTICIPANT_UPDATED PARTICIPANT_UPDATED,
RAISE_HAND_UPDATED
} from './actionTypes'; } from './actionTypes';
import { import {
localParticipantIdChanged, localParticipantIdChanged,
@ -35,6 +39,7 @@ import {
localParticipantLeft, localParticipantLeft,
participantLeft, participantLeft,
participantUpdated, participantUpdated,
raiseHandUpdateQueue,
setLoadableAvatarUrl setLoadableAvatarUrl
} from './actions'; } from './actions';
import { import {
@ -48,7 +53,9 @@ import {
getParticipantById, getParticipantById,
getParticipantCount, getParticipantCount,
getParticipantDisplayName, getParticipantDisplayName,
getRemoteParticipants getRaiseHandsQueue,
getRemoteParticipants,
isLocalParticipantModerator
} from './functions'; } from './functions';
import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds'; import { PARTICIPANT_JOINED_FILE, PARTICIPANT_LEFT_FILE } from './sounds';
@ -122,6 +129,11 @@ MiddlewareRegistry.register(store => next => action => {
const { enabled } = action; const { enabled } = action;
const localId = getLocalParticipant(store.getState())?.id; const localId = getLocalParticipant(store.getState())?.id;
store.dispatch(raiseHandUpdateQueue({
id: localId,
raisedHand: enabled
}));
store.dispatch(participantUpdated({ store.dispatch(participantUpdated({
// XXX Only the local participant is allowed to update without // XXX Only the local participant is allowed to update without
// stating the JitsiConference instance (i.e. participant property // stating the JitsiConference instance (i.e. participant property
@ -162,6 +174,21 @@ MiddlewareRegistry.register(store => next => action => {
break; 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: { case PARTICIPANT_JOINED: {
_maybePlaySounds(store, action); _maybePlaySounds(store, action);
@ -424,6 +451,7 @@ function _participantJoinedOrUpdated(store, next, action) {
// Send an external update of the local participant's raised hand state // Send an external update of the local participant's raised hand state
// if a new raised hand state is defined in the action. // if a new raised hand state is defined in the action.
if (typeof raisedHand !== 'undefined') { if (typeof raisedHand !== 'undefined') {
if (local) { if (local) {
const { conference } = getState()['features/base/conference']; const { conference } = getState()['features/base/conference'];
@ -476,6 +504,7 @@ function _participantJoinedOrUpdated(store, next, action) {
*/ */
function _raiseHandUpdated({ dispatch, getState }, conference, participantId, newValue) { function _raiseHandUpdated({ dispatch, getState }, conference, participantId, newValue) {
const raisedHand = newValue === 'true'; const raisedHand = newValue === 'true';
const state = getState();
dispatch(participantUpdated({ dispatch(participantUpdated({
conference, conference,
@ -483,17 +512,37 @@ function _raiseHandUpdated({ dispatch, getState }, conference, participantId, ne
raisedHand raisedHand
})); }));
dispatch(raiseHandUpdateQueue({
id: participantId,
raisedHand
}));
if (typeof APP !== 'undefined') { if (typeof APP !== 'undefined') {
APP.API.notifyRaiseHandUpdated(participantId, raisedHand); 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) { if (raisedHand) {
dispatch(showNotification({ dispatch(showNotification({
titleArguments: { titleKey: 'notify.somebody',
name: getParticipantDisplayName(getState, participantId) title: getParticipantDisplayName(state, participantId),
}, descriptionKey: 'notify.raisedHand',
titleKey: 'notify.raisedHand' raiseHandNotification: true,
}, NOTIFICATION_TIMEOUT)); ...action
}, NOTIFICATION_TIMEOUT * (shouldDisplayAllowAction ? 2 : 1)));
dispatch(playSound(RAISE_HAND_SOUND_ID)); dispatch(playSound(RAISE_HAND_SOUND_ID));
} }
} }

View File

@ -10,6 +10,7 @@ import {
PARTICIPANT_LEFT, PARTICIPANT_LEFT,
PARTICIPANT_UPDATED, PARTICIPANT_UPDATED,
PIN_PARTICIPANT, PIN_PARTICIPANT,
RAISE_HAND_UPDATED,
SET_LOADABLE_AVATAR_URL SET_LOADABLE_AVATAR_URL
} from './actionTypes'; } from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants'; import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
@ -63,7 +64,8 @@ const DEFAULT_STATE = {
remote: new Map(), remote: new Map(),
sortedRemoteParticipants: new Map(), sortedRemoteParticipants: new Map(),
sortedRemoteScreenshares: 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 }; return { ...state };
} }
case RAISE_HAND_UPDATED: {
return {
...state,
raisedHandsQueue: action.queue
};
}
case SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED: { case SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED: {
const { participantIds } = action; const { participantIds } = action;
const sortedSharesList = []; const sortedSharesList = [];

View File

@ -14,7 +14,8 @@ import {
SET_VIDEO_MUTED, SET_VIDEO_MUTED,
VIDEO_MUTISM_AUTHORITY, VIDEO_MUTISM_AUTHORITY,
TOGGLE_CAMERA_FACING_MODE, TOGGLE_CAMERA_FACING_MODE,
toggleCameraFacingMode toggleCameraFacingMode,
VIDEO_TYPE
} from '../media'; } from '../media';
import { MiddlewareRegistry } from '../redux'; import { MiddlewareRegistry } from '../redux';
@ -28,6 +29,7 @@ import {
import { import {
createLocalTracksA, createLocalTracksA,
showNoDataFromSourceVideoError, showNoDataFromSourceVideoError,
toggleScreensharing,
trackNoDataFromSourceNotificationInfoChanged trackNoDataFromSourceNotificationInfoChanged
} from './actions'; } from './actions';
import { import {
@ -137,9 +139,9 @@ MiddlewareRegistry.register(store => next => action => {
case TOGGLE_SCREENSHARING: case TOGGLE_SCREENSHARING:
if (typeof APP === 'object') { if (typeof APP === 'object') {
// check for A/V Moderation when trying to start screen sharing // 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)); store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER));
return; return;
@ -171,8 +173,10 @@ MiddlewareRegistry.register(store => next => action => {
// Do not change the video mute state for local presenter tracks. // Do not change the video mute state for local presenter tracks.
if (jitsiTrack.type === MEDIA_TYPE.PRESENTER) { if (jitsiTrack.type === MEDIA_TYPE.PRESENTER) {
APP.conference.mutePresenter(muted); APP.conference.mutePresenter(muted);
} else if (jitsiTrack.isLocal()) { } else if (jitsiTrack.isLocal() && !(jitsiTrack.videoType === VIDEO_TYPE.DESKTOP)) {
APP.conference.setVideoMuteStatus(); APP.conference.setVideoMuteStatus();
} else if (jitsiTrack.isLocal() && muted && jitsiTrack.videoType === VIDEO_TYPE.DESKTOP) {
store.dispatch(toggleScreensharing(false));
} else { } else {
APP.UI.setVideoMuted(participantID); APP.UI.setVideoMuted(participantID);
} }

View File

@ -3,6 +3,7 @@
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout'; import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { getParticipantById } from '../base/participants/functions';
import { OPEN_CHAT } from './actionTypes'; import { OPEN_CHAT } from './actionTypes';
import { closeChat } from './actions.any'; 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. * Toggles display of the chat panel.
* *

View File

@ -4,7 +4,6 @@ import _ from 'lodash';
import React from 'react'; import React from 'react';
import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout'; import VideoLayout from '../../../../../modules/UI/videolayout/VideoLayout';
import AudioModerationNotifications from '../../../av-moderation/components/AudioModerationNotifications';
import { getConferenceNameForTitle } from '../../../base/conference'; import { getConferenceNameForTitle } from '../../../base/conference';
import { connect, disconnect } from '../../../base/connection'; import { connect, disconnect } from '../../../base/connection';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
@ -233,7 +232,6 @@ class Conference extends AbstractConference<Props, *> {
{!_isParticipantsPaneVisible {!_isParticipantsPaneVisible
&& <div id = 'notification-participant-list'> && <div id = 'notification-participant-list'>
<KnockingParticipantList /> <KnockingParticipantList />
<AudioModerationNotifications />
</div>} </div>}
<Filmstrip /> <Filmstrip />
</div> </div>

View File

@ -45,3 +45,13 @@ export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';
* } * }
*/ */
export const SET_NOTIFICATIONS_ENABLED = 'SET_NOTIFICATIONS_ENABLED'; 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';

View File

@ -9,6 +9,7 @@ import { getParticipantCount } from '../base/participants/functions';
import { import {
CLEAR_NOTIFICATIONS, CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION, HIDE_NOTIFICATION,
HIDE_RAISE_HAND_NOTIFICATIONS,
SET_NOTIFICATIONS_ENABLED, SET_NOTIFICATIONS_ENABLED,
SHOW_NOTIFICATION SHOW_NOTIFICATION
} from './actionTypes'; } 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. * Stops notifications from being displayed.
* *

View File

@ -7,12 +7,15 @@ import {
PARTICIPANT_ROLE, PARTICIPANT_ROLE,
PARTICIPANT_UPDATED, PARTICIPANT_UPDATED,
getParticipantById, getParticipantById,
getParticipantDisplayName getParticipantDisplayName,
getLocalParticipant
} from '../base/participants'; } from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import { PARTICIPANTS_PANE_OPEN } from '../participants-pane/actionTypes';
import { import {
clearNotifications, clearNotifications,
hideRaiseHandNotifications,
showNotification, showNotification,
showParticipantJoinedNotification showParticipantJoinedNotification
} from './actions'; } 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; return result;
} }
case PARTICIPANT_LEFT: { case PARTICIPANT_LEFT: {
@ -82,30 +69,36 @@ MiddlewareRegistry.register(store => next => action => {
return next(action); return next(action);
} }
case PARTICIPANT_UPDATED: { 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. // Do not show the notification for mobile and also when the focus indicator is disabled.
return next(action); return next(action);
} }
const { id, role } = action.participant; const { id, role } = action.participant;
const state = store.getState(); const state = store.getState();
const localParticipant = getLocalParticipant(state);
if (localParticipant.id !== id) {
return next(action);
}
const oldParticipant = getParticipantById(state, id); const oldParticipant = getParticipantById(state, id);
const oldRole = oldParticipant?.role; const oldRole = oldParticipant?.role;
if (oldRole && oldRole !== role && role === PARTICIPANT_ROLE.MODERATOR) { if (oldRole && oldRole !== role && role === PARTICIPANT_ROLE.MODERATOR) {
const displayName = getParticipantDisplayName(state, id);
store.dispatch(showNotification({ store.dispatch(showNotification({
descriptionArguments: { to: displayName || '$t(notify.somebody)' }, titleKey: 'notify.moderator'
descriptionKey: 'notify.grantedTo',
titleKey: 'notify.somebody',
title: displayName
}, },
NOTIFICATION_TIMEOUT)); NOTIFICATION_TIMEOUT));
} }
return next(action); return next(action);
} }
case PARTICIPANTS_PANE_OPEN: {
store.dispatch(hideRaiseHandNotifications());
break;
}
} }
return next(action); return next(action);

View File

@ -5,6 +5,7 @@ import { ReducerRegistry } from '../base/redux';
import { import {
CLEAR_NOTIFICATIONS, CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION, HIDE_NOTIFICATION,
HIDE_RAISE_HAND_NOTIFICATIONS,
SET_NOTIFICATIONS_ENABLED, SET_NOTIFICATIONS_ENABLED,
SHOW_NOTIFICATION SHOW_NOTIFICATION
} from './actionTypes'; } from './actionTypes';
@ -43,6 +44,14 @@ ReducerRegistry.register('features/notifications',
notification => notification.uid !== action.uid) notification => notification.uid !== action.uid)
}; };
case HIDE_RAISE_HAND_NOTIFICATIONS:
return {
...state,
notifications: state.notifications.filter(
notification => !notification.props.raiseHandNotification
)
};
case SET_NOTIFICATIONS_ENABLED: case SET_NOTIFICATIONS_ENABLED:
return { return {
...state, ...state,

View File

@ -1,11 +1,17 @@
// @flow // @flow
import { makeStyles } from '@material-ui/core/styles'; import { makeStyles } from '@material-ui/core/styles';
import clsx from 'clsx';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions'; import {
requestDisableAudioModeration,
requestDisableVideoModeration,
requestEnableAudioModeration,
requestEnableVideoModeration
} from '../../av-moderation/actions';
import { import {
isEnabled as isAvModerationEnabled, isEnabled as isAvModerationEnabled,
isSupported as isAvModerationSupported isSupported as isAvModerationSupported
@ -13,7 +19,10 @@ import {
import { openDialog } from '../../base/dialog'; import { openDialog } from '../../base/dialog';
import { Icon, IconCheck, IconVideoOff } from '../../base/icons'; import { Icon, IconCheck, IconVideoOff } from '../../base/icons';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { getLocalParticipant } from '../../base/participants'; import {
getParticipantCount,
isEveryoneModerator
} from '../../base/participants';
import { MuteEveryonesVideoDialog } from '../../video-menu/components'; import { MuteEveryonesVideoDialog } from '../../video-menu/components';
import { import {
@ -33,6 +42,17 @@ const useStyles = makeStyles(() => {
transform: 'translateY(-100%)', transform: 'translateY(-100%)',
width: '283px' width: '283px'
}, },
drawer: {
width: '100%',
top: 'auto',
bottom: 0,
transform: 'none',
position: 'relative',
'& > div': {
lineHeight: '32px'
}
},
text: { text: {
color: '#C2C2C2', color: '#C2C2C2',
padding: '10px 16px 10px 52px' padding: '10px 16px 10px 52px'
@ -46,30 +66,42 @@ const useStyles = makeStyles(() => {
type Props = { type Props = {
/** /**
* Callback for the mouse leaving this item * Whether the menu is displayed inside a drawer.
*/ */
onMouseLeave: Function 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 dispatch = useDispatch();
const isModerationSupported = useSelector(isAvModerationSupported()); const isModerationSupported = useSelector(isAvModerationSupported());
const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO)); const allModerators = useSelector(isEveryoneModerator);
const { id } = useSelector(getLocalParticipant); const participantCount = useSelector(getParticipantCount);
const isAudioModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
const isVideoModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.VIDEO));
const { t } = useTranslation(); const { t } = useTranslation();
const disable = useCallback(() => dispatch(requestDisableModeration()), [ dispatch ]); const disableAudioModeration = useCallback(() => dispatch(requestDisableAudioModeration()), [ dispatch ]);
const enable = useCallback(() => dispatch(requestEnableModeration()), [ dispatch ]); const disableVideoModeration = useCallback(() => dispatch(requestDisableVideoModeration()), [ dispatch ]);
const enableAudioModeration = useCallback(() => dispatch(requestEnableAudioModeration()), [ dispatch ]);
const enableVideoModeration = useCallback(() => dispatch(requestEnableVideoModeration()), [ dispatch ]);
const classes = useStyles(); const classes = useStyles();
const muteAllVideo = useCallback( const muteAllVideo = useCallback(
() => dispatch(openDialog(MuteEveryonesVideoDialog, { exclude: [ id ] })), [ dispatch ]); () => dispatch(openDialog(MuteEveryonesVideoDialog)), [ dispatch ]);
return ( return (
<ContextMenu <ContextMenu
className = { classes.contextMenu } className = { clsx(classes.contextMenu, inDrawer && clsx(classes.drawer)) }
onMouseLeave = { onMouseLeave }> onMouseLeave = { onMouseLeave }>
<ContextMenuItemGroup> <ContextMenuItemGroup>
<ContextMenuItem <ContextMenuItem
@ -81,27 +113,45 @@ export const FooterContextMenu = ({ onMouseLeave }: Props) => {
<span>{ t('participantsPane.actions.stopEveryonesVideo') }</span> <span>{ t('participantsPane.actions.stopEveryonesVideo') }</span>
</ContextMenuItem> </ContextMenuItem>
</ContextMenuItemGroup> </ContextMenuItemGroup>
{ isModerationSupported ? ( {isModerationSupported && (participantCount === 1 || !allModerators) ? (
<ContextMenuItemGroup> <ContextMenuItemGroup>
<div className = { classes.text }> <div className = { classes.text }>
{t('participantsPane.actions.allow')} {t('participantsPane.actions.allow')}
</div> </div>
{ isModerationEnabled ? ( { isAudioModerationEnabled ? (
<ContextMenuItem <ContextMenuItem
id = 'participants-pane-context-menu-stop-moderation' id = 'participants-pane-context-menu-stop-audio-moderation'
onClick = { disable }> onClick = { disableAudioModeration }>
<span className = { classes.paddedAction }> <span className = { classes.paddedAction }>
{ t('participantsPane.actions.startModeration') } {t('participantsPane.actions.audioModeration') }
</span> </span>
</ContextMenuItem> </ContextMenuItem>
) : ( ) : (
<ContextMenuItem <ContextMenuItem
id = 'participants-pane-context-menu-start-moderation' id = 'participants-pane-context-menu-start-audio-moderation'
onClick = { enable }> onClick = { enableAudioModeration }>
<Icon <Icon
size = { 20 } size = { 20 }
src = { IconCheck } /> src = { IconCheck } />
<span>{ t('participantsPane.actions.startModeration') }</span> <span>{t('participantsPane.actions.audioModeration') }</span>
</ContextMenuItem>
)}
{ isVideoModerationEnabled ? (
<ContextMenuItem
id = 'participants-pane-context-menu-stop-video-moderation'
onClick = { disableVideoModeration }>
<span className = { classes.paddedAction }>
{t('participantsPane.actions.videoModeration')}
</span>
</ContextMenuItem>
) : (
<ContextMenuItem
id = 'participants-pane-context-menu-start-video-moderation'
onClick = { enableVideoModeration }>
<Icon
size = { 20 }
src = { IconCheck } />
<span>{t('participantsPane.actions.videoModeration')}</span>
</ContextMenuItem> </ContextMenuItem>
)} )}
</ContextMenuItemGroup> </ContextMenuItemGroup>

View File

@ -1,27 +1,39 @@
// @flow // @flow
import React, { useCallback } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../../../lobby/actions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants'; import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import { useLobbyActions } from '../../hooks';
import ParticipantItem from './ParticipantItem'; import ParticipantItem from './ParticipantItem';
import { ParticipantActionButton } from './styled'; import { ParticipantActionButton } from './styled';
type Props = { 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 reference
*/ */
participant: Object participant: Object
}; };
export const LobbyParticipantItem = ({ participant: p }: Props) => { export const LobbyParticipantItem = ({
const dispatch = useDispatch(); overflowDrawer,
const admit = useCallback(() => dispatch(approveKnockingParticipant(p.id), [ dispatch ])); participant: p,
const reject = useCallback(() => dispatch(rejectKnockingParticipant(p.id), [ dispatch ])); openDrawerForParticipant
}: Props) => {
const { id } = p;
const [ admit ] = useLobbyActions({ participantID: id });
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@ -30,14 +42,12 @@ export const LobbyParticipantItem = ({ participant: p }: Props) => {
audioMediaState = { MEDIA_STATE.NONE } audioMediaState = { MEDIA_STATE.NONE }
displayName = { p.name } displayName = { p.name }
local = { p.local } local = { p.local }
participantID = { p.id } openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantID = { id }
raisedHand = { p.raisedHand } raisedHand = { p.raisedHand }
videoMuteState = { MEDIA_STATE.NONE } videoMediaState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }> youText = { t('chat.you') }>
<ParticipantActionButton
onClick = { reject }>
{t('lobby.reject')}
</ParticipantActionButton>
<ParticipantActionButton <ParticipantActionButton
onClick = { admit } onClick = { admit }
primary = { true }> primary = { true }>

View File

@ -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<Object>
}
/**
* 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 (
<div>
{participants.map(p => (
<LobbyParticipantItem
key = { p.id }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participant = { p } />)
)}
</div>
);
}
// Memoize the component in order to avoid rerender on drawer open/close.
export default React.memo<Props>(LobbyParticipantItems);

View File

@ -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 (
<>
<div className = { classes.headingContainer }>
<div className = { classes.heading }>
{t('participantsPane.headings.lobby', { count: participants.length })}
</div>
{
participants.length > 1 && (
<div
className = { classes.link }
onClick = { admitAll }>{t('lobby.admitAll')}</div>
)
}
</div>
<div>
{participants.map(p => (
<LobbyParticipantItem
key = { p.id }
participant = { p } />)
)}
</div>
</>
);
};

View File

@ -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 (
<>
<div className = { classes.headingContainer }>
<div className = { classes.heading }>
{t('participantsPane.headings.lobby', { count: participants.length })}
</div>
<div
className = { classes.link }
onClick = { admitAll }>{t('lobby.admitAll')}</div>
</div>
<LobbyParticipantItems
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participants = { participants } />
<DrawerPortal>
<Drawer
isOpen = { Boolean(drawerParticipant && overflowDrawer) }
onClose = { closeDrawer }>
<ul className = { classes.drawerActions }>
<li className = { classes.drawerItem }>
<Avatar
className = { classes.icon }
participantId = { drawerParticipant && drawerParticipant.participantID }
size = { 20 } />
<span>{ drawerParticipant && drawerParticipant.displayName }</span>
</li>
<li
className = { classes.drawerItem }
onClick = { admit }>
<Icon
className = { classes.icon }
size = { 20 }
src = { IconCheck } />
<span>{ t('lobby.admit') }</span>
</li>
<li
className = { classes.drawerItem }
onClick = { reject }>
<Icon
className = { classes.icon }
size = { 20 }
src = { IconClose } />
<span>{ t('lobby.reject')}</span>
</li>
</ul>
</Drawer>
</DrawerPortal>
</>
);
}

View File

@ -1,7 +1,8 @@
// @flow // @flow
import { withStyles } from '@material-ui/core/styles';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Avatar } from '../../../base/avatar';
import { isToolbarButtonEnabled } from '../../../base/config/functions.web'; import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
import { openDialog } from '../../../base/dialog'; import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
@ -21,10 +22,13 @@ import {
isParticipantModerator isParticipantModerator
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks'; import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
import { openChat } from '../../../chat/actions'; import { openChatById } from '../../../chat/actions';
import { stopSharedVideo } from '../../../shared-video/actions.any'; import { setVolume } from '../../../filmstrip/actions.web';
import { Drawer, DrawerPortal } from '../../../toolbox/components/web';
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu'; import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
import { VolumeSlider } from '../../../video-menu/components/web';
import MuteRemoteParticipantsVideoDialog from '../../../video-menu/components/web/MuteRemoteParticipantsVideoDialog'; import MuteRemoteParticipantsVideoDialog from '../../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
import { getComputedOuterHeight } from '../../functions'; import { getComputedOuterHeight } from '../../functions';
@ -73,11 +77,33 @@ type Props = {
*/ */
_participant: Object, _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. * The dispatch function from redux.
*/ */
dispatch: Function, 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. * Callback used to open a confirmation dialog for audio muting.
*/ */
@ -108,6 +134,12 @@ type Props = {
*/ */
participantID: string, participantID: string,
/**
* True if an overflow drawer should be displayed.
*/
overflowDrawer: boolean,
/** /**
* The translate function. * The translate function.
*/ */
@ -122,6 +154,25 @@ type State = {
isHidden: boolean 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. * Implements the MeetingParticipantContextMenu component.
*/ */
@ -146,13 +197,27 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
this._containerRef = React.createRef(); this._containerRef = React.createRef();
this._getCurrentParticipantId = this._getCurrentParticipantId.bind(this);
this._onGrantModerator = this._onGrantModerator.bind(this); this._onGrantModerator = this._onGrantModerator.bind(this);
this._onKick = this._onKick.bind(this); this._onKick = this._onKick.bind(this);
this._onMuteEveryoneElse = this._onMuteEveryoneElse.bind(this); this._onMuteEveryoneElse = this._onMuteEveryoneElse.bind(this);
this._onMuteVideo = this._onMuteVideo.bind(this); this._onMuteVideo = this._onMuteVideo.bind(this);
this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this); this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
this._onStopSharedVideo = this._onStopSharedVideo.bind(this);
this._position = this._position.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; _onGrantModerator: () => void;
@ -163,10 +228,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void} * @returns {void}
*/ */
_onGrantModerator() { _onGrantModerator() {
const { _participant, dispatch } = this.props; this.props.dispatch(openDialog(GrantModeratorDialog, {
participantID: this._getCurrentParticipantId()
dispatch(openDialog(GrantModeratorDialog, {
participantID: _participant?.id
})); }));
} }
@ -178,10 +241,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void} * @returns {void}
*/ */
_onKick() { _onKick() {
const { _participant, dispatch } = this.props; this.props.dispatch(openDialog(KickRemoteParticipantDialog, {
participantID: this._getCurrentParticipantId()
dispatch(openDialog(KickRemoteParticipantDialog, {
participantID: _participant?.id
})); }));
} }
@ -195,7 +256,7 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
_onStopSharedVideo() { _onStopSharedVideo() {
const { dispatch } = this.props; const { dispatch } = this.props;
dispatch(stopSharedVideo()); dispatch(this._onStopSharedVideo());
} }
_onMuteEveryoneElse: () => void; _onMuteEveryoneElse: () => void;
@ -206,10 +267,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void} * @returns {void}
*/ */
_onMuteEveryoneElse() { _onMuteEveryoneElse() {
const { _participant, dispatch } = this.props; this.props.dispatch(openDialog(MuteEveryoneDialog, {
exclude: [ this._getCurrentParticipantId() ]
dispatch(openDialog(MuteEveryoneDialog, {
exclude: [ _participant?.id ]
})); }));
} }
@ -221,10 +280,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void} * @returns {void}
*/ */
_onMuteVideo() { _onMuteVideo() {
const { _participant, dispatch } = this.props; this.props.dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
participantID: this._getCurrentParticipantId()
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
participantID: _participant?.id
})); }));
} }
@ -236,9 +293,10 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void} * @returns {void}
*/ */
_onSendPrivateMessage() { _onSendPrivateMessage() {
const { _participant, dispatch } = this.props; const { closeDrawer, dispatch, overflowDrawer } = this.props;
dispatch(openChat(_participant)); dispatch(openChatById(this._getCurrentParticipantId()));
overflowDrawer && closeDrawer();
} }
_position: () => void; _position: () => void;
@ -270,6 +328,21 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
} }
} }
_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. * Implements React Component's componentDidMount.
* *
@ -306,9 +379,14 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
_isParticipantAudioMuted, _isParticipantAudioMuted,
_localVideoOwner, _localVideoOwner,
_participant, _participant,
_volume = 1,
classes,
closeDrawer,
drawerParticipant,
onEnter, onEnter,
onLeave, onLeave,
onSelect, onSelect,
overflowDrawer,
muteAudio, muteAudio,
t t
} = this.props; } = this.props;
@ -317,23 +395,23 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
return null; return null;
} }
return ( const actions
<ContextMenu = _participant.isFakeParticipant ? (
className = { ignoredChildClassName }
innerRef = { this._containerRef }
isHidden = { this.state.isHidden }
onClick = { onSelect }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{
!_participant.isFakeParticipant && (
<> <>
{_localVideoOwner && (
<ContextMenuItem onClick = { this._onStopSharedVideo }>
<ContextMenuIcon src = { IconShareVideo } />
<span>{t('toolbar.stopSharedVideo')}</span>
</ContextMenuItem>
)}
</>
) : (
<>
{_isLocalModerator && (
<ContextMenuItemGroup> <ContextMenuItemGroup>
{
_isLocalModerator && (
<> <>
{ {
!_isParticipantAudioMuted !_isParticipantAudioMuted && overflowDrawer
&& <ContextMenuItem onClick = { muteAudio(_participant) }> && <ContextMenuItem onClick = { muteAudio(_participant) }>
<ContextMenuIcon src = { IconMicDisabled } /> <ContextMenuIcon src = { IconMicDisabled } />
<span>{t('dialog.muteParticipantButton')}</span> <span>{t('dialog.muteParticipantButton')}</span>
@ -345,20 +423,17 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span> <span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
</ContextMenuItem> </ContextMenuItem>
</> </>
)
}
{ {
_isLocalModerator && (
_isParticipantVideoMuted || ( _isParticipantVideoMuted || (
<ContextMenuItem onClick = { this._onMuteVideo }> <ContextMenuItem onClick = { this._onMuteVideo }>
<ContextMenuIcon src = { IconVideoOff } /> <ContextMenuIcon src = { IconVideoOff } />
<span>{t('participantsPane.actions.stopVideo')}</span> <span>{t('participantsPane.actions.stopVideo')}</span>
</ContextMenuItem> </ContextMenuItem>
) )
)
} }
</ContextMenuItemGroup> </ContextMenuItemGroup>
)}
<ContextMenuItemGroup> <ContextMenuItemGroup>
{ {
@ -388,19 +463,48 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
) )
} }
</ContextMenuItemGroup> </ContextMenuItemGroup>
{ overflowDrawer && typeof _volume === 'number' && !isNaN(_volume)
&& <ContextMenuItemGroup>
<VolumeSlider
initialValue = { _volume }
key = 'volume-slider'
onChange = { this._onVolumeChange } />
</ContextMenuItemGroup>
}
</> </>
) );
}
{ return (
_participant.isFakeParticipant && _localVideoOwner && ( <>
<ContextMenuItem onClick = { this._onStopSharedVideo }> { !overflowDrawer
<ContextMenuIcon src = { IconShareVideo } /> && <ContextMenu
<span>{t('toolbar.stopSharedVideo')}</span> className = { ignoredChildClassName }
innerRef = { this._containerRef }
isHidden = { this.state.isHidden }
onClick = { onSelect }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{ actions }
</ContextMenu>}
<DrawerPortal>
<Drawer
isOpen = { drawerParticipant && overflowDrawer }
onClose = { closeDrawer }>
<div className = { classes && classes.drawer }>
<ContextMenuItemGroup>
<ContextMenuItem>
<Avatar
participantId = { drawerParticipant && drawerParticipant.participantID }
size = { 20 } />
<span>{ drawerParticipant && drawerParticipant.displayName }</span>
</ContextMenuItem> </ContextMenuItem>
) </ContextMenuItemGroup>
} { actions }
</ContextMenu> </div>
</Drawer>
</DrawerPortal>
</>
); );
} }
} }
@ -414,10 +518,12 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {Props} * @returns {Props}
*/ */
function _mapStateToProps(state, ownProps): Object { function _mapStateToProps(state, ownProps): Object {
const { participantID } = ownProps; const { participantID, overflowDrawer, drawerParticipant } = ownProps;
const { ownerId } = state['features/shared-video']; const { ownerId } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state).id; const localParticipantId = getLocalParticipant(state).id;
const participant = getParticipantByIdOrUndefined(state, participantID);
const participant = getParticipantByIdOrUndefined(state,
overflowDrawer ? drawerParticipant?.participantID : participantID);
const _isLocalModerator = isLocalParticipantModerator(state); const _isLocalModerator = isLocalParticipantModerator(state);
const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state); const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
@ -425,6 +531,10 @@ function _mapStateToProps(state, ownProps): Object {
const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state); const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
const _isParticipantModerator = isParticipantModerator(participant); const _isParticipantModerator = isParticipantModerator(participant);
const { participantsVolume } = state['features/filmstrip'];
const id = participant?.id;
const isLocal = participant?.local ?? true;
return { return {
_isLocalModerator, _isLocalModerator,
_isChatButtonEnabled, _isChatButtonEnabled,
@ -432,8 +542,9 @@ function _mapStateToProps(state, ownProps): Object {
_isParticipantVideoMuted, _isParticipantVideoMuted,
_isParticipantAudioMuted, _isParticipantAudioMuted,
_localVideoOwner: Boolean(ownerId === localParticipantId), _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)));

View File

@ -9,8 +9,12 @@ import {
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks'; import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
import { ACTION_TRIGGER, MEDIA_STATE, type MediaState } from '../../constants'; import { ACTION_TRIGGER, type MediaState } from '../../constants';
import { getParticipantAudioMediaState, getQuickActionButtonType } from '../../functions'; import {
getParticipantAudioMediaState,
getParticipantVideoMediaState,
getQuickActionButtonType
} from '../../functions';
import ParticipantQuickAction from '../ParticipantQuickAction'; import ParticipantQuickAction from '../ParticipantQuickAction';
import ParticipantItem from './ParticipantItem'; import ParticipantItem from './ParticipantItem';
@ -23,20 +27,21 @@ type Props = {
*/ */
_audioMediaState: MediaState, _audioMediaState: MediaState,
/**
* Media state for video.
*/
_videoMediaState: MediaState,
/** /**
* The display name of the participant. * The display name of the participant.
*/ */
_displayName: string, _displayName: string,
/**
* True if the participant is video muted.
*/
_isVideoMuted: boolean,
/** /**
* True if the participant is the local participant. * True if the participant is the local participant.
*/ */
_local: boolean, _local: Boolean,
/** /**
* Shared video local participant owner. * Shared video local participant owner.
@ -96,6 +101,17 @@ type Props = {
*/ */
onLeave: Function, 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. * The aria-label for the ellipsis action.
*/ */
@ -120,20 +136,22 @@ type Props = {
*/ */
function MeetingParticipantItem({ function MeetingParticipantItem({
_audioMediaState, _audioMediaState,
_videoMediaState,
_displayName, _displayName,
_isVideoMuted,
_localVideoOwner,
_local, _local,
_localVideoOwner,
_participant, _participant,
_participantID, _participantID,
_quickActionButtonType, _quickActionButtonType,
_raisedHand, _raisedHand,
askUnmuteText, askUnmuteText,
isHighlighted, isHighlighted,
onContextMenu,
onLeave,
muteAudio, muteAudio,
muteParticipantButtonText, muteParticipantButtonText,
onContextMenu,
onLeave,
openDrawerForParticipant,
overflowDrawer,
participantActionEllipsisLabel, participantActionEllipsisLabel,
youText youText
}: Props) { }: Props) {
@ -145,13 +163,15 @@ function MeetingParticipantItem({
isHighlighted = { isHighlighted } isHighlighted = { isHighlighted }
local = { _local } local = { _local }
onLeave = { onLeave } onLeave = { onLeave }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantID = { _participantID } participantID = { _participantID }
raisedHand = { _raisedHand } raisedHand = { _raisedHand }
videoMuteState = { _isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED } videoMediaState = { _videoMediaState }
youText = { youText }> youText = { youText }>
{
!_participant.isFakeParticipant && ( {!overflowDrawer && !_participant.isFakeParticipant
<> && <>
<ParticipantQuickAction <ParticipantQuickAction
askUnmuteText = { askUnmuteText } askUnmuteText = { askUnmuteText }
buttonType = { _quickActionButtonType } buttonType = { _quickActionButtonType }
@ -162,15 +182,13 @@ function MeetingParticipantItem({
aria-label = { participantActionEllipsisLabel } aria-label = { participantActionEllipsisLabel }
onClick = { onContextMenu } /> onClick = { onContextMenu } />
</> </>
)
} }
{
_participant.isFakeParticipant && _localVideoOwner && ( {!overflowDrawer && _localVideoOwner && _participant.isFakeParticipant && (
<ParticipantActionEllipsis <ParticipantActionEllipsis
aria-label = { participantActionEllipsisLabel } aria-label = { participantActionEllipsisLabel }
onClick = { onContextMenu } /> onClick = { onContextMenu } />
) )}
}
</ParticipantItem> </ParticipantItem>
); );
} }
@ -193,13 +211,13 @@ function _mapStateToProps(state, ownProps): Object {
const _isAudioMuted = isParticipantAudioMuted(participant, state); const _isAudioMuted = isParticipantAudioMuted(participant, state);
const _isVideoMuted = isParticipantVideoMuted(participant, state); const _isVideoMuted = isParticipantVideoMuted(participant, state);
const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state); const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state);
const _videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state);
const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, state); const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, state);
return { return {
_audioMediaState, _audioMediaState,
_videoMediaState,
_displayName: getParticipantDisplayName(state, participant?.id), _displayName: getParticipantDisplayName(state, participant?.id),
_isAudioMuted,
_isVideoMuted,
_local: Boolean(participant?.local), _local: Boolean(participant?.local),
_localVideoOwner: Boolean(ownerId === localParticipantId), _localVideoOwner: Boolean(ownerId === localParticipantId),
_participant: participant, _participant: participant,

View File

@ -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<string>,
/**
* 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 => (
<MeetingParticipantItem
askUnmuteText = { askUnmuteText }
isHighlighted = { raiseContextId === id }
key = { id }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
onContextMenu = { toggleMenu(id) }
onLeave = { lowerMenu }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantID = { id }
youText = { youText } />
);
return participantIds.map(renderParticipant);
}
// Memoize the component in order to avoid rerender on drawer open/close.
export default React.memo<Props>(MeetingParticipantItems);

View File

@ -5,18 +5,20 @@ import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { isToolbarButtonEnabled } from '../../../base/config/functions.web'; import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
import { openDialog } from '../../../base/dialog'; import { MEDIA_TYPE } from '../../../base/media';
import { import {
getParticipantCountWithFake, getParticipantCountWithFake,
getSortedParticipantIds getSortedParticipantIds
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; 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 { findStyledAncestor, shouldRenderInviteButton } from '../../functions';
import { useParticipantDrawer } from '../../hooks';
import { InviteButton } from './InviteButton'; import { InviteButton } from './InviteButton';
import MeetingParticipantContextMenu from './MeetingParticipantContextMenu'; import MeetingParticipantContextMenu from './MeetingParticipantContextMenu';
import MeetingParticipantItem from './MeetingParticipantItem'; import MeetingParticipantItems from './MeetingParticipantItems';
import { Heading, ParticipantContainer } from './styled'; import { Heading, ParticipantContainer } from './styled';
type NullProto = { type NullProto = {
@ -34,7 +36,7 @@ type RaiseContext = NullProto | {|
/** /**
* The ID of the participant. * The ID of the participant.
*/ */
participantID?: String, participantID ?: string,
|}; |};
const initialState = Object.freeze(Object.create(null)); const initialState = Object.freeze(Object.create(null));
@ -49,11 +51,11 @@ const initialState = Object.freeze(Object.create(null));
* *
* @returns {ReactNode} - The component. * @returns {ReactNode} - The component.
*/ */
function MeetingParticipantList({ participantsCount, showInviteButton, sortedParticipantIds = [] }) { function MeetingParticipants({ participantsCount, showInviteButton, overflowDrawer, sortedParticipantIds = [] }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const isMouseOverMenu = useRef(false); const isMouseOverMenu = useRef(false);
const [ raiseContext, setRaiseContext ] = useState<RaiseContext>(initialState); const [ raiseContext, setRaiseContext ] = useState < RaiseContext >(initialState);
const { t } = useTranslation(); const { t } = useTranslation();
const lowerMenu = useCallback(() => { const lowerMenu = useCallback(() => {
@ -101,8 +103,9 @@ function MeetingParticipantList({ participantsCount, showInviteButton, sortedPar
}, [ lowerMenu ]); }, [ lowerMenu ]);
const muteAudio = useCallback(id => () => { const muteAudio = useCallback(id => () => {
dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id })); dispatch(muteRemote(id, MEDIA_TYPE.AUDIO));
}); }, [ dispatch ]);
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
// FIXME: // FIXME:
// It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is // It seems that useTranslation is not very scallable. Unmount 500 components that have the useTranslation hook is
@ -115,32 +118,33 @@ function MeetingParticipantList({ participantsCount, showInviteButton, sortedPar
const askUnmuteText = t('participantsPane.actions.askUnmute'); const askUnmuteText = t('participantsPane.actions.askUnmute');
const muteParticipantButtonText = t('dialog.muteParticipantButton'); const muteParticipantButtonText = t('dialog.muteParticipantButton');
const renderParticipant = id => (
<MeetingParticipantItem
askUnmuteText = { askUnmuteText }
isHighlighted = { raiseContext.participantID === id }
key = { id }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
onContextMenu = { toggleMenu(id) }
onLeave = { lowerMenu }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantID = { id }
youText = { youText } />
);
return ( return (
<> <>
<Heading>{t('participantsPane.headings.participantsList', { count: participantsCount })}</Heading> <Heading>{t('participantsPane.headings.participantsList', { count: participantsCount })}</Heading>
{showInviteButton && <InviteButton />} {showInviteButton && <InviteButton />}
<div> <div>
{sortedParticipantIds.map(renderParticipant)} <MeetingParticipantItems
askUnmuteText = { askUnmuteText }
lowerMenu = { lowerMenu }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantIds = { sortedParticipantIds }
participantsCount = { participantsCount }
raiseContextId = { raiseContext.participantID }
toggleMenu = { toggleMenu }
youText = { youText } />
</div> </div>
<MeetingParticipantContextMenu <MeetingParticipantContextMenu
closeDrawer = { closeDrawer }
drawerParticipant = { drawerParticipant }
muteAudio = { muteAudio } muteAudio = { muteAudio }
onEnter = { menuEnter } onEnter = { menuEnter }
onLeave = { menuLeave } onLeave = { menuLeave }
onSelect = { lowerMenu } onSelect = { lowerMenu }
overflowDrawer = { overflowDrawer }
{ ...raiseContext } /> { ...raiseContext } />
</> </>
); );
@ -163,11 +167,14 @@ function _mapStateToProps(state): Object {
const showInviteButton = shouldRenderInviteButton(state) && isToolbarButtonEnabled('invite', state); const showInviteButton = shouldRenderInviteButton(state) && isToolbarButtonEnabled('invite', state);
const overflowDrawer = showOverflowDrawer(state);
return { return {
sortedParticipantIds, sortedParticipantIds,
participantsCount, participantsCount,
showInviteButton showInviteButton,
overflowDrawer
}; };
} }
export default connect(_mapStateToProps)(MeetingParticipantList); export default connect(_mapStateToProps)(MeetingParticipants);

View File

@ -1,6 +1,6 @@
// @flow // @flow
import React, { type Node } from 'react'; import React, { type Node, useCallback } from 'react';
import { Avatar } from '../../../base/avatar'; import { Avatar } from '../../../base/avatar';
import { import {
@ -61,13 +61,23 @@ type Props = {
/** /**
* True if the participant is local. * 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 * Callback for when the mouse leaves this component
*/ */
onLeave?: Function, onLeave?: Function,
/**
* If an overflow drawer can be opened.
*/
overflowDrawer?: boolean,
/** /**
* The ID of the participant. * The ID of the participant.
*/ */
@ -81,7 +91,7 @@ type Props = {
/** /**
* Media state for video * Media state for video
*/ */
videoMuteState: MediaState, videoMediaState: MediaState,
/** /**
* The translated "you" text. * The translated "you" text.
@ -101,20 +111,28 @@ export default function ParticipantItem({
onLeave, onLeave,
actionsTrigger = ACTION_TRIGGER.HOVER, actionsTrigger = ACTION_TRIGGER.HOVER,
audioMediaState = MEDIA_STATE.NONE, audioMediaState = MEDIA_STATE.NONE,
videoMuteState = MEDIA_STATE.NONE, videoMediaState = MEDIA_STATE.NONE,
displayName, displayName,
participantID, participantID,
local, local,
openDrawerForParticipant,
overflowDrawer,
raisedHand, raisedHand,
youText youText
}: Props) { }: Props) {
const ParticipantActions = Actions[actionsTrigger]; const ParticipantActions = Actions[actionsTrigger];
const onClick = useCallback(
() => openDrawerForParticipant({
participantID,
displayName
}));
return ( return (
<ParticipantContainer <ParticipantContainer
id = { `participant-item-${participantID}` } id = { `participant-item-${participantID}` }
isHighlighted = { isHighlighted } isHighlighted = { isHighlighted }
local = { local } local = { local }
onClick = { !local && overflowDrawer ? onClick : undefined }
onMouseLeave = { onLeave } onMouseLeave = { onLeave }
trigger = { actionsTrigger }> trigger = { actionsTrigger }>
<Avatar <Avatar
@ -131,7 +149,7 @@ export default function ParticipantItem({
{ !local && <ParticipantActions children = { children } /> } { !local && <ParticipantActions children = { children } /> }
<ParticipantStates> <ParticipantStates>
{ raisedHand && <RaisedHandIndicator /> } { raisedHand && <RaisedHandIndicator /> }
{ VideoStateIcons[videoMuteState] } { VideoStateIcons[videoMediaState] }
{ AudioStateIcons[audioMediaState] } { AudioStateIcons[audioMediaState] }
</ParticipantStates> </ParticipantStates>
</ParticipantContent> </ParticipantContent>

View File

@ -7,14 +7,16 @@ import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { isLocalParticipantModerator } from '../../../base/participants'; import { isLocalParticipantModerator } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { Drawer, DrawerPortal } from '../../../toolbox/components/web';
import { showOverflowDrawer } from '../../../toolbox/functions';
import { MuteEveryoneDialog } from '../../../video-menu/components/'; import { MuteEveryoneDialog } from '../../../video-menu/components/';
import { close } from '../../actions'; import { close } from '../../actions';
import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../../functions'; import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../../functions';
import theme from '../../theme.json'; import theme from '../../theme.json';
import { FooterContextMenu } from '../FooterContextMenu'; import { FooterContextMenu } from '../FooterContextMenu';
import { LobbyParticipantList } from './LobbyParticipantList'; import LobbyParticipants from './LobbyParticipants';
import MeetingParticipantList from './MeetingParticipantList'; import MeetingParticipants from './MeetingParticipants';
import { import {
AntiCollapse, AntiCollapse,
Close, Close,
@ -31,6 +33,11 @@ import {
*/ */
type Props = { type Props = {
/**
* Whether to display the context menu as a drawer.
*/
_overflowDrawer: boolean,
/** /**
* Is the participants pane open. * Is the participants pane open.
*/ */
@ -81,6 +88,7 @@ class ParticipantsPane extends Component<Props, State> {
// Bind event handlers so they are only bound once per instance. // Bind event handlers so they are only bound once per instance.
this._onClosePane = this._onClosePane.bind(this); this._onClosePane = this._onClosePane.bind(this);
this._onDrawerClose = this._onDrawerClose.bind(this);
this._onKeyPress = this._onKeyPress.bind(this); this._onKeyPress = this._onKeyPress.bind(this);
this._onMuteAll = this._onMuteAll.bind(this); this._onMuteAll = this._onMuteAll.bind(this);
this._onToggleContext = this._onToggleContext.bind(this); this._onToggleContext = this._onToggleContext.bind(this);
@ -113,10 +121,12 @@ class ParticipantsPane extends Component<Props, State> {
*/ */
render() { render() {
const { const {
_overflowDrawer,
_paneOpen, _paneOpen,
_showFooter, _showFooter,
t t
} = this.props; } = this.props;
const { contextOpen } = this.state;
// when the pane is not open optimize to not // when the pane is not open optimize to not
// execute the MeetingParticipantList render for large list of participants // execute the MeetingParticipantList render for large list of participants
@ -137,9 +147,9 @@ class ParticipantsPane extends Component<Props, State> {
tabIndex = { 0 } /> tabIndex = { 0 } />
</Header> </Header>
<Container> <Container>
<LobbyParticipantList /> <LobbyParticipants />
<AntiCollapse /> <AntiCollapse />
<MeetingParticipantList /> <MeetingParticipants />
</Container> </Container>
{_showFooter && ( {_showFooter && (
<Footer> <Footer>
@ -150,12 +160,19 @@ class ParticipantsPane extends Component<Props, State> {
<FooterEllipsisButton <FooterEllipsisButton
id = 'participants-pane-context-menu' id = 'participants-pane-context-menu'
onClick = { this._onToggleContext } /> onClick = { this._onToggleContext } />
{this.state.contextOpen {this.state.contextOpen && !_overflowDrawer
&& <FooterContextMenu onMouseLeave = { this._onToggleContext } />} && <FooterContextMenu onMouseLeave = { this._onToggleContext } />}
</FooterEllipsisContainer> </FooterEllipsisContainer>
</Footer> </Footer>
)} )}
</div> </div>
<DrawerPortal>
<Drawer
isOpen = { contextOpen && _overflowDrawer }
onClose = { this._onDrawerClose }>
<FooterContextMenu inDrawer = { true } />
</Drawer>
</DrawerPortal>
</div> </div>
</ThemeProvider> </ThemeProvider>
); );
@ -173,6 +190,20 @@ class ParticipantsPane extends Component<Props, State> {
this.props.dispatch(close()); this.props.dispatch(close());
} }
_onDrawerClose: () => void
/**
* Callback for closing the drawer.
*
* @private
* @returns {void}
*/
_onDrawerClose() {
this.setState({
contextOpen: false
});
}
_onKeyPress: (Object) => void; _onKeyPress: (Object) => void;
/** /**
@ -228,6 +259,8 @@ class ParticipantsPane extends Component<Props, State> {
}); });
} }
} }
} }
/** /**
@ -245,6 +278,7 @@ function _mapStateToProps(state: Object) {
const isPaneOpen = getParticipantsPaneOpen(state); const isPaneOpen = getParticipantsPaneOpen(state);
return { return {
_overflowDrawer: showOverflowDrawer(state),
_paneOpen: isPaneOpen, _paneOpen: isPaneOpen,
_showFooter: isPaneOpen && isLocalParticipantModerator(state) _showFooter: isPaneOpen && isLocalParticipantModerator(state)
}; };

View File

@ -1,7 +1,5 @@
export * from './InviteButton'; export * from './InviteButton';
export * from './LobbyParticipantItem'; export * from './LobbyParticipantItem';
export * from './LobbyParticipantList';
export * from './MeetingParticipantList';
export { default as ParticipantsPane } from './ParticipantsPane'; export { default as ParticipantsPane } from './ParticipantsPane';
export * from '../ParticipantsPaneButton'; export * from '../ParticipantsPaneButton';
export * from './RaisedHandIndicator'; export * from './RaisedHandIndicator';

View File

@ -4,6 +4,8 @@ import styled from 'styled-components';
import { Icon, IconHorizontalPoints } from '../../../base/icons'; import { Icon, IconHorizontalPoints } from '../../../base/icons';
import { ACTION_TRIGGER } from '../../constants'; import { ACTION_TRIGGER } from '../../constants';
const MD_BREAKPOINT = '580px';
export const ignoredChildClassName = 'ignore-child'; export const ignoredChildClassName = 'ignore-child';
export const AntiCollapse = styled.br` export const AntiCollapse = styled.br`
@ -89,7 +91,7 @@ export const ContextMenuIcon = styled(Icon).attrs({
size: 20 size: 20
})` })`
& > svg { & > svg {
fill: #a4b8d1; fill: #ffffff;
} }
`; `;
@ -162,6 +164,12 @@ export const FooterButton = styled(Button)`
height: 40px; height: 40px;
font-size: 15px; font-size: 15px;
padding: 0 16px; padding: 0 16px;
@media (max-width: ${MD_BREAKPOINT}) {
font-size: 16px;
height: 48px;
min-width: 48px;
}
`; `;
export const FooterEllipsisButton = styled(FooterButton).attrs({ export const FooterEllipsisButton = styled(FooterButton).attrs({
@ -188,6 +196,10 @@ export const Heading = styled.div`
font-size: 15px; font-size: 15px;
line-height: 24px; line-height: 24px;
margin: 8px 0 ${props => props.theme.panePadding}px; margin: 8px 0 ${props => props.theme.panePadding}px;
@media (max-width: ${MD_BREAKPOINT}) {
font-size: 16px;
}
`; `;
export const ParticipantActionButton = styled(Button)` export const ParticipantActionButton = styled(Button)`
@ -275,6 +287,11 @@ export const ParticipantContainer = styled.div`
padding-left: ${props => props.theme.panePadding}px; padding-left: ${props => props.theme.panePadding}px;
position: relative; position: relative;
@media (max-width: ${MD_BREAKPOINT}) {
font-size: 16px;
height: 64px;
}
&:hover { &:hover {
${ParticipantStates} { ${ParticipantStates} {
${props => !props.local && 'display: none'}; ${props => !props.local && 'display: none'};
@ -293,6 +310,10 @@ export const ParticipantContainer = styled.div`
& ${ParticipantContent} { & ${ParticipantContent} {
box-shadow: none; box-shadow: none;
} }
& ${ParticipantStates} {
display: none;
}
${props => !props.isHighlighted && '}'} ${props => !props.isHighlighted && '}'}
`; `;
@ -306,6 +327,11 @@ export const ParticipantInviteButton = styled(Button).attrs({
& > *:not(:last-child) { & > *:not(:last-child) {
margin-right: 8px; margin-right: 8px;
} }
@media (max-width: ${MD_BREAKPOINT}) {
font-size: 16px;
height: 48px;
}
`; `;
export const ParticipantName = styled.div` export const ParticipantName = styled.div`

View File

@ -94,6 +94,7 @@ export const AudioStateIcons: {[MediaState]: React$Element<any> | null} = {
export const VideoStateIcons = { export const VideoStateIcons = {
[MEDIA_STATE.FORCE_MUTED]: ( [MEDIA_STATE.FORCE_MUTED]: (
<Icon <Icon
color = '#E04757'
size = { 16 } size = { 16 }
src = { IconCameraEmptyDisabled } /> src = { IconCameraEmptyDisabled } />
), ),

View File

@ -71,17 +71,39 @@ export function isForceMuted(participant: Object, mediaType: MediaType, state: O
* @param {Object} participant - The participant. * @param {Object} participant - The participant.
* @param {boolean} muted - The mute state of the participant. * @param {boolean} muted - The mute state of the participant.
* @param {Object} state - The redux state. * @param {Object} state - The redux state.
* @param {boolean} ignoreDominantSpeaker - Whether to ignore the dominant speaker state.
* @returns {MediaState} * @returns {MediaState}
*/ */
export function getParticipantAudioMediaState(participant: Object, muted: Boolean, state: Object) { export function getParticipantAudioMediaState(participant: Object, muted: Boolean, state: Object) {
const dominantSpeaker = getDominantSpeakerParticipant(state); 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) { if (participant === dominantSpeaker) {
return MEDIA_STATE.DOMINANT_SPEAKER; 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 (muted) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) { if (isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) {
return MEDIA_STATE.FORCE_MUTED; 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) { export function getQuickActionButtonType(participant: Object, isAudioMuted: Boolean, state: Object) {
// handled only by moderators // handled only by moderators
if (isLocalParticipantModerator(state)) { 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; return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
} }
if (!isAudioMuted) { if (!isAudioMuted) {

View File

@ -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<Function>}
*/
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<any>}
*/
export function useParticipantDrawer() {
const [ drawerParticipant, openDrawerForParticipant ] = useState(null);
const closeDrawer = useCallback(() => {
openDrawerForParticipant(null);
});
return [
drawerParticipant,
closeDrawer,
openDrawerForParticipant
];
}

View File

@ -3,13 +3,15 @@
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app';
import { CONFERENCE_JOINED } from '../base/conference'; import { CONFERENCE_JOINED } from '../base/conference';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet'; 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 { MiddlewareRegistry } from '../base/redux';
import { playSound, registerSound, unregisterSound } from '../base/sounds'; import { playSound, registerSound, unregisterSound } from '../base/sounds';
import { import {
hideNotification, hideNotification,
showNotification showNotification
} from '../notifications'; } from '../notifications';
import { isForceMuted } from '../participants-pane/functions';
import { setCurrentNotificationUid } from './actions'; import { setCurrentNotificationUid } from './actions';
import { TALK_WHILE_MUTED_SOUND_ID } from './constants'; import { TALK_WHILE_MUTED_SOUND_ID } from './constants';
@ -41,10 +43,13 @@ MiddlewareRegistry.register(store => next => action => {
}); });
conference.on( conference.on(
JitsiConferenceEvents.TALK_WHILE_MUTED, async () => { 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({ const notification = await dispatch(showNotification({
titleKey: 'toolbar.talkWhileMutedPopup', titleKey: 'toolbar.talkWhileMutedPopup',
customActionNameKey: 'notify.unmute', customActionNameKey: forceMuted ? 'notify.raiseHandAction' : 'notify.unmute',
customActionHandler: () => dispatch(setAudioMuted(false)) customActionHandler: () => dispatch(forceMuted ? raiseHand(true) : setAudioMuted(false))
})); }));
const { soundsTalkWhileMuted } = getState()['features/base/settings']; const { soundsTalkWhileMuted } = getState()['features/base/settings'];

View File

@ -80,3 +80,14 @@ export function isVideoSettingsButtonDisabled(state: Object) {
export function isVideoMuteButtonDisabled(state: Object) { export function isVideoMuteButtonDisabled(state: Object) {
return !hasAvailableDevices(state, 'videoInput'); 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;
}

View File

@ -6,7 +6,7 @@ import {
createRemoteVideoMenuButtonEvent, createRemoteVideoMenuButtonEvent,
sendAnalytics sendAnalytics
} from '../../analytics'; } from '../../analytics';
import { grantModerator } from '../../base/participants'; import { getParticipantById, grantModerator } from '../../base/participants';
type Props = { type Props = {
@ -20,6 +20,11 @@ type Props = {
*/ */
participantID: string, participantID: string,
/**
* The name of the remote participant to be granted moderator rights.
*/
participantName: string,
/** /**
* Function to translate i18n labels. * Function to translate i18n labels.
*/ */
@ -64,3 +69,17 @@ export default class AbstractGrantModeratorDialog
return true; 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
};
}

View File

@ -4,13 +4,11 @@ import {
createRemoteVideoMenuButtonEvent, createRemoteVideoMenuButtonEvent,
sendAnalytics sendAnalytics
} from '../../analytics'; } from '../../analytics';
import { openDialog } from '../../base/dialog';
import { IconMicDisabled } from '../../base/icons'; import { IconMicDisabled } from '../../base/icons';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components'; import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components';
import { isRemoteTrackMuted } from '../../base/tracks'; import { isRemoteTrackMuted } from '../../base/tracks';
import { muteRemote } from '../actions.any';
import { MuteRemoteParticipantDialog } from '.';
export type Props = AbstractButtonProps & { export type Props = AbstractButtonProps & {
@ -61,7 +59,7 @@ export default class AbstractMuteButton extends AbstractButton<Props, *> {
'participant_id': participantID 'participant_id': participantID
})); }));
dispatch(openDialog(MuteRemoteParticipantDialog, { participantID })); dispatch(muteRemote(participantID, MEDIA_TYPE.AUDIO));
} }
/** /**

View File

@ -2,6 +2,8 @@
import React from 'react'; import React from 'react';
import { requestDisableAudioModeration, requestEnableAudioModeration } from '../../av-moderation/actions';
import { isEnabledFromState } from '../../av-moderation/functions';
import { Dialog } from '../../base/dialog'; import { Dialog } from '../../base/dialog';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants'; import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants';
@ -19,7 +21,14 @@ export type Props = AbstractProps & {
content: string, content: string,
exclude: Array<string>, exclude: Array<string>,
title: string title: string,
showAdvancedModerationToggle: boolean,
isAudioModerationEnabled: boolean
};
type State = {
audioModerationEnabled: boolean,
content: string
}; };
/** /**
@ -29,12 +38,33 @@ export type Props = AbstractProps & {
* *
* @extends AbstractMuteRemoteParticipantDialog * @extends AbstractMuteRemoteParticipantDialog
*/ */
export default class AbstractMuteEveryoneDialog<P: Props> extends AbstractMuteRemoteParticipantDialog<P> { export default class AbstractMuteEveryoneDialog<P: Props> extends AbstractMuteRemoteParticipantDialog<P, State> {
static defaultProps = { static defaultProps = {
exclude: [], exclude: [],
muteLocal: false 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()}. * Implements React's {@link Component#render()}.
* *
@ -59,6 +89,8 @@ export default class AbstractMuteEveryoneDialog<P: Props> extends AbstractMuteRe
_onSubmit: () => boolean; _onSubmit: () => boolean;
_onToggleModeration: () => void;
/** /**
* Callback to be invoked when the value of this dialog is submitted. * Callback to be invoked when the value of this dialog is submitted.
* *
@ -71,6 +103,11 @@ export default class AbstractMuteEveryoneDialog<P: Props> extends AbstractMuteRe
} = this.props; } = this.props;
dispatch(muteAllParticipants(exclude, MEDIA_TYPE.AUDIO)); dispatch(muteAllParticipants(exclude, MEDIA_TYPE.AUDIO));
if (this.state.audioModerationEnabled) {
dispatch(requestEnableAudioModeration());
} else {
dispatch(requestDisableAudioModeration());
}
return true; return true;
} }
@ -97,7 +134,7 @@ export function abstractMapStateToProps(state: Object, ownProps: Props) {
content: t('dialog.muteEveryoneElseDialog'), content: t('dialog.muteEveryoneElseDialog'),
title: t('dialog.muteEveryoneElseTitle', { whom }) title: t('dialog.muteEveryoneElseTitle', { whom })
} : { } : {
content: t('dialog.muteEveryoneDialog'), title: t('dialog.muteEveryoneTitle'),
title: t('dialog.muteEveryoneTitle') isAudioModerationEnabled: isEnabledFromState(MEDIA_TYPE.AUDIO, state)
}; };
} }

View File

@ -2,6 +2,8 @@
import React from 'react'; import React from 'react';
import { requestDisableVideoModeration, requestEnableVideoModeration } from '../../av-moderation/actions';
import { isEnabledFromState } from '../../av-moderation/functions';
import { Dialog } from '../../base/dialog'; import { Dialog } from '../../base/dialog';
import { MEDIA_TYPE } from '../../base/media'; import { MEDIA_TYPE } from '../../base/media';
import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants'; import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants';
@ -19,7 +21,14 @@ export type Props = AbstractProps & {
content: string, content: string,
exclude: Array<string>, exclude: Array<string>,
title: string title: string,
showAdvancedModerationToggle: boolean,
isVideoModerationEnabled: boolean
};
type State = {
moderationEnabled: boolean;
content: string;
}; };
/** /**
@ -29,12 +38,34 @@ export type Props = AbstractProps & {
* *
* @extends AbstractMuteRemoteParticipantsVideoDialog * @extends AbstractMuteRemoteParticipantsVideoDialog
*/ */
export default class AbstractMuteEveryonesVideoDialog<P: Props> extends AbstractMuteRemoteParticipantsVideoDialog<P> { export default class AbstractMuteEveryonesVideoDialog<P: Props>
extends AbstractMuteRemoteParticipantsVideoDialog<P, State> {
static defaultProps = { static defaultProps = {
exclude: [], exclude: [],
muteLocal: false 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()}. * Implements React's {@link Component#render()}.
* *
@ -59,6 +90,8 @@ export default class AbstractMuteEveryonesVideoDialog<P: Props> extends Abstract
_onSubmit: () => boolean; _onSubmit: () => boolean;
_onToggleModeration: () => void;
/** /**
* Callback to be invoked when the value of this dialog is submitted. * Callback to be invoked when the value of this dialog is submitted.
* *
@ -71,6 +104,11 @@ export default class AbstractMuteEveryonesVideoDialog<P: Props> extends Abstract
} = this.props; } = this.props;
dispatch(muteAllParticipants(exclude, MEDIA_TYPE.VIDEO)); dispatch(muteAllParticipants(exclude, MEDIA_TYPE.VIDEO));
if (this.state.moderationEnabled) {
dispatch(requestEnableVideoModeration());
} else {
dispatch(requestDisableVideoModeration());
}
return true; return true;
} }
@ -84,7 +122,8 @@ export default class AbstractMuteEveryonesVideoDialog<P: Props> extends Abstract
* @returns {Props} * @returns {Props}
*/ */
export function abstractMapStateToProps(state: Object, ownProps: 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 const whom = exclude
// eslint-disable-next-line no-confusing-arrow // eslint-disable-next-line no-confusing-arrow
@ -97,7 +136,7 @@ export function abstractMapStateToProps(state: Object, ownProps: Props) {
content: t('dialog.muteEveryoneElsesVideoDialog'), content: t('dialog.muteEveryoneElsesVideoDialog'),
title: t('dialog.muteEveryoneElsesVideoTitle', { whom }) title: t('dialog.muteEveryoneElsesVideoTitle', { whom })
} : { } : {
content: t('dialog.muteEveryonesVideoDialog'), title: t('dialog.muteEveryonesVideoTitle'),
title: t('dialog.muteEveryonesVideoTitle') isVideoModerationEnabled
}; };
} }

View File

@ -32,8 +32,8 @@ export type Props = {
* *
* @extends Component * @extends Component
*/ */
export default class AbstractMuteRemoteParticipantDialog<P:Props = Props> export default class AbstractMuteRemoteParticipantDialog<P:Props = Props, State=void>
extends Component<P> { extends Component<P, State> {
/** /**
* Initializes a new {@code AbstractMuteRemoteParticipantDialog} instance. * Initializes a new {@code AbstractMuteRemoteParticipantDialog} instance.
* *

View File

@ -32,8 +32,8 @@ export type Props = {
* *
* @extends Component * @extends Component
*/ */
export default class AbstractMuteRemoteParticipantsVideoDialog<P:Props = Props> export default class AbstractMuteRemoteParticipantsVideoDialog<P:Props = Props, State=void>
extends Component<P> { extends Component<P, State> {
/** /**
* Initializes a new {@code AbstractMuteRemoteParticipantsVideoDialog} instance. * Initializes a new {@code AbstractMuteRemoteParticipantsVideoDialog} instance.
* *

View File

@ -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 (
<ConfirmDialog
contentKey = 'dialog.muteParticipantDialog'
onSubmit = { this._onSubmit } />
);
}
_onSubmit: () => boolean;
}
export default translate(connect()(MuteRemoteParticipantDialog));

View File

@ -5,7 +5,6 @@ export { default as GrantModeratorDialog } from './GrantModeratorDialog';
export { default as KickRemoteParticipantDialog } from './KickRemoteParticipantDialog'; export { default as KickRemoteParticipantDialog } from './KickRemoteParticipantDialog';
export { default as MuteEveryoneDialog } from './MuteEveryoneDialog'; export { default as MuteEveryoneDialog } from './MuteEveryoneDialog';
export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog'; export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog';
export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog';
export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog'; export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog';
export { default as RemoteVideoMenu } from './RemoteVideoMenu'; export { default as RemoteVideoMenu } from './RemoteVideoMenu';
export { default as SharedVideoMenu } from './SharedVideoMenu'; export { default as SharedVideoMenu } from './SharedVideoMenu';

View File

@ -6,7 +6,7 @@ import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import AbstractGrantModeratorDialog import AbstractGrantModeratorDialog
from '../AbstractGrantModeratorDialog'; , { abstractMapStateToProps } from '../AbstractGrantModeratorDialog';
/** /**
* Dialog to confirm a grant moderator action. * Dialog to confirm a grant moderator action.
@ -26,7 +26,7 @@ class GrantModeratorDialog extends AbstractGrantModeratorDialog {
titleKey = 'dialog.grantModeratorTitle' titleKey = 'dialog.grantModeratorTitle'
width = 'small'> width = 'small'>
<div> <div>
{ this.props.t('dialog.grantModeratorDialog') } { this.props.t('dialog.grantModeratorDialog', { participantName: this.props.participantName }) }
</div> </div>
</Dialog> </Dialog>
); );
@ -35,4 +35,4 @@ class GrantModeratorDialog extends AbstractGrantModeratorDialog {
_onSubmit: () => boolean; _onSubmit: () => boolean;
} }
export default translate(connect()(GrantModeratorDialog)); export default translate(connect(abstractMapStateToProps)(GrantModeratorDialog));

View File

@ -4,8 +4,10 @@ import React from 'react';
import { Dialog } from '../../../base/dialog'; import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { Switch } from '../../../base/react';
import { connect } from '../../../base/redux'; 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 * 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 * @extends AbstractMuteEveryoneDialog
*/ */
class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<Props> { class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<Props> {
/**
* 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()}. * Implements React's {@link Component#render()}.
* *
@ -27,8 +46,22 @@ class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<Props> {
onSubmit = { this._onSubmit } onSubmit = { this._onSubmit }
titleString = { this.props.title } titleString = { this.props.title }
width = 'small'> width = 'small'>
<div> <div className = 'mute-dialog'>
{ this.props.content } { this.state.content }
{this.props.exclude.length === 0 && (
<>
<div className = 'separator-line' />
<div className = 'control-row'>
<label htmlFor = 'moderation-switch'>
{this.props.t('dialog.moderationAudioLabel')}
</label>
<Switch
id = 'moderation-switch'
onValueChange = { this._onToggleModeration }
value = { !this.state.audioModerationEnabled } />
</div>
</>
)}
</div> </div>
</Dialog> </Dialog>
); );

View File

@ -4,6 +4,7 @@ import React from 'react';
import { Dialog } from '../../../base/dialog'; import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { Switch } from '../../../base/react';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props } import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props }
from '../AbstractMuteEveryonesVideoDialog'; from '../AbstractMuteEveryonesVideoDialog';
@ -15,6 +16,23 @@ import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props }
* @extends AbstractMuteEveryonesVideoDialog * @extends AbstractMuteEveryonesVideoDialog
*/ */
class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<Props> { class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<Props> {
/**
* 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()}. * Implements React's {@link Component#render()}.
* *
@ -28,8 +46,22 @@ class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<Props> {
onSubmit = { this._onSubmit } onSubmit = { this._onSubmit }
titleString = { this.props.title } titleString = { this.props.title }
width = 'small'> width = 'small'>
<div> <div className = 'mute-dialog'>
{ this.props.content } {this.state.content}
{this.props.exclude.length === 0 && (
<>
<div className = 'separator-line' />
<div className = 'control-row'>
<label htmlFor = 'moderation-switch'>
{this.props.t('dialog.moderationVideoLabel')}
</label>
<Switch
id = 'moderation-switch'
onValueChange = { this._onToggleModeration }
value = { !this.state.moderationEnabled } />
</div>
</>
)}
</div> </div>
</Dialog> </Dialog>
); );

View File

@ -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 (
<Dialog
okKey = 'dialog.muteParticipantButton'
onSubmit = { this._onSubmit }
titleKey = 'dialog.muteParticipantTitle'
width = 'small'>
<div>
{ this.props.t('dialog.muteParticipantBody') }
</div>
</Dialog>
);
}
_onSubmit: () => boolean;
}
export default translate(connect()(MuteRemoteParticipantDialog));

View File

@ -11,7 +11,6 @@ export { default as MuteEveryoneDialog } from './MuteEveryoneDialog';
export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog'; export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog';
export { default as MuteEveryoneElseButton } from './MuteEveryoneElseButton'; export { default as MuteEveryoneElseButton } from './MuteEveryoneElseButton';
export { default as MuteEveryoneElsesVideoButton } from './MuteEveryoneElsesVideoButton'; export { default as MuteEveryoneElsesVideoButton } from './MuteEveryoneElsesVideoButton';
export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog';
export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog'; export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog';
export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton'; export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton';
export { REMOTE_CONTROL_MENU_STATES, default as RemoteControlButton } from './RemoteControlButton'; export { REMOTE_CONTROL_MENU_STATES, default as RemoteControlButton } from './RemoteControlButton';