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;
}
@media (max-width: 375px) {
@media (max-width: 580px) {
.participants_pane {
height: 100vh;
height: -webkit-fill-available;

View File

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

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

View File

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

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.

View File

@ -11,9 +11,12 @@ import {
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
PARTICIPANT_APPROVED,
PARTICIPANT_PENDING_AUDIO,
REQUEST_DISABLE_MODERATION,
REQUEST_ENABLE_MODERATION
REQUEST_DISABLE_AUDIO_MODERATION,
REQUEST_ENABLE_AUDIO_MODERATION,
REQUEST_DISABLE_VIDEO_MODERATION,
REQUEST_ENABLE_VIDEO_MODERATION
} from './actionTypes';
import { isEnabledFromState } from './functions';
/**
* Action used by moderator to approve audio and video for a participant.
@ -22,10 +25,15 @@ import {
* @returns {void}
*/
export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => {
const { conference } = getConferenceState(getState());
const state = getState();
const { conference } = getConferenceState(state);
conference.avModerationApprove(MEDIA_TYPE.AUDIO, id);
conference.avModerationApprove(MEDIA_TYPE.VIDEO, id);
if (isEnabledFromState(MEDIA_TYPE.AUDIO, state)) {
conference.avModerationApprove(MEDIA_TYPE.AUDIO, id);
}
if (isEnabledFromState(MEDIA_TYPE.VIDEO, state)) {
conference.avModerationApprove(MEDIA_TYPE.VIDEO, id);
}
};
/**
@ -89,28 +97,54 @@ export const enableModeration = (mediaType: MediaType, actor: Object) => {
};
/**
* Requests disable of audio and video moderation.
* Requests disable of audio moderation.
*
* @returns {{
* type: REQUEST_DISABLE_MODERATED_AUDIO
* type: REQUEST_DISABLE_AUDIO_MODERATION
* }}
*/
export const requestDisableModeration = () => {
export const requestDisableAudioModeration = () => {
return {
type: REQUEST_DISABLE_MODERATION
type: REQUEST_DISABLE_AUDIO_MODERATION
};
};
/**
* Requests enabled audio & video moderation.
* Requests disable of video moderation.
*
* @returns {{
* type: REQUEST_ENABLE_MODERATED_AUDIO
* type: REQUEST_DISABLE_VIDEO_MODERATION
* }}
*/
export const requestEnableModeration = () => {
export const requestDisableVideoModeration = () => {
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 {
getLocalParticipant,
getParticipantDisplayName,
getRemoteParticipants,
isLocalParticipantModerator,
isParticipantModerator,
@ -16,16 +15,16 @@ import {
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import {
hideNotification,
NOTIFICATION_TIMEOUT,
showNotification
} from '../notifications';
import { muteLocal } from '../video-menu/actions.any';
import {
DISABLE_MODERATION,
ENABLE_MODERATION,
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
REQUEST_DISABLE_MODERATION,
REQUEST_ENABLE_MODERATION
REQUEST_DISABLE_AUDIO_MODERATION,
REQUEST_DISABLE_VIDEO_MODERATION,
REQUEST_ENABLE_AUDIO_MODERATION,
REQUEST_ENABLE_VIDEO_MODERATION
} from './actionTypes';
import {
disableModeration,
@ -47,29 +46,10 @@ const AUDIO_MODERATION_NOTIFICATION_ID = 'audio-moderation';
const CS_MODERATION_NOTIFICATION_ID = 'video-moderation';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const { actor, mediaType, type } = action;
const { type } = action;
const { conference } = getConferenceState(getState());
switch (type) {
case DISABLE_MODERATION:
case ENABLE_MODERATION: {
// Audio & video moderation are both enabled at the same time.
// Avoid displaying 2 different notifications.
if (mediaType === MEDIA_TYPE.VIDEO) {
const titleKey = type === ENABLE_MODERATION
? 'notify.moderationStartedTitle'
: 'notify.moderationStoppedTitle';
dispatch(showNotification({
descriptionKey: actor ? 'notify.moderationToggleDescription' : undefined,
descriptionArguments: actor ? {
participantDisplayName: getParticipantDisplayName(getState, actor.getId())
} : undefined,
titleKey
}, NOTIFICATION_TIMEOUT));
}
break;
}
case LOCAL_PARTICIPANT_MODERATION_NOTIFICATION: {
let descriptionKey;
let titleKey;
@ -78,19 +58,16 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
switch (action.mediaType) {
case MEDIA_TYPE.AUDIO: {
titleKey = 'notify.moderationInEffectTitle';
descriptionKey = 'notify.moderationInEffectDescription';
uid = AUDIO_MODERATION_NOTIFICATION_ID;
break;
}
case MEDIA_TYPE.VIDEO: {
titleKey = 'notify.moderationInEffectVideoTitle';
descriptionKey = 'notify.moderationInEffectVideoDescription';
uid = VIDEO_MODERATION_NOTIFICATION_ID;
break;
}
case MEDIA_TYPE.PRESENTER: {
titleKey = 'notify.moderationInEffectCSTitle';
descriptionKey = 'notify.moderationInEffectCSDescription';
uid = CS_MODERATION_NOTIFICATION_ID;
break;
}
@ -110,17 +87,19 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
break;
}
case REQUEST_DISABLE_MODERATION: {
const { conference } = getConferenceState(getState());
case REQUEST_DISABLE_AUDIO_MODERATION: {
conference.disableAVModeration(MEDIA_TYPE.AUDIO);
break;
}
case REQUEST_DISABLE_VIDEO_MODERATION: {
conference.disableAVModeration(MEDIA_TYPE.VIDEO);
break;
}
case REQUEST_ENABLE_MODERATION: {
const { conference } = getConferenceState(getState());
case REQUEST_ENABLE_AUDIO_MODERATION: {
conference.enableAVModeration(MEDIA_TYPE.AUDIO);
break;
}
case REQUEST_ENABLE_VIDEO_MODERATION: {
conference.enableAVModeration(MEDIA_TYPE.VIDEO);
break;
}
@ -174,11 +153,12 @@ StateListenerRegistry.register(
// Audio & video moderation are both enabled at the same time.
// Avoid displaying 2 different notifications.
if (mediaType === MEDIA_TYPE.VIDEO) {
if (mediaType === MEDIA_TYPE.AUDIO) {
dispatch(showNotification({
titleKey: 'notify.unmute',
descriptionKey: 'notify.hostAskedUnmute',
sticky: true
titleKey: 'notify.hostAskedUnmute',
sticky: true,
customActionNameKey: 'notify.unmute',
customActionHandler: () => dispatch(muteLocal(false, MEDIA_TYPE.AUDIO))
}));
}
});

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" />
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -8,12 +8,15 @@ import {
sendAnalytics
} from '../../analytics';
import { APP_STATE_CHANGED } from '../../mobile/background';
import { isForceMuted } from '../../participants-pane/functions';
import { SET_AUDIO_ONLY, setAudioOnly } from '../audio-only';
import { isRoomValid, SET_ROOM } from '../conference';
import { getLocalParticipant } from '../participants';
import { MiddlewareRegistry } from '../redux';
import { getPropertyValue } from '../settings';
import { isLocalVideoTrackDesktop, setTrackMuted, TRACK_ADDED } from '../tracks';
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from './actionTypes';
import { setAudioMuted, setCameraFacingMode, setVideoMuted } from './actions';
import {
CAMERA_FACING_MODE,
@ -55,6 +58,26 @@ MiddlewareRegistry.register(store => next => action => {
return result;
}
case SET_AUDIO_MUTED: {
const state = store.getState();
const participant = getLocalParticipant(state);
if (!action.muted && isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) {
return;
}
break;
}
case SET_VIDEO_MUTED: {
const state = store.getState();
const participant = getLocalParticipant(state);
if (!action.muted && isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) {
return;
}
break;
}
}
return next(action);

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';
/**
* 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_UPDATED,
PIN_PARTICIPANT,
SET_LOADABLE_AVATAR_URL
SET_LOADABLE_AVATAR_URL,
RAISE_HAND_UPDATED
} from './actionTypes';
import {
DISCO_REMOTE_CONTROL_FEATURE
@ -465,7 +466,7 @@ export function participantUpdated(participant = {}) {
* @returns {Promise}
*/
export function participantMutedUs(participant, track) {
return (dispatch, getState) => {
return dispatch => {
if (!participant) {
return;
}
@ -473,12 +474,7 @@ export function participantMutedUs(participant, track) {
const isAudio = track.isAudioTrack();
dispatch(showNotification({
descriptionKey: isAudio ? 'notify.mutedRemotelyDescription' : 'notify.videoMutedRemotelyDescription',
titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle',
titleArguments: {
participantDisplayName:
getParticipantDisplayName(getState, participant.getId())
}
titleKey: isAudio ? 'notify.mutedRemotelyTitle' : 'notify.videoMutedRemotelyTitle'
}));
};
}
@ -574,3 +570,19 @@ export function raiseHand(enabled) {
enabled
};
}
/**
* Update raise hand queue of participants.
*
* @param {Object} participant - Participant that updated raised hand.
* @returns {{
* type: RAISE_HAND_UPDATED,
* participant: Object
* }}
*/
export function raiseHandUpdateQueue(participant) {
return {
type: RAISE_HAND_UPDATED,
participant
};
}

View File

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

View File

@ -10,6 +10,7 @@ import {
PARTICIPANT_LEFT,
PARTICIPANT_UPDATED,
PIN_PARTICIPANT,
RAISE_HAND_UPDATED,
SET_LOADABLE_AVATAR_URL
} from './actionTypes';
import { LOCAL_PARTICIPANT_DEFAULT_ID, PARTICIPANT_ROLE } from './constants';
@ -63,7 +64,8 @@ const DEFAULT_STATE = {
remote: new Map(),
sortedRemoteParticipants: new Map(),
sortedRemoteScreenshares: new Map(),
speakersList: new Map()
speakersList: new Map(),
raisedHandsQueue: []
};
/**
@ -318,6 +320,12 @@ ReducerRegistry.register('features/base/participants', (state = DEFAULT_STATE, a
return { ...state };
}
case RAISE_HAND_UPDATED: {
return {
...state,
raisedHandsQueue: action.queue
};
}
case SCREEN_SHARE_REMOTE_PARTICIPANTS_UPDATED: {
const { participantIds } = action;
const sortedSharesList = [];

View File

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

View File

@ -3,6 +3,7 @@
import type { Dispatch } from 'redux';
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { getParticipantById } from '../base/participants/functions';
import { OPEN_CHAT } from './actionTypes';
import { closeChat } from './actions.any';
@ -27,6 +28,27 @@ export function openChat(participant: Object) {
};
}
/**
* Displays the chat panel for a participant identified by an id.
*
* @param {string} id - The id of the participant.
* @returns {{
* participant: Participant,
* type: OPEN_CHAT
* }}
*/
export function openChatById(id: string) {
return function(dispatch: (Object) => Object, getState: Function) {
const participant = getParticipantById(getState(), id);
return dispatch({
participant,
type: OPEN_CHAT
});
};
}
/**
* Toggles display of the chat panel.
*

View File

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

View File

@ -45,3 +45,13 @@ export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';
* }
*/
export const SET_NOTIFICATIONS_ENABLED = 'SET_NOTIFICATIONS_ENABLED';
/**
* The type of (redux) action which signals that raise hand notifications
* should be dismissed.
*
* {
* type: HIDE_RAISE_HAND_NOTIFICATIONS
* }
*/
export const HIDE_RAISE_HAND_NOTIFICATIONS = 'HIDE_RAISE_HAND_NOTIFICATIONS';

View File

@ -9,6 +9,7 @@ import { getParticipantCount } from '../base/participants/functions';
import {
CLEAR_NOTIFICATIONS,
HIDE_NOTIFICATION,
HIDE_RAISE_HAND_NOTIFICATIONS,
SET_NOTIFICATIONS_ENABLED,
SHOW_NOTIFICATION
} from './actionTypes';
@ -48,6 +49,19 @@ export function hideNotification(uid: string) {
};
}
/**
* Removes the raise hand notifications.
*
* @returns {{
* type: HIDE_RAISE_HAND_NOTIFICATIONS
* }}
*/
export function hideRaiseHandNotifications() {
return {
type: HIDE_RAISE_HAND_NOTIFICATIONS
};
}
/**
* Stops notifications from being displayed.
*

View File

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

View File

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

View File

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

View File

@ -1,27 +1,39 @@
// @flow
import React, { useCallback } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../../../lobby/actions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../../constants';
import { useLobbyActions } from '../../hooks';
import ParticipantItem from './ParticipantItem';
import { ParticipantActionButton } from './styled';
type Props = {
/**
* If an overflow drawer should be displayed.
*/
overflowDrawer: boolean,
/**
* Callback used to open a drawer with admit/reject actions.
*/
openDrawerForParticipant: Function,
/**
* Participant reference
*/
participant: Object
};
export const LobbyParticipantItem = ({ participant: p }: Props) => {
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(approveKnockingParticipant(p.id), [ dispatch ]));
const reject = useCallback(() => dispatch(rejectKnockingParticipant(p.id), [ dispatch ]));
export const LobbyParticipantItem = ({
overflowDrawer,
participant: p,
openDrawerForParticipant
}: Props) => {
const { id } = p;
const [ admit ] = useLobbyActions({ participantID: id });
const { t } = useTranslation();
return (
@ -30,14 +42,12 @@ export const LobbyParticipantItem = ({ participant: p }: Props) => {
audioMediaState = { MEDIA_STATE.NONE }
displayName = { p.name }
local = { p.local }
participantID = { p.id }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantID = { id }
raisedHand = { p.raisedHand }
videoMuteState = { MEDIA_STATE.NONE }
videoMediaState = { MEDIA_STATE.NONE }
youText = { t('chat.you') }>
<ParticipantActionButton
onClick = { reject }>
{t('lobby.reject')}
</ParticipantActionButton>
<ParticipantActionButton
onClick = { admit }
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
import { withStyles } from '@material-ui/core/styles';
import React, { Component } from 'react';
import { Avatar } from '../../../base/avatar';
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
@ -21,10 +22,13 @@ import {
isParticipantModerator
} from '../../../base/participants';
import { connect } from '../../../base/redux';
import { withPixelLineHeight } from '../../../base/styles/functions.web';
import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/tracks';
import { openChat } from '../../../chat/actions';
import { stopSharedVideo } from '../../../shared-video/actions.any';
import { openChatById } from '../../../chat/actions';
import { setVolume } from '../../../filmstrip/actions.web';
import { Drawer, DrawerPortal } from '../../../toolbox/components/web';
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../../video-menu';
import { VolumeSlider } from '../../../video-menu/components/web';
import MuteRemoteParticipantsVideoDialog from '../../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
import { getComputedOuterHeight } from '../../functions';
@ -73,11 +77,33 @@ type Props = {
*/
_participant: Object,
/**
* A value between 0 and 1 indicating the volume of the participant's
* audio element.
*/
_volume: ?number,
/**
* Closes a drawer if open.
*/
closeDrawer: Function,
/**
* An object containing the CSS classes.
*/
classes?: {[ key: string]: string},
/**
* The dispatch function from redux.
*/
dispatch: Function,
/**
* The participant for which the drawer is open.
* It contains the displayName & participantID.
*/
drawerParticipant: Object,
/**
* Callback used to open a confirmation dialog for audio muting.
*/
@ -108,6 +134,12 @@ type Props = {
*/
participantID: string,
/**
* True if an overflow drawer should be displayed.
*/
overflowDrawer: boolean,
/**
* The translate function.
*/
@ -122,6 +154,25 @@ type State = {
isHidden: boolean
};
const styles = theme => {
return {
drawer: {
'& > div': {
...withPixelLineHeight(theme.typography.bodyShortRegularLarge),
lineHeight: '32px',
'& svg': {
fill: theme.palette.icon01
}
},
'&:first-child': {
marginTop: 15
}
}
};
};
/**
* Implements the MeetingParticipantContextMenu component.
*/
@ -146,13 +197,27 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
this._containerRef = React.createRef();
this._getCurrentParticipantId = this._getCurrentParticipantId.bind(this);
this._onGrantModerator = this._onGrantModerator.bind(this);
this._onKick = this._onKick.bind(this);
this._onMuteEveryoneElse = this._onMuteEveryoneElse.bind(this);
this._onMuteVideo = this._onMuteVideo.bind(this);
this._onSendPrivateMessage = this._onSendPrivateMessage.bind(this);
this._onStopSharedVideo = this._onStopSharedVideo.bind(this);
this._position = this._position.bind(this);
this._onVolumeChange = this._onVolumeChange.bind(this);
}
_getCurrentParticipantId: () => string;
/**
* Returns the participant id for the item we want to operate.
*
* @returns {void}
*/
_getCurrentParticipantId() {
const { _participant, drawerParticipant, overflowDrawer } = this.props;
return overflowDrawer ? drawerParticipant?.participantID : _participant?.id;
}
_onGrantModerator: () => void;
@ -163,10 +228,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void}
*/
_onGrantModerator() {
const { _participant, dispatch } = this.props;
dispatch(openDialog(GrantModeratorDialog, {
participantID: _participant?.id
this.props.dispatch(openDialog(GrantModeratorDialog, {
participantID: this._getCurrentParticipantId()
}));
}
@ -178,10 +241,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void}
*/
_onKick() {
const { _participant, dispatch } = this.props;
dispatch(openDialog(KickRemoteParticipantDialog, {
participantID: _participant?.id
this.props.dispatch(openDialog(KickRemoteParticipantDialog, {
participantID: this._getCurrentParticipantId()
}));
}
@ -195,7 +256,7 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
_onStopSharedVideo() {
const { dispatch } = this.props;
dispatch(stopSharedVideo());
dispatch(this._onStopSharedVideo());
}
_onMuteEveryoneElse: () => void;
@ -206,10 +267,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void}
*/
_onMuteEveryoneElse() {
const { _participant, dispatch } = this.props;
dispatch(openDialog(MuteEveryoneDialog, {
exclude: [ _participant?.id ]
this.props.dispatch(openDialog(MuteEveryoneDialog, {
exclude: [ this._getCurrentParticipantId() ]
}));
}
@ -221,10 +280,8 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void}
*/
_onMuteVideo() {
const { _participant, dispatch } = this.props;
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
participantID: _participant?.id
this.props.dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
participantID: this._getCurrentParticipantId()
}));
}
@ -236,9 +293,10 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {void}
*/
_onSendPrivateMessage() {
const { _participant, dispatch } = this.props;
const { closeDrawer, dispatch, overflowDrawer } = this.props;
dispatch(openChat(_participant));
dispatch(openChatById(this._getCurrentParticipantId()));
overflowDrawer && closeDrawer();
}
_position: () => void;
@ -270,6 +328,21 @@ class MeetingParticipantContextMenu extends Component<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.
*
@ -306,9 +379,14 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
_isParticipantAudioMuted,
_localVideoOwner,
_participant,
_volume = 1,
classes,
closeDrawer,
drawerParticipant,
onEnter,
onLeave,
onSelect,
overflowDrawer,
muteAudio,
t
} = this.props;
@ -317,90 +395,116 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
return null;
}
return (
<ContextMenu
className = { ignoredChildClassName }
innerRef = { this._containerRef }
isHidden = { this.state.isHidden }
onClick = { onSelect }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{
!_participant.isFakeParticipant && (
<>
<ContextMenuItemGroup>
{
_isLocalModerator && (
<>
{
!_isParticipantAudioMuted
&& <ContextMenuItem onClick = { muteAudio(_participant) }>
<ContextMenuIcon src = { IconMicDisabled } />
<span>{t('dialog.muteParticipantButton')}</span>
</ContextMenuItem>
}
<ContextMenuItem onClick = { this._onMuteEveryoneElse }>
<ContextMenuIcon src = { IconMuteEveryoneElse } />
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
</ContextMenuItem>
</>
)
}
{
_isLocalModerator && (
_isParticipantVideoMuted || (
<ContextMenuItem onClick = { this._onMuteVideo }>
<ContextMenuIcon src = { IconVideoOff } />
<span>{t('participantsPane.actions.stopVideo')}</span>
</ContextMenuItem>
)
)
}
</ContextMenuItemGroup>
<ContextMenuItemGroup>
{
_isLocalModerator && (
<>
{
!_isParticipantModerator && (
<ContextMenuItem onClick = { this._onGrantModerator }>
<ContextMenuIcon src = { IconCrown } />
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
</ContextMenuItem>
)
}
<ContextMenuItem onClick = { this._onKick }>
<ContextMenuIcon src = { IconCloseCircle } />
<span>{ t('videothumbnail.kick') }</span>
</ContextMenuItem>
</>
)
}
{
_isChatButtonEnabled && (
<ContextMenuItem onClick = { this._onSendPrivateMessage }>
<ContextMenuIcon src = { IconMessage } />
<span>{t('toolbar.accessibilityLabel.privateMessage')}</span>
</ContextMenuItem>
)
}
</ContextMenuItemGroup>
</>
)
}
{
_participant.isFakeParticipant && _localVideoOwner && (
const actions
= _participant.isFakeParticipant ? (
<>
{_localVideoOwner && (
<ContextMenuItem onClick = { this._onStopSharedVideo }>
<ContextMenuIcon src = { IconShareVideo } />
<span>{t('toolbar.stopSharedVideo')}</span>
</ContextMenuItem>
)
}
</ContextMenu>
)}
</>
) : (
<>
{_isLocalModerator && (
<ContextMenuItemGroup>
<>
{
!_isParticipantAudioMuted && overflowDrawer
&& <ContextMenuItem onClick = { muteAudio(_participant) }>
<ContextMenuIcon src = { IconMicDisabled } />
<span>{t('dialog.muteParticipantButton')}</span>
</ContextMenuItem>
}
<ContextMenuItem onClick = { this._onMuteEveryoneElse }>
<ContextMenuIcon src = { IconMuteEveryoneElse } />
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
</ContextMenuItem>
</>
{
_isParticipantVideoMuted || (
<ContextMenuItem onClick = { this._onMuteVideo }>
<ContextMenuIcon src = { IconVideoOff } />
<span>{t('participantsPane.actions.stopVideo')}</span>
</ContextMenuItem>
)
}
</ContextMenuItemGroup>
)}
<ContextMenuItemGroup>
{
_isLocalModerator && (
<>
{
!_isParticipantModerator && (
<ContextMenuItem onClick = { this._onGrantModerator }>
<ContextMenuIcon src = { IconCrown } />
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
</ContextMenuItem>
)
}
<ContextMenuItem onClick = { this._onKick }>
<ContextMenuIcon src = { IconCloseCircle } />
<span>{ t('videothumbnail.kick') }</span>
</ContextMenuItem>
</>
)
}
{
_isChatButtonEnabled && (
<ContextMenuItem onClick = { this._onSendPrivateMessage }>
<ContextMenuIcon src = { IconMessage } />
<span>{t('toolbar.accessibilityLabel.privateMessage')}</span>
</ContextMenuItem>
)
}
</ContextMenuItemGroup>
{ overflowDrawer && typeof _volume === 'number' && !isNaN(_volume)
&& <ContextMenuItemGroup>
<VolumeSlider
initialValue = { _volume }
key = 'volume-slider'
onChange = { this._onVolumeChange } />
</ContextMenuItemGroup>
}
</>
);
return (
<>
{ !overflowDrawer
&& <ContextMenu
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>
</ContextMenuItemGroup>
{ actions }
</div>
</Drawer>
</DrawerPortal>
</>
);
}
}
@ -414,10 +518,12 @@ class MeetingParticipantContextMenu extends Component<Props, State> {
* @returns {Props}
*/
function _mapStateToProps(state, ownProps): Object {
const { participantID } = ownProps;
const { participantID, overflowDrawer, drawerParticipant } = ownProps;
const { ownerId } = state['features/shared-video'];
const localParticipantId = getLocalParticipant(state).id;
const participant = getParticipantByIdOrUndefined(state, participantID);
const participant = getParticipantByIdOrUndefined(state,
overflowDrawer ? drawerParticipant?.participantID : participantID);
const _isLocalModerator = isLocalParticipantModerator(state);
const _isChatButtonEnabled = isToolbarButtonEnabled('chat', state);
@ -425,6 +531,10 @@ function _mapStateToProps(state, ownProps): Object {
const _isParticipantAudioMuted = isParticipantAudioMuted(participant, state);
const _isParticipantModerator = isParticipantModerator(participant);
const { participantsVolume } = state['features/filmstrip'];
const id = participant?.id;
const isLocal = participant?.local ?? true;
return {
_isLocalModerator,
_isChatButtonEnabled,
@ -432,8 +542,9 @@ function _mapStateToProps(state, ownProps): Object {
_isParticipantVideoMuted,
_isParticipantAudioMuted,
_localVideoOwner: Boolean(ownerId === localParticipantId),
_participant: participant
_participant: participant,
_volume: isLocal ? undefined : id ? participantsVolume[id] : undefined
};
}
export default translate(connect(_mapStateToProps)(MeetingParticipantContextMenu));
export default withStyles(styles)(translate(connect(_mapStateToProps)(MeetingParticipantContextMenu)));

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,17 +71,39 @@ export function isForceMuted(participant: Object, mediaType: MediaType, state: O
* @param {Object} participant - The participant.
* @param {boolean} muted - The mute state of the participant.
* @param {Object} state - The redux state.
* @param {boolean} ignoreDominantSpeaker - Whether to ignore the dominant speaker state.
* @returns {MediaState}
*/
export function getParticipantAudioMediaState(participant: Object, muted: Boolean, state: Object) {
const dominantSpeaker = getDominantSpeakerParticipant(state);
if (muted) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) {
return MEDIA_STATE.FORCE_MUTED;
}
return MEDIA_STATE.MUTED;
}
if (participant === dominantSpeaker) {
return MEDIA_STATE.DOMINANT_SPEAKER;
}
return MEDIA_STATE.UNMUTED;
}
/**
* Determines the video media state (the mic icon) for a participant.
*
* @param {Object} participant - The participant.
* @param {boolean} muted - The mute state of the participant.
* @param {Object} state - The redux state.
* @param {boolean} ignoreDominantSpeaker - Whether to ignore the dominant speaker state.
* @returns {MediaState}
*/
export function getParticipantVideoMediaState(participant: Object, muted: Boolean, state: Object) {
if (muted) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) {
if (isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) {
return MEDIA_STATE.FORCE_MUTED;
}
@ -144,7 +166,7 @@ export const getParticipantsPaneOpen = (state: Object) => Boolean(getState(state
export function getQuickActionButtonType(participant: Object, isAudioMuted: Boolean, state: Object) {
// handled only by moderators
if (isLocalParticipantModerator(state)) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state)) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO, state) || isForceMuted(participant, MEDIA_TYPE.VIDEO, state)) {
return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
}
if (!isAudioMuted) {

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

View File

@ -80,3 +80,14 @@ export function isVideoSettingsButtonDisabled(state: Object) {
export function isVideoMuteButtonDisabled(state: Object) {
return !hasAvailableDevices(state, 'videoInput');
}
/**
* If an overflow drawer should be displayed or not.
* This is usually done for mobile devices or on narrow screens.
*
* @param {Object} state - The state from the Redux store.
* @returns {boolean}
*/
export function showOverflowDrawer(state: Object) {
return state['features/toolbox'].overflowDrawer;
}

View File

@ -6,7 +6,7 @@ import {
createRemoteVideoMenuButtonEvent,
sendAnalytics
} from '../../analytics';
import { grantModerator } from '../../base/participants';
import { getParticipantById, grantModerator } from '../../base/participants';
type Props = {
@ -20,6 +20,11 @@ type Props = {
*/
participantID: string,
/**
* The name of the remote participant to be granted moderator rights.
*/
participantName: string,
/**
* Function to translate i18n labels.
*/
@ -64,3 +69,17 @@ export default class AbstractGrantModeratorDialog
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated {@code AbstractMuteEveryoneDialog}'s props.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component.
* @returns {Props}
*/
export function abstractMapStateToProps(state: Object, ownProps: Props) {
return {
participantName: getParticipantById(state, ownProps.participantID).name
};
}

View File

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

View File

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

View File

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

View File

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

View File

@ -32,8 +32,8 @@ export type Props = {
*
* @extends Component
*/
export default class AbstractMuteRemoteParticipantsVideoDialog<P:Props = Props>
extends Component<P> {
export default class AbstractMuteRemoteParticipantsVideoDialog<P:Props = Props, State=void>
extends Component<P, State> {
/**
* 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 MuteEveryoneDialog } from './MuteEveryoneDialog';
export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog';
export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog';
export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog';
export { default as RemoteVideoMenu } from './RemoteVideoMenu';
export { default as SharedVideoMenu } from './SharedVideoMenu';

View File

@ -6,7 +6,7 @@ import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { connect } from '../../../base/redux';
import AbstractGrantModeratorDialog
from '../AbstractGrantModeratorDialog';
, { abstractMapStateToProps } from '../AbstractGrantModeratorDialog';
/**
* Dialog to confirm a grant moderator action.
@ -26,7 +26,7 @@ class GrantModeratorDialog extends AbstractGrantModeratorDialog {
titleKey = 'dialog.grantModeratorTitle'
width = 'small'>
<div>
{ this.props.t('dialog.grantModeratorDialog') }
{ this.props.t('dialog.grantModeratorDialog', { participantName: this.props.participantName }) }
</div>
</Dialog>
);
@ -35,4 +35,4 @@ class GrantModeratorDialog extends AbstractGrantModeratorDialog {
_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 { translate } from '../../../base/i18n';
import { Switch } from '../../../base/react';
import { connect } from '../../../base/redux';
import AbstractMuteEveryoneDialog, { abstractMapStateToProps, type Props } from '../AbstractMuteEveryoneDialog';
import AbstractMuteEveryoneDialog, { abstractMapStateToProps, type Props }
from '../AbstractMuteEveryoneDialog';
/**
* A React Component with the contents for a dialog that asks for confirmation
@ -14,6 +16,23 @@ import AbstractMuteEveryoneDialog, { abstractMapStateToProps, type Props } from
* @extends AbstractMuteEveryoneDialog
*/
class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<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()}.
*
@ -27,8 +46,22 @@ class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<Props> {
onSubmit = { this._onSubmit }
titleString = { this.props.title }
width = 'small'>
<div>
{ this.props.content }
<div className = 'mute-dialog'>
{ 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>
</Dialog>
);

View File

@ -4,6 +4,7 @@ import React from 'react';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { Switch } from '../../../base/react';
import { connect } from '../../../base/redux';
import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props }
from '../AbstractMuteEveryonesVideoDialog';
@ -15,6 +16,23 @@ import AbstractMuteEveryonesVideoDialog, { abstractMapStateToProps, type Props }
* @extends AbstractMuteEveryonesVideoDialog
*/
class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<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()}.
*
@ -28,8 +46,22 @@ class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<Props> {
onSubmit = { this._onSubmit }
titleString = { this.props.title }
width = 'small'>
<div>
{ this.props.content }
<div className = 'mute-dialog'>
{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>
</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 MuteEveryoneElseButton } from './MuteEveryoneElseButton';
export { default as MuteEveryoneElsesVideoButton } from './MuteEveryoneElsesVideoButton';
export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog';
export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog';
export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton';
export { REMOTE_CONTROL_MENU_STATES, default as RemoteControlButton } from './RemoteControlButton';