feat: UI part for A/V moderation. (#9195)

* feat: Initial UI part for A/V moderation.

Based on https://github.com/jitsi/jitsi-meet/pull/7779

Co-authored-by: Gabriel Imre <gabriel.lucaci@8x8.com>

* feat: Hides context menu in p2p or only moderators in the meeting.

* feat: Show notifications on enable/disable.

* feat(moderation): Add buttons to participant list & notifications

* fix(moderation): Fix raised hand participant leaving

* feat(moderation): Add support for video moderation

* feat(moderation): Add mute all video to context menu

* feat(moderation): Redo participants list 'More menu'

* fix: Fixes clearing av_moderation table.

* fix: Start moderation context menu

* fix(moderation): Show notification if unapproved participant tries to start CS

Co-authored-by: Gabriel Imre <gabriel.lucaci@8x8.com>
Co-authored-by: Vlad Piersec <vlad.piersec@8x8.com>
This commit is contained in:
Дамян Минков 2021-06-23 14:23:44 +03:00 committed by GitHub
parent 3e8f725c62
commit 64ae9c7953
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 1643 additions and 198 deletions

View File

@ -38,17 +38,17 @@
} }
} }
#knocking-participant-list { #notification-participant-list {
background-color: $newToolbarBackgroundColor; background-color: $newToolbarBackgroundColor;
border: 1px solid rgba(255, 255, 255, .4); border: 1px solid rgba(255, 255, 255, .4);
border-radius: 8px; border-radius: 8px;
display: flex;
flex-direction: column;
left: 0; left: 0;
margin: 20px; margin: 20px;
max-height: 600px;
overflow: hidden;
overflow-y: auto;
position: fixed; position: fixed;
top: 20; top: 30px;
transition: top 1s ease;
z-index: $toolbarZ + 1; z-index: $toolbarZ + 1;
&.toolbox-visible { &.toolbox-visible {
@ -94,8 +94,6 @@
.knocking-participants-container { .knocking-participants-container {
list-style-type: none; list-style-type: none;
max-height: 600px;
overflow-y: scroll;
padding: 0 15px 15px 15px; padding: 0 15px 15px 15px;
} }

View File

@ -521,6 +521,7 @@
"focus": "Conference focus", "focus": "Conference focus",
"focusFail": "{{component}} not available - retry in {{ms}} sec", "focusFail": "{{component}} not available - retry in {{ms}} sec",
"grantedTo": "Moderator rights granted to {{to}}!", "grantedTo": "Moderator rights granted to {{to}}!",
"hostAskedUnmute": "The host would like you to unmute",
"invitedOneMember": "{{name}} has been invited", "invitedOneMember": "{{name}} has been invited",
"invitedThreePlusMembers": "{{name}} and {{count}} others have been invited", "invitedThreePlusMembers": "{{name}} and {{count}} others have been invited",
"invitedTwoMembers": "{{first}} and {{second}} have been invited", "invitedTwoMembers": "{{first}} and {{second}} have been invited",
@ -551,6 +552,18 @@
"oldElectronClientDescription1": "You appear to be using an old version of the Jitsi Meet client which has known security vulnerabilities. Please make sure you update to our ", "oldElectronClientDescription1": "You appear to be using an old version of the Jitsi Meet client which has known security vulnerabilities. Please make sure you update to our ",
"oldElectronClientDescription2": "latest build", "oldElectronClientDescription2": "latest build",
"oldElectronClientDescription3": " now!", "oldElectronClientDescription3": " now!",
"moderationInEffectDescription": "Please raise hand if you want to speak",
"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",
"moderationRequestFromModerator": "The host would like you to unmute",
"moderationRequestFromParticipant": "Wants to speak",
"moderationStartedTitle": "Moderation started",
"moderationStoppedTitle": "Moderation stopped",
"moderationToggleDescription": "by {{participantDisplayName}}",
"raiseHandAction": "Raise hand",
"groupTitle": "Notifications" "groupTitle": "Notifications"
}, },
"participantsPane": { "participantsPane": {
@ -560,8 +573,12 @@
"participantsList": "Meeting participants ({{count}})" "participantsList": "Meeting participants ({{count}})"
}, },
"actions": { "actions": {
"allow": "Allow attendees to:",
"invite": "Invite Someone", "invite": "Invite Someone",
"askUnmute": "Ask to unmute",
"muteAll": "Mute all", "muteAll": "Mute all",
"startModeration": "Unmute themselves or start video",
"stopEveryonesVideo": "Stop everyone's video",
"stopVideo": "Stop video" "stopVideo": "Stop video"
} }
}, },

View File

@ -20,9 +20,9 @@ import { MEDIA_TYPE } from '../../react/features/base/media';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipantById, getParticipantById,
participantUpdated,
pinParticipant, pinParticipant,
kickParticipant kickParticipant,
raiseHand
} from '../../react/features/base/participants'; } from '../../react/features/base/participants';
import { updateSettings } from '../../react/features/base/settings'; import { updateSettings } from '../../react/features/base/settings';
import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks'; import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks';
@ -205,13 +205,7 @@ function initCommands() {
const { raisedHand } = localParticipant; const { raisedHand } = localParticipant;
sendAnalytics(createApiEvent('raise-hand.toggled')); sendAnalytics(createApiEvent('raise-hand.toggled'));
APP.store.dispatch( APP.store.dispatch(raiseHand(!raisedHand));
participantUpdated({
id: APP.conference.getMyUserId(),
local: true,
raisedHand: !raisedHand
})
);
}, },
/** /**

View File

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

View File

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

View File

@ -0,0 +1,87 @@
/**
* The type of (redux) action which signals that A/V Moderation had been disabled.
*
* {
* type: DISABLE_MODERATION
* }
*/
export const DISABLE_MODERATION = 'DISABLE_MODERATION';
/**
* The type of (redux) action which signals that the notification for audio/video unmute should
* be dismissed.
*
* {
* type: DISMISS_PARTICIPANT_PENDING_AUDIO
* }
*/
export const DISMISS_PENDING_PARTICIPANT = 'DISMISS_PENDING_PARTICIPANT';
/**
* The type of (redux) action which signals that A/V Moderation had been enabled.
*
* {
* type: ENABLE_MODERATION
* }
*/
export const ENABLE_MODERATION = 'ENABLE_MODERATION';
/**
* The type of (redux) action which signals that A/V Moderation disable has been requested.
*
* {
* type: REQUEST_DISABLE_MODERATION
* }
*/
export const REQUEST_DISABLE_MODERATION = 'REQUEST_DISABLE_MODERATION';
/**
* The type of (redux) action which signals that A/V Moderation enable has been requested.
*
* {
* type: REQUEST_ENABLE_MODERATION
* }
*/
export const REQUEST_ENABLE_MODERATION = 'REQUEST_ENABLE_MODERATION';
/**
* The type of (redux) action which signals that the local participant had been approved.
*
* {
* type: LOCAL_PARTICIPANT_APPROVED,
* mediaType: MediaType
* }
*/
export const LOCAL_PARTICIPANT_APPROVED = 'LOCAL_PARTICIPANT_APPROVED';
/**
* The type of (redux) action which signals to show notification to the local participant.
*
* {
* type: LOCAL_PARTICIPANT_MODERATION_NOTIFICATION
* }
*/
export const LOCAL_PARTICIPANT_MODERATION_NOTIFICATION = 'LOCAL_PARTICIPANT_MODERATION_NOTIFICATION';
/**
* The type of (redux) action which signals that a participant was approved for a media type.
*
* {
* type: PARTICIPANT_APPROVED,
* mediaType: MediaType
* participantId: String
* }
*/
export const PARTICIPANT_APPROVED = 'PARTICIPANT_APPROVED';
/**
* The type of (redux) action which signals that a participant asked to have its audio umuted.
*
* {
* type: PARTICIPANT_PENDING_AUDIO
* }
*/
export const PARTICIPANT_PENDING_AUDIO = 'PARTICIPANT_PENDING_AUDIO';

View File

@ -0,0 +1,173 @@
// @flow
import { getConferenceState } from '../base/conference';
import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import {
DISMISS_PENDING_PARTICIPANT,
DISABLE_MODERATION,
ENABLE_MODERATION,
LOCAL_PARTICIPANT_APPROVED,
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
PARTICIPANT_APPROVED,
PARTICIPANT_PENDING_AUDIO,
REQUEST_DISABLE_MODERATION,
REQUEST_ENABLE_MODERATION
} from './actionTypes';
/**
* Action used by moderator to approve audio and video for a participant.
*
* @param {staring} id - The id of the participant to be approved.
* @returns {void}
*/
export const approveParticipant = (id: string) => (dispatch: Function, getState: Function) => {
const { conference } = getConferenceState(getState());
conference.avModerationApprove(MEDIA_TYPE.AUDIO, id);
conference.avModerationApprove(MEDIA_TYPE.VIDEO, id);
};
/**
* Audio or video moderation is disabled.
*
* @param {MediaType} mediaType - The media type that was disabled.
* @param {JitsiParticipant} actor - The actor disabling.
* @returns {{
* type: REQUEST_DISABLE_MODERATED_AUDIO
* }}
*/
export const disableModeration = (mediaType: MediaType, actor: Object) => {
return {
type: DISABLE_MODERATION,
mediaType,
actor
};
};
/**
* Hides the notification with the participant that asked to unmute audio.
*
* @param {string} id - The participant id.
* @returns {Object}
*/
export function dismissPendingAudioParticipant(id: string) {
return dismissPendingParticipant(id, MEDIA_TYPE.AUDIO);
}
/**
* Hides the notification with the participant that asked to unmute.
*
* @param {string} id - The participant id.
* @param {MediaType} mediaType - The media type.
* @returns {Object}
*/
export function dismissPendingParticipant(id: string, mediaType: MediaType) {
return {
type: DISMISS_PENDING_PARTICIPANT,
id,
mediaType
};
}
/**
* Audio or video moderation is enabled.
*
* @param {MediaType} mediaType - The media type that was enabled.
* @param {JitsiParticipant} actor - The actor enabling.
* @returns {{
* type: REQUEST_ENABLE_MODERATED_AUDIO
* }}
*/
export const enableModeration = (mediaType: MediaType, actor: Object) => {
return {
type: ENABLE_MODERATION,
mediaType,
actor
};
};
/**
* Requests disable of audio and video moderation.
*
* @returns {{
* type: REQUEST_DISABLE_MODERATED_AUDIO
* }}
*/
export const requestDisableModeration = () => {
return {
type: REQUEST_DISABLE_MODERATION
};
};
/**
* Requests enabled audio & video moderation.
*
* @returns {{
* type: REQUEST_ENABLE_MODERATED_AUDIO
* }}
*/
export const requestEnableModeration = () => {
return {
type: REQUEST_ENABLE_MODERATION
};
};
/**
* Local participant was approved to be able to unmute audio and video.
*
* @param {MediaType} mediaType - The media type to disable.
* @returns {{
* type: LOCAL_PARTICIPANT_APPROVED
* }}
*/
export const localParticipantApproved = (mediaType: MediaType) => {
return {
type: LOCAL_PARTICIPANT_APPROVED,
mediaType
};
};
/**
* Shows notification when A/V moderation is enabled and local participant is still not approved.
*
* @param {MediaType} mediaType - Audio or video media type.
* @returns {Object}
*/
export function showModeratedNotification(mediaType: MediaType) {
return {
type: LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
mediaType
};
}
/**
* Shows a notification with the participant that asked to audio unmute.
*
* @param {string} id - The participant id.
* @returns {Object}
*/
export function participantPendingAudio(id: string) {
return {
type: PARTICIPANT_PENDING_AUDIO,
id
};
}
/**
* A participant was approved to unmute for a mediaType.
*
* @param {string} id - The id of the approved participant.
* @param {MediaType} mediaType - The media type which was approved.
* @returns {{
* type: PARTICIPANT_APPROVED,
* }}
*/
export function participantApproved(id: string, mediaType: MediaType) {
return {
type: PARTICIPANT_APPROVED,
id,
mediaType
};
}

View File

@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import NotificationWithParticipants from '../../notifications/components/web/NotificationWithParticipants';
import { approveAudio, 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 = { approveAudio }
onReject = { dismissPendingAudioParticipant }
participants = { participants }
rejectButtonText = { t('dialog.dismiss') }
testIdPrefix = 'avModeration' />
</>
) : null;
}

View File

@ -0,0 +1,19 @@
// @flow
import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
/**
* Mapping between a media type and the witelist reducer key.
*/
export const MEDIA_TYPE_TO_WHITELIST_STORE_KEY: {[key: MediaType]: string} = {
[MEDIA_TYPE.AUDIO]: 'audioWhitelist',
[MEDIA_TYPE.VIDEO]: 'videoWhitelist'
};
/**
* Mapping between a media type and the pending reducer key.
*/
export const MEDIA_TYPE_TO_PENDING_STORE_KEY: {[key: MediaType]: string} = {
[MEDIA_TYPE.AUDIO]: 'pendingAudio',
[MEDIA_TYPE.VIDEO]: 'pendingVideo'
};

View File

@ -0,0 +1,115 @@
// @flow
import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import { getParticipantById, isLocalParticipantModerator } from '../base/participants/functions';
import { MEDIA_TYPE_TO_WHITELIST_STORE_KEY, MEDIA_TYPE_TO_PENDING_STORE_KEY } from './constants';
/**
* Returns this feature's root state.
*
* @param {Object} state - Global state.
* @returns {Object} Feature state.
*/
const getState = state => state['features/av-moderation'];
/**
* Returns whether moderation is enabled per media type.
*
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @param {Object} state - Global state.
* @returns {null|boolean|*}
*/
export const isEnabledFromState = (mediaType: MediaType, state: Object) =>
(mediaType === MEDIA_TYPE.AUDIO
? getState(state).audioModerationEnabled
: getState(state).videoModerationEnabled) === true;
/**
* Returns whether moderation is enabled per media type.
*
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @returns {null|boolean|*}
*/
export const isEnabled = (mediaType: MediaType) => (state: Object) => isEnabledFromState(mediaType, state);
/**
* Returns whether local participant is approved to unmute a media type.
*
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @param {Object} state - Global state.
* @returns {boolean}
*/
export const isLocalParticipantApprovedFromState = (mediaType: MediaType, state: Object) => {
const approved = (mediaType === MEDIA_TYPE.AUDIO
? getState(state).audioUnmuteApproved
: getState(state).videoUnmuteApproved) === true;
return approved || isLocalParticipantModerator(state);
};
/**
* Returns whether local participant is approved to unmute a media type.
*
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @returns {null|boolean|*}
*/
export const isLocalParticipantApproved = (mediaType: MediaType) =>
(state: Object) =>
isLocalParticipantApprovedFromState(mediaType, state);
/**
* Returns a selector creator which determines if the participant is approved or not for a media type.
*
* @param {string} id - The participant id.
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @returns {boolean}
*/
export const isParticipantApproved = (id: string, mediaType: MediaType) => (state: Object) => {
const storeKey = MEDIA_TYPE_TO_WHITELIST_STORE_KEY[mediaType];
return Boolean(getState(state)[storeKey][id]);
};
/**
* Returns a selector creator which determines if the participant is pending or not for a media type.
*
* @param {string} id - The participant id.
* @param {MEDIA_TYPE} mediaType - The media type to check.
* @returns {boolean}
*/
export const isParticipantPending = (id: string, mediaType: MediaType) => (state: Object) => {
const storeKey = MEDIA_TYPE_TO_PENDING_STORE_KEY[mediaType];
const arr = getState(state)[storeKey];
return Boolean(arr.find(pending => pending === id));
};
/**
* Selector which returns a list with all the participants asking to audio unmute.
* This is visible ony for the moderator.
*
* @param {Object} state - The global state.
* @returns {Array<Object>}
*/
export const getParticipantsAskingToAudioUnmute = (state: Object) => {
if (isLocalParticipantModerator(state)) {
const ids = getState(state).pendingAudio;
return ids.map(id => getParticipantById(state, id)).filter(Boolean);
}
return [];
};
/**
* Returns true if a special notification can be displayed when a participant
* tries to unmute.
*
* @param {MediaType} mediaType - 'audio' or 'video' media type.
* @param {Object} state - The global state.
* @returns {boolean}
*/
export const shouldShowModeratedNotification = (mediaType: MediaType, state: Object) =>
isEnabledFromState(mediaType, state)
&& !isLocalParticipantApprovedFromState(mediaType, state);

View File

@ -0,0 +1,190 @@
// @flow
import { batch } from 'react-redux';
import { getConferenceState } from '../base/conference';
import { JitsiConferenceEvents } from '../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../base/media';
import {
getParticipantDisplayName,
isLocalParticipantModerator,
PARTICIPANT_UPDATED,
raiseHand
} from '../base/participants';
import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux';
import {
hideNotification,
NOTIFICATION_TIMEOUT,
showNotification
} from '../notifications';
import {
DISABLE_MODERATION,
ENABLE_MODERATION,
LOCAL_PARTICIPANT_MODERATION_NOTIFICATION,
REQUEST_DISABLE_MODERATION,
REQUEST_ENABLE_MODERATION
} from './actionTypes';
import {
disableModeration,
dismissPendingParticipant,
dismissPendingAudioParticipant,
enableModeration,
localParticipantApproved,
participantApproved,
participantPendingAudio
} from './actions';
import {
isEnabledFromState,
isParticipantApproved,
isParticipantPending
} from './functions';
const VIDEO_MODERATION_NOTIFICATION_ID = 'video-moderation';
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;
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;
let uid;
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;
}
}
dispatch(showNotification({
customActionNameKey: 'notify.raiseHandAction',
customActionHandler: () => batch(() => {
dispatch(raiseHand(true));
dispatch(hideNotification(uid));
}),
descriptionKey,
sticky: true,
titleKey,
uid
}));
break;
}
case REQUEST_DISABLE_MODERATION: {
const { conference } = getConferenceState(getState());
conference.disableAVModeration(MEDIA_TYPE.AUDIO);
conference.disableAVModeration(MEDIA_TYPE.VIDEO);
break;
}
case REQUEST_ENABLE_MODERATION: {
const { conference } = getConferenceState(getState());
conference.enableAVModeration(MEDIA_TYPE.AUDIO);
conference.enableAVModeration(MEDIA_TYPE.VIDEO);
break;
}
case PARTICIPANT_UPDATED: {
const state = getState();
const audioModerationEnabled = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
// this is handled only by moderators
if (audioModerationEnabled && isLocalParticipantModerator(state)) {
const { participant: { id, raisedHand } } = action;
if (raisedHand) {
// if participant raises hand show notification
!isParticipantApproved(id, MEDIA_TYPE.AUDIO)(state) && dispatch(participantPendingAudio(id));
} else {
// if participant lowers hand hide notification
isParticipantPending(id, MEDIA_TYPE.AUDIO)(state) && dispatch(dismissPendingAudioParticipant(id));
}
}
break;
}
}
return next(action);
});
/**
* Registers a change handler for state['features/base/conference'].conference to
* set the event listeners needed for the A/V moderation feature to operate.
*/
StateListenerRegistry.register(
state => state['features/base/conference'].conference,
(conference, { dispatch }, previousConference) => {
if (conference && !previousConference) {
// local participant is allowed to unmute
conference.on(JitsiConferenceEvents.AV_MODERATION_APPROVED, ({ mediaType }) => {
dispatch(localParticipantApproved(mediaType));
// Audio & video moderation are both enabled at the same time.
// Avoid displaying 2 different notifications.
if (mediaType === MEDIA_TYPE.VIDEO) {
dispatch(showNotification({
titleKey: 'notify.unmute',
descriptionKey: 'notify.hostAskedUnmute',
sticky: true
}));
}
});
conference.on(JitsiConferenceEvents.AV_MODERATION_CHANGED, ({ enabled, mediaType, actor }) => {
enabled ? dispatch(enableModeration(mediaType, actor)) : dispatch(disableModeration(mediaType, actor));
});
// this is received by moderators
conference.on(
JitsiConferenceEvents.AV_MODERATION_PARTICIPANT_APPROVED,
({ participant, mediaType }) => {
const { _id: id } = participant;
batch(() => {
// store in the whitelist
dispatch(participantApproved(id, mediaType));
// remove from pending list
dispatch(dismissPendingParticipant(id, mediaType));
});
});
}
});

View File

@ -0,0 +1,134 @@
/* @flow */
import { MEDIA_TYPE } from '../base/media/constants';
import { ReducerRegistry } from '../base/redux';
import {
DISABLE_MODERATION,
DISMISS_PENDING_PARTICIPANT,
ENABLE_MODERATION,
LOCAL_PARTICIPANT_APPROVED,
PARTICIPANT_APPROVED,
PARTICIPANT_PENDING_AUDIO
} from './actionTypes';
const initialState = {
audioModerationEnabled: false,
videoModerationEnabled: false,
audioWhitelist: {},
videoWhitelist: {},
pendingAudio: [],
pendingVideo: []
};
ReducerRegistry.register('features/av-moderation', (state = initialState, action) => {
switch (action.type) {
case DISABLE_MODERATION: {
const newState = action.mediaType === MEDIA_TYPE.AUDIO
? {
audioModerationEnabled: false,
audioUnmuteApproved: undefined
} : {
videoModerationEnabled: false,
videoUnmuteApproved: undefined
};
return {
...state,
...newState,
audioWhitelist: {},
videoWhitelist: {},
pendingAudio: [],
pendingVideo: []
};
}
case ENABLE_MODERATION: {
const newState = action.mediaType === MEDIA_TYPE.AUDIO
? { audioModerationEnabled: true } : { videoModerationEnabled: true };
return {
...state,
...newState
};
}
case LOCAL_PARTICIPANT_APPROVED: {
const newState = action.mediaType === MEDIA_TYPE.AUDIO
? { audioUnmuteApproved: true } : { videoUnmuteApproved: true };
return {
...state,
...newState
};
}
case PARTICIPANT_PENDING_AUDIO: {
const { id } = action;
// Add participant to pendigAudio array only if it's not already added
if (!state.pendingAudio.find(pending => pending === id)) {
const updated = [ ...state.pendingAudio ];
updated.push(id);
return {
...state,
pendingAudio: updated
};
}
return state;
}
case DISMISS_PENDING_PARTICIPANT: {
const { id, mediaType } = action;
if (mediaType === MEDIA_TYPE.AUDIO) {
return {
...state,
pendingAudio: state.pendingAudio.filter(pending => pending !== id)
};
}
if (mediaType === MEDIA_TYPE.VIDEO) {
return {
...state,
pendingAudio: state.pendingVideo.filter(pending => pending !== id)
};
}
return state;
}
case PARTICIPANT_APPROVED: {
const { mediaType, id } = action;
if (mediaType === MEDIA_TYPE.AUDIO) {
return {
...state,
audioWhitelist: {
...state.audioWhitelist,
[id]: true
}
};
}
if (mediaType === MEDIA_TYPE.VIDEO) {
return {
...state,
videoWhitelist: {
...state.videoWhitelist,
[id]: true
}
};
}
return state;
}
}
return state;
});

View File

@ -2,6 +2,9 @@
import type { Dispatch } from 'redux'; import type { Dispatch } from 'redux';
import { showModeratedNotification } from '../../av-moderation/actions';
import { shouldShowModeratedNotification } from '../../av-moderation/functions';
import { import {
SET_AUDIO_MUTED, SET_AUDIO_MUTED,
SET_AUDIO_AVAILABLE, SET_AUDIO_AVAILABLE,
@ -12,8 +15,8 @@ import {
TOGGLE_CAMERA_FACING_MODE TOGGLE_CAMERA_FACING_MODE
} from './actionTypes'; } from './actionTypes';
import { import {
CAMERA_FACING_MODE,
MEDIA_TYPE, MEDIA_TYPE,
type MediaType,
VIDEO_MUTISM_AUTHORITY VIDEO_MUTISM_AUTHORITY
} from './constants'; } from './constants';
@ -64,7 +67,7 @@ export function setAudioMuted(muted: boolean, ensureTrack: boolean = false) {
* cameraFacingMode: CAMERA_FACING_MODE * cameraFacingMode: CAMERA_FACING_MODE
* }} * }}
*/ */
export function setCameraFacingMode(cameraFacingMode: CAMERA_FACING_MODE) { export function setCameraFacingMode(cameraFacingMode: string) {
return { return {
type: SET_CAMERA_FACING_MODE, type: SET_CAMERA_FACING_MODE,
cameraFacingMode cameraFacingMode
@ -102,11 +105,20 @@ export function setVideoAvailable(available: boolean) {
*/ */
export function setVideoMuted( export function setVideoMuted(
muted: boolean, muted: boolean,
mediaType: MEDIA_TYPE = MEDIA_TYPE.VIDEO, mediaType: MediaType = MEDIA_TYPE.VIDEO,
authority: number = VIDEO_MUTISM_AUTHORITY.USER, authority: number = VIDEO_MUTISM_AUTHORITY.USER,
ensureTrack: boolean = false) { ensureTrack: boolean = false) {
return (dispatch: Dispatch<any>, getState: Function) => { return (dispatch: Dispatch<any>, getState: Function) => {
const oldValue = getState()['features/base/media'].video.muted; const state = getState();
// check for A/V Moderation when trying to unmute
if (!muted && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, state)) {
ensureTrack && dispatch(showModeratedNotification(MEDIA_TYPE.VIDEO));
return;
}
const oldValue = state['features/base/media'].video.muted;
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
const newValue = muted ? oldValue | authority : oldValue & ~authority; const newValue = muted ? oldValue | authority : oldValue & ~authority;

View File

@ -1,3 +1,5 @@
// @flow
/** /**
* The set of facing modes for camera. * The set of facing modes for camera.
* *
@ -8,17 +10,20 @@ export const CAMERA_FACING_MODE = {
USER: 'user' USER: 'user'
}; };
export type MediaType = 'audio' | 'video' | 'presenter';
/** /**
* The set of media types. * The set of media types.
* *
* @enum {string} * @enum {string}
*/ */
export const MEDIA_TYPE = { export const MEDIA_TYPE: { AUDIO: MediaType, PRESENTER: MediaType, VIDEO: MediaType} = {
AUDIO: 'audio', AUDIO: 'audio',
PRESENTER: 'presenter', PRESENTER: 'presenter',
VIDEO: 'video' VIDEO: 'video'
}; };
/* eslint-disable no-bitwise */ /* eslint-disable no-bitwise */
/** /**

View File

@ -171,3 +171,10 @@ export const HIDDEN_PARTICIPANT_LEFT = 'HIDDEN_PARTICIPANT_LEFT';
*/ */
export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL'; export const SET_LOADABLE_AVATAR_URL = 'SET_LOADABLE_AVATAR_URL';
/**
* Raises hand for the local participant.
* {
* type: LOCAL_PARTICIPANT_RAISE_HAND
* }
*/
export const LOCAL_PARTICIPANT_RAISE_HAND = 'LOCAL_PARTICIPANT_RAISE_HAND';

View File

@ -7,6 +7,7 @@ import {
HIDDEN_PARTICIPANT_LEFT, HIDDEN_PARTICIPANT_LEFT,
GRANT_MODERATOR, GRANT_MODERATOR,
KICK_PARTICIPANT, KICK_PARTICIPANT,
LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT, MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_ID_CHANGED, PARTICIPANT_ID_CHANGED,
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
@ -555,3 +556,18 @@ export function setLoadableAvatarUrl(participantId, url) {
}; };
} }
/**
* Raise hand for the local participant.
*
* @param {boolean} enabled - Raise or lower hand.
* @returns {{
* type: LOCAL_PARTICIPANT_RAISE_HAND,
* enabled: boolean
* }}
*/
export function raiseHand(enabled) {
return {
type: LOCAL_PARTICIPANT_RAISE_HAND,
enabled
};
}

View File

@ -18,6 +18,7 @@ import {
DOMINANT_SPEAKER_CHANGED, DOMINANT_SPEAKER_CHANGED,
GRANT_MODERATOR, GRANT_MODERATOR,
KICK_PARTICIPANT, KICK_PARTICIPANT,
LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT, MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_DISPLAY_NAME_CHANGED, PARTICIPANT_DISPLAY_NAME_CHANGED,
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
@ -110,6 +111,29 @@ MiddlewareRegistry.register(store => next => action => {
break; break;
} }
case LOCAL_PARTICIPANT_RAISE_HAND: {
const { enabled } = action;
const localId = getLocalParticipant(store.getState())?.id;
store.dispatch(participantUpdated({
// XXX Only the local participant is allowed to update without
// stating the JitsiConference instance (i.e. participant property
// `conference` for a remote participant) because the local
// participant is uniquely identified by the very fact that there is
// only one local participant.
id: localId,
local: true,
raisedHand: enabled
}));
if (typeof APP !== 'undefined') {
APP.API.notifyRaiseHandUpdated(localId, enabled);
}
break;
}
case MUTE_REMOTE_PARTICIPANT: { case MUTE_REMOTE_PARTICIPANT: {
const { conference } = store.getState()['features/base/conference']; const { conference } = store.getState()['features/base/conference'];

View File

@ -1,6 +1,8 @@
// @flow // @flow
import UIEvents from '../../../../service/UI/UIEvents'; import UIEvents from '../../../../service/UI/UIEvents';
import { showModeratedNotification } from '../../av-moderation/actions';
import { shouldShowModeratedNotification } from '../../av-moderation/functions';
import { hideNotification } from '../../notifications'; import { hideNotification } from '../../notifications';
import { isPrejoinPageVisible } from '../../prejoin/functions'; import { isPrejoinPageVisible } from '../../prejoin/functions';
import { getAvailableDevices } from '../devices/actions'; import { getAvailableDevices } from '../devices/actions';
@ -135,6 +137,14 @@ MiddlewareRegistry.register(store => next => action => {
case TOGGLE_SCREENSHARING: case TOGGLE_SCREENSHARING:
if (typeof APP === 'object') { if (typeof APP === 'object') {
// check for A/V Moderation when trying to start screen sharing
if (action.enabled && shouldShowModeratedNotification(MEDIA_TYPE.VIDEO, store.getState())) {
store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER));
return;
}
APP.UI.emitEvent(UIEvents.TOGGLE_SCREENSHARING, action.audioOnly); APP.UI.emitEvent(UIEvents.TOGGLE_SCREENSHARING, action.audioOnly);
} }
break; break;

View File

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

View File

@ -145,6 +145,34 @@ export function admitMultiple(participants: Array<Object>) {
}; };
} }
/**
* Approves the request of a knocking participant to join the meeting.
*
* @param {string} id - The id of the knocking participant.
* @returns {Function}
*/
export function approveKnockingParticipant(id: string) {
return (dispatch: Dispatch<any>, getState: Function) => {
const conference = getCurrentConference(getState);
conference && conference.lobbyApproveAccess(id);
};
}
/**
* Denies the request of a knocking participant to join the meeting.
*
* @param {string} id - The id of the knocking participant.
* @returns {Function}
*/
export function rejectKnockingParticipant(id: string) {
return (dispatch: Dispatch<any>, getState: Function) => {
const conference = getCurrentConference(getState);
conference && conference.lobbyDenyAccess(id);
};
}
/** /**
* Action to set the knocking state of the participant. * Action to set the knocking state of the participant.
* *

View File

@ -2,11 +2,10 @@
import React from 'react'; import React from 'react';
import { Avatar } from '../../../base/avatar';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { isToolboxVisible } from '../../../toolbox/functions.web'; import NotificationWithParticipants from '../../../notifications/components/web/NotificationWithParticipants';
import { HIDDEN_EMAILS } from '../../constants'; import { approveKnockingParticipant, rejectKnockingParticipant } from '../../actions';
import AbstractKnockingParticipantList, { import AbstractKnockingParticipantList, {
mapStateToProps as abstractMapStateToProps, mapStateToProps as abstractMapStateToProps,
type Props as AbstractProps type Props as AbstractProps
@ -17,7 +16,7 @@ type Props = AbstractProps & {
/** /**
* True if the toolbox is visible, so we need to adjust the position. * True if the toolbox is visible, so we need to adjust the position.
*/ */
_toolboxVisible: boolean, _toolboxVisible: boolean
}; };
/** /**
@ -30,56 +29,24 @@ class KnockingParticipantList extends AbstractKnockingParticipantList<Props> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { _participants, _toolboxVisible, _visible, t } = this.props; const { _participants, _visible, t } = this.props;
if (!_visible) { if (!_visible) {
return null; return null;
} }
return ( return (
<div <div id = 'knocking-participant-list'>
className = { _toolboxVisible ? 'toolbox-visible' : '' } <div className = 'title'>
id = 'knocking-participant-list'>
<span className = 'title'>
{ t('lobby.knockingParticipantList') } { t('lobby.knockingParticipantList') }
</span> </div>
<ul className = 'knocking-participants-container'> <NotificationWithParticipants
{ _participants.map(p => ( approveButtonText = { t('lobby.allow') }
<li onApprove = { approveKnockingParticipant }
className = 'knocking-participant' onReject = { rejectKnockingParticipant }
key = { p.id }> participants = { _participants }
<Avatar rejectButtonText = { t('lobby.reject') }
displayName = { p.name } testIdPrefix = 'lobby' />
size = { 48 }
testId = 'knockingParticipant.avatar'
url = { p.loadableAvatarUrl } />
<div className = 'details'>
<span data-testid = 'knockingParticipant.name'>
{ p.name }
</span>
{ p.email && !HIDDEN_EMAILS.includes(p.email) && (
<span data-testid = 'knockingParticipant.email'>
{ p.email }
</span>
) }
</div>
<button
className = 'primary'
data-testid = 'lobby.allow'
onClick = { this._onRespondToParticipant(p.id, true) }
type = 'button'>
{ t('lobby.allow') }
</button>
<button
className = 'borderLess'
data-testid = 'lobby.reject'
onClick = { this._onRespondToParticipant(p.id, false) }
type = 'button'>
{ t('lobby.reject') }
</button>
</li>
)) }
</ul>
</div> </div>
); );
} }
@ -87,17 +54,4 @@ class KnockingParticipantList extends AbstractKnockingParticipantList<Props> {
_onRespondToParticipant: (string, boolean) => Function; _onRespondToParticipant: (string, boolean) => Function;
} }
/** export default translate(connect(abstractMapStateToProps)(KnockingParticipantList));
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {Props}
*/
function _mapStateToProps(state: Object): $Shape<Props> {
return {
...abstractMapStateToProps(state),
_toolboxVisible: isToolboxVisible(state)
};
}
export default translate(connect(_mapStateToProps)(KnockingParticipantList));

View File

@ -16,7 +16,7 @@ export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS';
* *
* { * {
* type: HIDE_NOTIFICATION, * type: HIDE_NOTIFICATION,
* uid: number * uid: string
* } * }
*/ */
export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION'; export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION';
@ -30,7 +30,7 @@ export const HIDE_NOTIFICATION = 'HIDE_NOTIFICATION';
* component: ReactComponent, * component: ReactComponent,
* props: Object, * props: Object,
* timeout: number, * timeout: number,
* uid: number * uid: string
* } * }
*/ */
export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION'; export const SHOW_NOTIFICATION = 'SHOW_NOTIFICATION';

View File

@ -33,10 +33,10 @@ export function clearNotifications() {
* removed. * removed.
* @returns {{ * @returns {{
* type: HIDE_NOTIFICATION, * type: HIDE_NOTIFICATION,
* uid: number * uid: string
* }} * }}
*/ */
export function hideNotification(uid: number) { export function hideNotification(uid: string) {
return { return {
type: HIDE_NOTIFICATION, type: HIDE_NOTIFICATION,
uid uid
@ -95,7 +95,7 @@ export function showNotification(props: Object = {}, timeout: ?number) {
type: SHOW_NOTIFICATION, type: SHOW_NOTIFICATION,
props, props,
timeout, timeout,
uid: window.Date.now() uid: props.uid || window.Date.now().toString()
}); });
} }
}; };

View File

@ -90,7 +90,7 @@ export type Props = {
/** /**
* The unique identifier for the notification. * The unique identifier for the notification.
*/ */
uid: number uid: string
}; };
/** /**

View File

@ -0,0 +1,52 @@
// @flow
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
type Props = {
/**
* Action to be dispatched on click.
*/
action: Function,
/**
* The text of the button.
*/
children: React$Node,
/**
* CSS class of the button.
*/
className: string,
/**
* The `data-testid` used for the button.
*/
testId: string,
/**
* The participant.
*/
participant: Object
}
/**
* Component used to display an approve/reject button.
*
* @returns {React$Element<'button'>}
*/
export default function({ action, children, className, testId, participant }: Props) {
const dispatch = useDispatch();
const onClick = useCallback(() => dispatch(action(participant.id)), [ dispatch, participant ]);
return (
<button
className = { className }
data-testid = { testId }
onClick = { onClick }
type = 'button'>
{ children }
</button>
);
}

View File

@ -0,0 +1,97 @@
// @flow
import React from 'react';
import { Avatar } from '../../../base/avatar';
import { HIDDEN_EMAILS } from '../../../lobby/constants';
import NotificationButton from './NotificationButton';
type Props = {
/**
* Text used for button which triggeres `onApprove` action.
*/
approveButtonText: string,
/**
* Callback used when clicking the ok/approve button.
*/
onApprove: Function,
/**
* Callback used when clicking the reject button.
*/
onReject: Function,
/**
* Array of participants to be displayed.
*/
participants: Array<Object>,
/**
* Text for button which triggeres the `reject` action.
*/
rejectButtonText: string,
/**
* String prefix used for button `test-id`.
*/
testIdPrefix: string
}
/**
* Component used to display a list of notifications based on a list of participants.
* This is visible only to moderators.
*
* @returns {React$Element<'div'> | null}
*/
export default function({
approveButtonText,
onApprove,
onReject,
participants,
testIdPrefix,
rejectButtonText
}: Props): React$Element<'ul'> {
return (
<ul className = 'knocking-participants-container'>
{ participants.map(p => (
<li
className = 'knocking-participant'
key = { p.id }>
<Avatar
displayName = { p.name }
size = { 48 }
testId = { `${testIdPrefix}.avatar` }
url = { p.loadableAvatarUrl } />
<div className = 'details'>
<span data-testid = { `${testIdPrefix}.name` }>
{ p.name }
</span>
{ p.email && !HIDDEN_EMAILS.includes(p.email) && (
<span data-testid = { `${testIdPrefix}.email` }>
{ p.email }
</span>
) }
</div>
{ <NotificationButton
action = { onApprove }
className = 'primary'
participant = { p }
testId = { `${testIdPrefix}.allow` }>
{ approveButtonText }
</NotificationButton> }
{ <NotificationButton
action = { onReject }
className = 'borderLess'
participant = { p }
testId = { `${testIdPrefix}.reject` }>
{ rejectButtonText }
</NotificationButton>}
</li>
)) }
</ul>);
}

View File

@ -0,0 +1,82 @@
// @flow
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar } from '../../../base/avatar';
import { HIDDEN_EMAILS } from '../../../lobby/constants';
import NotificationButton from './NotificationButton';
type Props = {
/**
* Callback used when clicking the ok/approve button.
*/
onApprove: Function,
/**
* Callback used when clicking the reject button.
*/
onReject: Function,
/**
* Array of participants to be displayed.
*/
participants: Array<Object>,
/**
* String prefix used for button `test-id`.
*/
testIdPrefix: string
}
/**
* Component used to display a list of notifications based on a list of participants.
* This is visible only to moderators.
*
* @returns {React$Element<'div'> | null}
*/
export default function({ onApprove, onReject, participants, testIdPrefix }: Props): React$Element<'ul'> {
const { t } = useTranslation();
return (
<ul className = 'knocking-participants-container'>
{ participants.map(p => (
<li
className = 'knocking-participant'
key = { p.id }>
<Avatar
displayName = { p.name }
size = { 48 }
testId = { `${testIdPrefix}.avatar` }
url = { p.loadableAvatarUrl } />
<div className = 'details'>
<span data-testid = { `${testIdPrefix}.name` }>
{ p.name }
</span>
{ p.email && !HIDDEN_EMAILS.includes(p.email) && (
<span data-testid = { `${testIdPrefix}.email` }>
{ p.email }
</span>
) }
</div>
<NotificationButton
action = { onApprove }
className = 'primary'
participant = { p }
testId = { `${testIdPrefix}.allow` }>
{ t('lobby.allow') }
</NotificationButton>
<NotificationButton
action = { onReject }
className = 'borderLess'
participant = { p }
testId = { `${testIdPrefix}.reject` }>
{ t('lobby.reject') }
</NotificationButton>
</li>
)) }
</ul>);
}

View File

@ -0,0 +1,43 @@
// @flow
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { approveParticipant } from '../../av-moderation/actions';
import { QuickActionButton } from './styled';
type Props = {
/**
* Participant id.
*/
id: string
}
/**
* Component used to display the `ask to unmute` button.
*
* @param {Object} participant - Participant reference.
* @returns {React$Element<'button'>}
*/
export default function({ id }: Props) {
const dispatch = useDispatch();
const { t } = useTranslation();
const askToUnmute = useCallback(() => {
dispatch(approveParticipant(id));
}, [ dispatch, id ]);
return (
<QuickActionButton
onClick = { askToUnmute }
primary = { true }
theme = {{
panePadding: 16
}}>
{t('participantsPane.actions.askUnmute')}
</QuickActionButton>
);
}

View File

@ -0,0 +1,101 @@
// @flow
import { makeStyles } from '@material-ui/core/styles';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { requestDisableModeration, requestEnableModeration } from '../../av-moderation/actions';
import { isEnabled as isAvModerationEnabled } from '../../av-moderation/functions';
import { openDialog } from '../../base/dialog';
import { Icon, IconCheck, IconVideoOff } from '../../base/icons';
import { MEDIA_TYPE } from '../../base/media';
import { getLocalParticipant } from '../../base/participants';
import { MuteEveryonesVideoDialog } from '../../video-menu/components';
import {
ContextMenu,
ContextMenuItem
} from './styled';
const useStyles = makeStyles(() => {
return {
contextMenu: {
bottom: 'auto',
margin: '0',
padding: '8px 0',
right: 0,
top: '-8px',
transform: 'translateY(-100%)',
width: '238px'
},
text: {
marginLeft: '52px',
lineHeight: '40px'
},
paddedAction: {
marginLeft: '36px;'
}
};
});
type Props = {
/**
* Callback for the mouse leaving this item
*/
onMouseLeave: Function
};
export const FooterContextMenu = ({ onMouseLeave }: Props) => {
const dispatch = useDispatch();
const isModerationEnabled = useSelector(isAvModerationEnabled(MEDIA_TYPE.AUDIO));
const { id } = useSelector(getLocalParticipant);
const { t } = useTranslation();
const disable = useCallback(() => dispatch(requestDisableModeration()), [ dispatch ]);
const enable = useCallback(() => dispatch(requestEnableModeration()), [ dispatch ]);
const classes = useStyles();
const muteAllVideo = useCallback(
() => dispatch(openDialog(MuteEveryonesVideoDialog, { exclude: [ id ] })), [ dispatch ]);
return (
<ContextMenu
className = { classes.contextMenu }
onMouseLeave = { onMouseLeave }>
<ContextMenuItem
id = 'participants-pane-context-menu-stop-video'
onClick = { muteAllVideo }>
<Icon
size = { 20 }
src = { IconVideoOff } />
<span>{ t('participantsPane.actions.stopEveryonesVideo') }</span>
</ContextMenuItem>
<div className = { classes.text }>
{t('participantsPane.actions.allow')}
</div>
{ isModerationEnabled ? (
<ContextMenuItem
id = 'participants-pane-context-menu-start-moderation'
onClick = { disable }>
<span className = { classes.paddedAction }>
{ t('participantsPane.actions.startModeration') }
</span>
</ContextMenuItem>
) : (
<ContextMenuItem
id = 'participants-pane-context-menu-stop-moderation'
onClick = { enable }>
<Icon
size = { 20 }
src = { IconCheck } />
<span>{ t('participantsPane.actions.startModeration') }</span>
</ContextMenuItem>
)}
</ContextMenu>
);
};

View File

@ -4,8 +4,8 @@ import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { setKnockingParticipantApproval } from '../../lobby/actions'; import { approveKnockingParticipant, rejectKnockingParticipant } from '../../lobby/actions';
import { ActionTrigger, MediaState } from '../constants'; import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
import { ParticipantItem } from './ParticipantItem'; import { ParticipantItem } from './ParticipantItem';
import { ParticipantActionButton } from './styled'; import { ParticipantActionButton } from './styled';
@ -20,17 +20,17 @@ type Props = {
export const LobbyParticipantItem = ({ participant: p }: Props) => { export const LobbyParticipantItem = ({ participant: p }: Props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true), [ dispatch ])); const admit = useCallback(() => dispatch(approveKnockingParticipant(p.id), [ dispatch ]));
const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false), [ dispatch ])); const reject = useCallback(() => dispatch(rejectKnockingParticipant(p.id), [ dispatch ]));
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<ParticipantItem <ParticipantItem
actionsTrigger = { ActionTrigger.Permanent } actionsTrigger = { ACTION_TRIGGER.PERMANENT }
audioMuteState = { MediaState.None } audioMediaState = { MEDIA_STATE.NONE }
name = { p.name } name = { p.name }
participant = { p } participant = { p }
videoMuteState = { MediaState.None }> videoMuteState = { MEDIA_STATE.NONE }>
<ParticipantActionButton <ParticipantActionButton
onClick = { reject }> onClick = { reject }>
{t('lobby.reject')} {t('lobby.reject')}

View File

@ -10,11 +10,12 @@ import {
IconCloseCircle, IconCloseCircle,
IconCrown, IconCrown,
IconMessage, IconMessage,
IconMicDisabled,
IconMuteEveryoneElse, IconMuteEveryoneElse,
IconVideoOff IconVideoOff
} from '../../base/icons'; } from '../../base/icons';
import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants'; import { isLocalParticipantModerator, isParticipantModerator } from '../../base/participants';
import { getIsParticipantVideoMuted } from '../../base/tracks'; import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
import { openChat } from '../../chat/actions'; import { openChat } from '../../chat/actions';
import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu'; import { GrantModeratorDialog, KickRemoteParticipantDialog, MuteEveryoneDialog } from '../../video-menu';
import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog'; import MuteRemoteParticipantsVideoDialog from '../../video-menu/components/web/MuteRemoteParticipantsVideoDialog';
@ -30,6 +31,11 @@ import {
type Props = { type Props = {
/**
* Callback used to open a confirmation dialog for audio muting.
*/
muteAudio: Function,
/** /**
* Target elements against which positioning calculations are made * Target elements against which positioning calculations are made
*/ */
@ -61,6 +67,7 @@ export const MeetingParticipantContextMenu = ({
onEnter, onEnter,
onLeave, onLeave,
onSelect, onSelect,
muteAudio,
participant participant
}: Props) => { }: Props) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -68,6 +75,7 @@ export const MeetingParticipantContextMenu = ({
const isLocalModerator = useSelector(isLocalParticipantModerator); const isLocalModerator = useSelector(isLocalParticipantModerator);
const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat')); const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat'));
const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant)); const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
const isParticipantAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
const [ isHidden, setIsHidden ] = useState(true); const [ isHidden, setIsHidden ] = useState(true);
const { t } = useTranslation(); const { t } = useTranslation();
@ -133,11 +141,20 @@ export const MeetingParticipantContextMenu = ({
onMouseLeave = { onLeave }> onMouseLeave = { onLeave }>
<ContextMenuItemGroup> <ContextMenuItemGroup>
{isLocalModerator && ( {isLocalModerator && (
<ContextMenuItem onClick = { muteEveryoneElse }> <>
<ContextMenuIcon src = { IconMuteEveryoneElse } /> {!isParticipantAudioMuted
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span> && <ContextMenuItem onClick = { muteAudio(participant) }>
</ContextMenuItem> <ContextMenuIcon src = { IconMicDisabled } />
<span>{t('dialog.muteParticipantButton')}</span>
</ContextMenuItem>}
<ContextMenuItem onClick = { muteEveryoneElse }>
<ContextMenuIcon src = { IconMuteEveryoneElse } />
<span>{t('toolbar.accessibilityLabel.muteEveryoneElse')}</span>
</ContextMenuItem>
</>
)} )}
{isLocalModerator && (isParticipantVideoMuted || ( {isLocalModerator && (isParticipantVideoMuted || (
<ContextMenuItem onClick = { muteVideo }> <ContextMenuItem onClick = { muteVideo }>
<ContextMenuIcon src = { IconVideoOff } /> <ContextMenuIcon src = { IconVideoOff } />
@ -145,18 +162,20 @@ export const MeetingParticipantContextMenu = ({
</ContextMenuItem> </ContextMenuItem>
))} ))}
</ContextMenuItemGroup> </ContextMenuItemGroup>
<ContextMenuItemGroup> <ContextMenuItemGroup>
{isLocalModerator && !isParticipantModerator(participant) && (
<ContextMenuItem onClick = { grantModerator }>
<ContextMenuIcon src = { IconCrown } />
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
</ContextMenuItem>
)}
{isLocalModerator && ( {isLocalModerator && (
<ContextMenuItem onClick = { kick }> <>
<ContextMenuIcon src = { IconCloseCircle } /> {!isParticipantModerator(participant)
<span>{t('videothumbnail.kick')}</span> && <ContextMenuItem onClick = { grantModerator }>
</ContextMenuItem> <ContextMenuIcon src = { IconCrown } />
<span>{t('toolbar.accessibilityLabel.grantModerator')}</span>
</ContextMenuItem>}
<ContextMenuItem onClick = { kick }>
<ContextMenuIcon src = { IconCloseCircle } />
<span>{t('videothumbnail.kick')}</span>
</ContextMenuItem>
</>
)} )}
{isChatButtonEnabled && ( {isChatButtonEnabled && (
<ContextMenuItem onClick = { sendPrivateMessage }> <ContextMenuItem onClick = { sendPrivateMessage }>

View File

@ -5,9 +5,11 @@ import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks'; import { getIsParticipantAudioMuted, getIsParticipantVideoMuted } from '../../base/tracks';
import { ActionTrigger, MediaState } from '../constants'; import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
import { getParticipantAudioMediaState } from '../functions';
import { ParticipantItem } from './ParticipantItem'; import { ParticipantItem } from './ParticipantItem';
import ParticipantQuickAction from './ParticipantQuickAction';
import { ParticipantActionEllipsis } from './styled'; import { ParticipantActionEllipsis } from './styled';
type Props = { type Props = {
@ -17,6 +19,11 @@ type Props = {
*/ */
isHighlighted: boolean, isHighlighted: boolean,
/**
* Callback used to open a confirmation dialog for audio muting.
*/
muteAudio: Function,
/** /**
* Callback for the activation of this item's context menu * Callback for the activation of this item's context menu
*/ */
@ -37,20 +44,26 @@ export const MeetingParticipantItem = ({
isHighlighted, isHighlighted,
onContextMenu, onContextMenu,
onLeave, onLeave,
muteAudio,
participant participant
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant)); const isAudioMuted = useSelector(getIsParticipantAudioMuted(participant));
const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant)); const isVideoMuted = useSelector(getIsParticipantVideoMuted(participant));
const audioMediaState = useSelector(getParticipantAudioMediaState(participant, isAudioMuted));
return ( return (
<ParticipantItem <ParticipantItem
actionsTrigger = { ActionTrigger.Hover } actionsTrigger = { ACTION_TRIGGER.HOVER }
audioMuteState = { isAudioMuted ? MediaState.Muted : MediaState.Unmuted } audioMediaState = { audioMediaState }
isHighlighted = { isHighlighted } isHighlighted = { isHighlighted }
onLeave = { onLeave } onLeave = { onLeave }
participant = { participant } participant = { participant }
videoMuteState = { isVideoMuted ? MediaState.Muted : MediaState.Unmuted }> videoMuteState = { isVideoMuted ? MEDIA_STATE.MUTED : MEDIA_STATE.UNMUTED }>
<ParticipantQuickAction
isAudioMuted = { isAudioMuted }
muteAudio = { muteAudio }
participant = { participant } />
<ParticipantActionEllipsis <ParticipantActionEllipsis
aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') } aria-label = { t('MeetingParticipantItem.ParticipantActionEllipsis.options') }
onClick = { onContextMenu } /> onClick = { onContextMenu } />

View File

@ -3,9 +3,11 @@
import _ from 'lodash'; import _ from 'lodash';
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { openDialog } from '../../base/dialog';
import { getParticipants } from '../../base/participants'; import { getParticipants } from '../../base/participants';
import MuteRemoteParticipantDialog from '../../video-menu/components/web/MuteRemoteParticipantDialog';
import { findStyledAncestor, shouldRenderInviteButton } from '../functions'; import { findStyledAncestor, shouldRenderInviteButton } from '../functions';
import { InviteButton } from './InviteButton'; import { InviteButton } from './InviteButton';
@ -34,6 +36,7 @@ type RaiseContext = NullProto | {
const initialState = Object.freeze(Object.create(null)); const initialState = Object.freeze(Object.create(null));
export const MeetingParticipantList = () => { export const MeetingParticipantList = () => {
const dispatch = useDispatch();
const isMouseOverMenu = useRef(false); const isMouseOverMenu = useRef(false);
const participants = useSelector(getParticipants, _.isEqual); const participants = useSelector(getParticipants, _.isEqual);
const showInviteButton = useSelector(shouldRenderInviteButton); const showInviteButton = useSelector(shouldRenderInviteButton);
@ -84,6 +87,10 @@ export const MeetingParticipantList = () => {
lowerMenu(); lowerMenu();
}, [ lowerMenu ]); }, [ lowerMenu ]);
const muteAudio = useCallback(id => () => {
dispatch(openDialog(MuteRemoteParticipantDialog, { participantID: id }));
});
return ( return (
<> <>
<Heading>{t('participantsPane.headings.participantsList', { count: participants.length })}</Heading> <Heading>{t('participantsPane.headings.participantsList', { count: participants.length })}</Heading>
@ -93,12 +100,14 @@ export const MeetingParticipantList = () => {
<MeetingParticipantItem <MeetingParticipantItem
isHighlighted = { raiseContext.participant === p } isHighlighted = { raiseContext.participant === p }
key = { p.id } key = { p.id }
muteAudio = { muteAudio }
onContextMenu = { toggleMenu(p) } onContextMenu = { toggleMenu(p) }
onLeave = { lowerMenu } onLeave = { lowerMenu }
participant = { p } /> participant = { p } />
))} ))}
</div> </div>
<MeetingParticipantContextMenu <MeetingParticipantContextMenu
muteAudio = { muteAudio }
onEnter = { menuEnter } onEnter = { menuEnter }
onLeave = { menuLeave } onLeave = { menuLeave }
onSelect = { lowerMenu } onSelect = { lowerMenu }
@ -106,4 +115,3 @@ export const MeetingParticipantList = () => {
</> </>
); );
}; };

View File

@ -13,10 +13,11 @@ import {
IconMicrophoneEmptySlash IconMicrophoneEmptySlash
} from '../../base/icons'; } from '../../base/icons';
import { getParticipantDisplayNameWithId } from '../../base/participants'; import { getParticipantDisplayNameWithId } from '../../base/participants';
import { ActionTrigger, MediaState } from '../constants'; import { ACTION_TRIGGER, MEDIA_STATE, type ActionTrigger, type MediaState } from '../constants';
import { RaisedHandIndicator } from './RaisedHandIndicator'; import { RaisedHandIndicator } from './RaisedHandIndicator';
import { import {
ColoredIcon,
ParticipantActionsHover, ParticipantActionsHover,
ParticipantActionsPermanent, ParticipantActionsPermanent,
ParticipantContainer, ParticipantContainer,
@ -30,52 +31,56 @@ import {
* Participant actions component mapping depending on trigger type. * Participant actions component mapping depending on trigger type.
*/ */
const Actions = { const Actions = {
[ActionTrigger.Hover]: ParticipantActionsHover, [ACTION_TRIGGER.HOVER]: ParticipantActionsHover,
[ActionTrigger.Permanent]: ParticipantActionsPermanent [ACTION_TRIGGER.PERMANENT]: ParticipantActionsPermanent
}; };
/** /**
* Icon mapping for possible participant audio states. * Icon mapping for possible participant audio states.
*/ */
const AudioStateIcons = { const AudioStateIcons: {[MediaState]: React$Element<any> | null} = {
[MediaState.ForceMuted]: ( [MEDIA_STATE.FORCE_MUTED]: (
<ColoredIcon color = '#E04757'>
<Icon
size = { 16 }
src = { IconMicrophoneEmptySlash } />
</ColoredIcon>
),
[MEDIA_STATE.MUTED]: (
<Icon <Icon
size = { 16 } size = { 16 }
src = { IconMicrophoneEmptySlash } /> src = { IconMicrophoneEmptySlash } />
), ),
[MediaState.Muted]: ( [MEDIA_STATE.UNMUTED]: (
<Icon <ColoredIcon color = '#1EC26A'>
size = { 16 } <Icon
src = { IconMicrophoneEmptySlash } /> size = { 16 }
src = { IconMicrophoneEmpty } />
</ColoredIcon>
), ),
[MediaState.Unmuted]: ( [MEDIA_STATE.NONE]: null
<Icon
size = { 16 }
src = { IconMicrophoneEmpty } />
),
[MediaState.None]: null
}; };
/** /**
* Icon mapping for possible participant video states. * Icon mapping for possible participant video states.
*/ */
const VideoStateIcons = { const VideoStateIcons = {
[MediaState.ForceMuted]: ( [MEDIA_STATE.FORCE_MUTED]: (
<Icon <Icon
size = { 16 } size = { 16 }
src = { IconCameraEmptyDisabled } /> src = { IconCameraEmptyDisabled } />
), ),
[MediaState.Muted]: ( [MEDIA_STATE.MUTED]: (
<Icon <Icon
size = { 16 } size = { 16 }
src = { IconCameraEmptyDisabled } /> src = { IconCameraEmptyDisabled } />
), ),
[MediaState.Unmuted]: ( [MEDIA_STATE.UNMUTED]: (
<Icon <Icon
size = { 16 } size = { 16 }
src = { IconCameraEmpty } /> src = { IconCameraEmpty } />
), ),
[MediaState.None]: null [MEDIA_STATE.NONE]: null
}; };
type Props = { type Props = {
@ -88,7 +93,7 @@ type Props = {
/** /**
* Media state for audio * Media state for audio
*/ */
audioMuteState: MediaState, audioMediaState: MediaState,
/** /**
* React children * React children
@ -125,9 +130,9 @@ export const ParticipantItem = ({
children, children,
isHighlighted, isHighlighted,
onLeave, onLeave,
actionsTrigger = ActionTrigger.Hover, actionsTrigger = ACTION_TRIGGER.HOVER,
audioMuteState = MediaState.None, audioMediaState = MEDIA_STATE.NONE,
videoMuteState = MediaState.None, videoMuteState = MEDIA_STATE.NONE,
name, name,
participant: p participant: p
}: Props) => { }: Props) => {
@ -155,7 +160,7 @@ export const ParticipantItem = ({
<ParticipantStates> <ParticipantStates>
{p.raisedHand && <RaisedHandIndicator />} {p.raisedHand && <RaisedHandIndicator />}
{VideoStateIcons[videoMuteState]} {VideoStateIcons[videoMuteState]}
{AudioStateIcons[audioMuteState]} {AudioStateIcons[audioMediaState]}
</ParticipantStates> </ParticipantStates>
</ParticipantContent> </ParticipantContent>
</ParticipantContainer> </ParticipantContainer>

View File

@ -0,0 +1,59 @@
// @flow
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { QUICK_ACTION_BUTTON } from '../constants';
import { getQuickActionButtonType } from '../functions';
import AskToUnmuteButton from './AskToUnmuteButton';
import { QuickActionButton } from './styled';
type Props = {
/**
* If audio is muted for the current participant.
*/
isAudioMuted: Boolean,
/**
* Callback used to open a confirmation dialog for audio muting.
*/
muteAudio: Function,
/**
* Participant.
*/
participant: Object,
}
/**
* Component used to display mute/ask to unmute button.
*
* @param {Props} props - The props of the component.
* @returns {React$Element<'button'>}
*/
export default function({ isAudioMuted, muteAudio, participant }: Props) {
const buttonType = useSelector(getQuickActionButtonType(participant, isAudioMuted));
const { id } = participant;
const { t } = useTranslation();
switch (buttonType) {
case QUICK_ACTION_BUTTON.MUTE: {
return (
<QuickActionButton
onClick = { muteAudio(id) }
primary = { true }>
{t('dialog.muteParticipantButton')}
</QuickActionButton>
);
}
case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
return <AskToUnmuteButton id = { id } />;
}
default: {
return null;
}
}
}

View File

@ -1,17 +1,22 @@
// @flow // @flow
import React, { useCallback } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { ThemeProvider } from 'styled-components'; import { ThemeProvider } from 'styled-components';
import { openDialog } from '../../base/dialog'; import { openDialog } from '../../base/dialog';
import { isLocalParticipantModerator } from '../../base/participants'; import {
getParticipantCount,
isEveryoneModerator,
isLocalParticipantModerator
} from '../../base/participants';
import { MuteEveryoneDialog } from '../../video-menu/components/'; import { MuteEveryoneDialog } from '../../video-menu/components/';
import { close } from '../actions'; import { close } from '../actions';
import { classList, getParticipantsPaneOpen } from '../functions'; import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions';
import theme from '../theme.json'; import theme from '../theme.json';
import { FooterContextMenu } from './FooterContextMenu';
import { LobbyParticipantList } from './LobbyParticipantList'; import { LobbyParticipantList } from './LobbyParticipantList';
import { MeetingParticipantList } from './MeetingParticipantList'; import { MeetingParticipantList } from './MeetingParticipantList';
import { import {
@ -20,6 +25,8 @@ import {
Container, Container,
Footer, Footer,
FooterButton, FooterButton,
FooterEllipsisButton,
FooterEllipsisContainer,
Header Header
} from './styled'; } from './styled';
@ -27,6 +34,11 @@ export const ParticipantsPane = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const paneOpen = useSelector(getParticipantsPaneOpen); const paneOpen = useSelector(getParticipantsPaneOpen);
const isLocalModerator = useSelector(isLocalParticipantModerator); const isLocalModerator = useSelector(isLocalParticipantModerator);
const participantsCount = useSelector(getParticipantCount);
const everyoneModerator = useSelector(isEveryoneModerator);
const showContextMenu = !everyoneModerator && participantsCount > 2;
const [ contextOpen, setContextOpen ] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const closePane = useCallback(() => dispatch(close(), [ dispatch ])); const closePane = useCallback(() => dispatch(close(), [ dispatch ]));
@ -38,13 +50,23 @@ export const ParticipantsPane = () => {
}, [ closePane ]); }, [ closePane ]);
const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]); const muteAll = useCallback(() => dispatch(openDialog(MuteEveryoneDialog)), [ dispatch ]);
useEffect(() => {
const handler = [ 'click', e => {
if (!findStyledAncestor(e.target, FooterEllipsisContainer)) {
setContextOpen(false);
}
} ];
window.addEventListener(...handler);
return () => window.removeEventListener(...handler);
}, [ contextOpen ]);
const toggleContext = useCallback(() => setContextOpen(!contextOpen), [ contextOpen, setContextOpen ]);
return ( return (
<ThemeProvider theme = { theme }> <ThemeProvider theme = { theme }>
<div <div className = { classList('participants_pane', !paneOpen && 'participants_pane--closed') }>
className = { classList(
'participants_pane',
!paneOpen && 'participants_pane--closed'
) }>
<div className = 'participants_pane-content'> <div className = 'participants_pane-content'>
<Header> <Header>
<Close <Close
@ -64,6 +86,14 @@ export const ParticipantsPane = () => {
<FooterButton onClick = { muteAll }> <FooterButton onClick = { muteAll }>
{t('participantsPane.actions.muteAll')} {t('participantsPane.actions.muteAll')}
</FooterButton> </FooterButton>
{showContextMenu && (
<FooterEllipsisContainer>
<FooterEllipsisButton
id = 'participants-pane-context-menu'
onClick = { toggleContext } />
{contextOpen && <FooterContextMenu onMouseLeave = { toggleContext } />}
</FooterEllipsisContainer>
)}
</Footer> </Footer>
)} )}
</div> </div>

View File

@ -2,7 +2,7 @@ import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { Icon, IconHorizontalPoints } from '../../base/icons'; import { Icon, IconHorizontalPoints } from '../../base/icons';
import { ActionTrigger } from '../constants'; import { ACTION_TRIGGER } from '../constants';
export const ignoredChildClassName = 'ignore-child'; export const ignoredChildClassName = 'ignore-child';
@ -21,6 +21,7 @@ export const Button = styled.button`
display: flex; display: flex;
font-weight: unset; font-weight: unset;
justify-content: center; justify-content: center;
min-height: 32px;
&:hover { &:hover {
background-color: ${ background-color: ${
@ -30,6 +31,10 @@ export const Button = styled.button`
} }
`; `;
export const QuickActionButton = styled(Button)`
padding: 0 12px;
`;
export const Container = styled.div` export const Container = styled.div`
box-sizing: border-box; box-sizing: border-box;
flex: 1; flex: 1;
@ -93,7 +98,7 @@ export const ContextMenuItem = styled.div`
box-sizing: border-box; box-sizing: border-box;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
height: 40px; min-height: 40px;
padding: 8px 16px; padding: 8px 16px;
& > *:not(:last-child) { & > *:not(:last-child) {
@ -185,6 +190,12 @@ export const Heading = styled.div`
margin: 8px 0 ${props => props.theme.panePadding}px; margin: 8px 0 ${props => props.theme.panePadding}px;
`; `;
export const ColoredIcon = styled.div`
& > div > svg {
fill: ${props => props.color || '#fff'};
}
`;
export const ParticipantActionButton = styled(Button)` export const ParticipantActionButton = styled(Button)`
height: ${props => props.theme.participantActionButtonHeight}px; height: ${props => props.theme.participantActionButtonHeight}px;
padding: 6px 10px; padding: 6px 10px;
@ -256,7 +267,7 @@ export const ParticipantContainer = styled.div`
background-color: #292929; background-color: #292929;
& ${ParticipantActions} { & ${ParticipantActions} {
${props => props.trigger === ActionTrigger.Hover && ` ${props => props.trigger === ACTION_TRIGGER.HOVER && `
display: flex; display: flex;
`} `}
} }

View File

@ -1,22 +1,48 @@
// @flow
/** /**
* Reducer key for the feature. * Reducer key for the feature.
*/ */
export const REDUCER_KEY = 'features/participants-pane'; export const REDUCER_KEY = 'features/participants-pane';
export type ActionTrigger = 'Hover' | 'Permanent'
/** /**
* Enum of possible participant action triggers. * Enum of possible participant action triggers.
*/ */
export const ActionTrigger = { export const ACTION_TRIGGER: {HOVER: ActionTrigger, PERMANENT: ActionTrigger} = {
Hover: 'ActionTrigger.Hover', HOVER: 'Hover',
Permanent: 'ActionTrigger.Permanent' PERMANENT: 'Permanent'
}; };
export type MediaState = 'Muted' | 'ForceMuted' | 'Unmuted' | 'None';
/** /**
* Enum of possible participant media states. * Enum of possible participant media states.
*/ */
export const MediaState = { export const MEDIA_STATE: {
Muted: 'MediaState.Muted', MUTED: MediaState,
ForceMuted: 'MediaState.ForceMuted', FORCE_MUTED: MediaState,
Unmuted: 'MediaState.Unmuted', UNMUTED: MediaState,
None: 'MediaState.None' NONE: MediaState,
} = {
MUTED: 'Muted',
FORCE_MUTED: 'ForceMuted',
UNMUTED: 'Unmuted',
NONE: 'None'
};
export type QuickActionButtonType = 'Mute' | 'AskToUnmute' | 'None';
/**
* Enum of possible participant mute button states.
*/
export const QUICK_ACTION_BUTTON: {
MUTE: QuickActionButtonType,
ASK_TO_UNMUTE: QuickActionButtonType,
NONE: QuickActionButtonType
} = {
MUTE: 'Mute',
ASK_TO_UNMUTE: 'AskToUnmute',
NONE: 'None'
}; };

View File

@ -1,8 +1,20 @@
// @flow
import {
isParticipantApproved,
isEnabledFromState,
isLocalParticipantApprovedFromState
} from '../av-moderation/functions';
import { getFeatureFlag, INVITE_ENABLED } from '../base/flags'; import { getFeatureFlag, INVITE_ENABLED } from '../base/flags';
import { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import {
getParticipantCount,
isLocalParticipantModerator,
isParticipantModerator
} from '../base/participants/functions';
import { toState } from '../base/redux'; import { toState } from '../base/redux';
import { REDUCER_KEY } from './constants'; import { QUICK_ACTION_BUTTON, REDUCER_KEY, MEDIA_STATE } from './constants';
/** /**
* Generates a class attribute value. * Generates a class attribute value.
@ -10,7 +22,7 @@ import { REDUCER_KEY } from './constants';
* @param {Iterable<string>} args - String iterable. * @param {Iterable<string>} args - String iterable.
* @returns {string} Class attribute value. * @returns {string} Class attribute value.
*/ */
export const classList = (...args) => args.filter(Boolean).join(' '); export const classList = (...args: Array<string | boolean>) => args.filter(Boolean).join(' ');
/** /**
@ -20,7 +32,7 @@ export const classList = (...args) => args.filter(Boolean).join(' ');
* @param {StyledComponentClass} component - Styled component reference. * @param {StyledComponentClass} component - Styled component reference.
* @returns {Element|null} Ancestor. * @returns {Element|null} Ancestor.
*/ */
export const findStyledAncestor = (target, component) => { export const findStyledAncestor = (target: Object, component: any) => {
if (!target || target.matches(`.${component.styledComponentId}`)) { if (!target || target.matches(`.${component.styledComponentId}`)) {
return target; return target;
} }
@ -28,6 +40,50 @@ export const findStyledAncestor = (target, component) => {
return findStyledAncestor(target.parentElement, component); return findStyledAncestor(target.parentElement, component);
}; };
/**
* Returns a selector used to determine if a participant is force muted.
*
* @param {Object} participant - The participant id.
* @param {MediaType} mediaType - The media type.
* @returns {MediaState}.
*/
export const isForceMuted = (participant: Object, mediaType: MediaType) => (state: Object) => {
if (getParticipantCount(state) > 2 && isEnabledFromState(mediaType, state)) {
if (participant.local) {
return !isLocalParticipantApprovedFromState(mediaType, state);
}
// moderators cannot be force muted
if (isParticipantModerator(participant)) {
return false;
}
return !isParticipantApproved(participant.id, mediaType)(state);
}
return false;
};
/**
* Returns a selector used to determine the audio media state (the mic icon) for a participant.
*
* @param {Object} participant - The participant.
* @param {boolean} muted - The mute state of the participant.
* @returns {MediaState}.
*/
export const getParticipantAudioMediaState = (participant: Object, muted: Boolean) => (state: Object) => {
if (muted) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) {
return MEDIA_STATE.FORCE_MUTED;
}
return MEDIA_STATE.MUTED;
}
return MEDIA_STATE.UNMUTED;
};
/** /**
* Get a style property from a style declaration as a float. * Get a style property from a style declaration as a float.
* *
@ -35,7 +91,7 @@ export const findStyledAncestor = (target, component) => {
* @param {string} name - Property name. * @param {string} name - Property name.
* @returns {number} Float value. * @returns {number} Float value.
*/ */
export const getFloatStyleProperty = (styles, name) => export const getFloatStyleProperty = (styles: Object, name: string) =>
parseFloat(styles.getPropertyValue(name)); parseFloat(styles.getPropertyValue(name));
/** /**
@ -44,7 +100,7 @@ export const getFloatStyleProperty = (styles, name) =>
* @param {Element} element - Target element. * @param {Element} element - Target element.
* @returns {number} Computed height. * @returns {number} Computed height.
*/ */
export const getComputedOuterHeight = element => { export const getComputedOuterHeight = (element: HTMLElement) => {
const computedStyle = getComputedStyle(element); const computedStyle = getComputedStyle(element);
return element.offsetHeight return element.offsetHeight
@ -58,7 +114,7 @@ export const getComputedOuterHeight = element => {
* @param {Object} state - Global state. * @param {Object} state - Global state.
* @returns {Object} Feature state. * @returns {Object} Feature state.
*/ */
const getState = state => state[REDUCER_KEY]; const getState = (state: Object) => state[REDUCER_KEY];
/** /**
* Is the participants pane open. * Is the participants pane open.
@ -66,7 +122,29 @@ const getState = state => state[REDUCER_KEY];
* @param {Object} state - Global state. * @param {Object} state - Global state.
* @returns {boolean} Is the participants pane open. * @returns {boolean} Is the participants pane open.
*/ */
export const getParticipantsPaneOpen = state => Boolean(getState(state)?.isOpen); export const getParticipantsPaneOpen = (state: Object) => Boolean(getState(state)?.isOpen);
/**
* Returns a selector used to determine the type of quick action button to be displayed for a participant.
* The button is displayed when hovering a participant from the participant list.
*
* @param {Object} participant - The participant.
* @param {boolean} isAudioMuted - If audio is muted for the participant.
* @returns {Function}
*/
export const getQuickActionButtonType = (participant: Object, isAudioMuted: Boolean) => (state: Object) => {
// handled only by moderators
if (isLocalParticipantModerator(state)) {
if (isForceMuted(participant, MEDIA_TYPE.AUDIO)(state)) {
return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
}
if (!isAudioMuted) {
return QUICK_ACTION_BUTTON.MUTE;
}
}
return QUICK_ACTION_BUTTON.NONE;
};
/** /**
* Returns true if the invite button should be rendered. * Returns true if the invite button should be rendered.
@ -74,7 +152,7 @@ export const getParticipantsPaneOpen = state => Boolean(getState(state)?.isOpen)
* @param {Object} state - Global state. * @param {Object} state - Global state.
* @returns {boolean} * @returns {boolean}
*/ */
export const shouldRenderInviteButton = state => { export const shouldRenderInviteButton = (state: Object) => {
const { disableInviteFunctions } = toState(state)['features/base/config']; const { disableInviteFunctions } = toState(state)['features/base/config'];
const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true); const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);

View File

@ -1,4 +1,7 @@
{ {
"colors": {
"moderationDisabled": "#E54B4B"
},
"contextFontSize": 14, "contextFontSize": 14,
"contextFontWeight": 400, "contextFontWeight": 400,
"headerSize": 60, "headerSize": 60,
@ -7,4 +10,4 @@
"participantItemHeight": 48, "participantItemHeight": 48,
"participantsPaneWidth": 315, "participantsPaneWidth": 315,
"rangeInputThumbSize": 14 "rangeInputThumbSize": 14
} }

View File

@ -11,7 +11,7 @@ import { translate } from '../../../base/i18n';
import { IconRaisedHand } from '../../../base/icons'; import { IconRaisedHand } from '../../../base/icons';
import { import {
getLocalParticipant, getLocalParticipant,
participantUpdated raiseHand
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components'; import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
@ -78,17 +78,7 @@ class RaiseHandButton extends AbstractButton<Props, *> {
sendAnalytics(createToolbarEvent('raise.hand', { enable })); sendAnalytics(createToolbarEvent('raise.hand', { enable }));
this.props.dispatch(participantUpdated({ this.props.dispatch(raiseHand(enable));
// XXX Only the local participant is allowed to update without
// stating the JitsiConference instance (i.e. participant property
// `conference` for a remote participant) because the local
// participant is uniquely identified by the very fact that there is
// only one local participant.
id: this.props._localParticipant.id,
local: true,
raisedHand: enable
}));
} }
} }

View File

@ -32,7 +32,7 @@ import JitsiMeetJS from '../../../base/lib-jitsi-meet';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipants, getParticipants,
participantUpdated raiseHand
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { OverflowMenuItem } from '../../../base/toolbox/components'; import { OverflowMenuItem } from '../../../base/toolbox/components';
@ -522,17 +522,7 @@ class Toolbox extends Component<Props> {
const { _localParticipantID, _raisedHand } = this.props; const { _localParticipantID, _raisedHand } = this.props;
const newRaisedStatus = !_raisedHand; const newRaisedStatus = !_raisedHand;
this.props.dispatch(participantUpdated({ this.props.dispatch(raiseHand(newRaisedStatus));
// XXX Only the local participant is allowed to update without
// stating the JitsiConference instance (i.e. participant property
// `conference` for a remote participant) because the local
// participant is uniquely identified by the very fact that there is
// only one local participant.
id: _localParticipantID,
local: true,
raisedHand: newRaisedStatus
}));
APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus); APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus);
} }
@ -1276,6 +1266,7 @@ class Toolbox extends Component<Props> {
<ToolbarButton <ToolbarButton
accessibilityLabel = { t('toolbar.accessibilityLabel.participants') } accessibilityLabel = { t('toolbar.accessibilityLabel.participants') }
icon = { IconParticipants } icon = { IconParticipants }
key = 'participants'
onClick = { this._onToolbarToggleParticipantsPane } onClick = { this._onToolbarToggleParticipantsPane }
toggled = { this.props._participantsPaneOpen } toggled = { this.props._participantsPaneOpen }
tooltip = { t('toolbar.participants') } />) tooltip = { t('toolbar.participants') } />)

View File

@ -10,6 +10,8 @@ import {
sendAnalytics, sendAnalytics,
VIDEO_MUTE VIDEO_MUTE
} from '../analytics'; } from '../analytics';
import { showModeratedNotification } from '../av-moderation/actions';
import { shouldShowModeratedNotification } from '../av-moderation/functions';
import { import {
MEDIA_TYPE, MEDIA_TYPE,
setAudioMuted, setAudioMuted,
@ -33,7 +35,7 @@ const logger = getLogger(__filename);
* @returns {Function} * @returns {Function}
*/ */
export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) { export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
return (dispatch: Dispatch<any>) => { return (dispatch: Dispatch<any>, getState: Function) => {
const isAudio = mediaType === MEDIA_TYPE.AUDIO; const isAudio = mediaType === MEDIA_TYPE.AUDIO;
if (!isAudio && mediaType !== MEDIA_TYPE.VIDEO) { if (!isAudio && mediaType !== MEDIA_TYPE.VIDEO) {
@ -41,6 +43,14 @@ export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
return; return;
} }
// check for A/V Moderation when trying to unmute
if (!enable && shouldShowModeratedNotification(MEDIA_TYPE.AUDIO, getState())) {
dispatch(showModeratedNotification(MEDIA_TYPE.AUDIO));
return;
}
sendAnalytics(createToolbarEvent(isAudio ? AUDIO_MUTE : VIDEO_MUTE, { enable })); sendAnalytics(createToolbarEvent(isAudio ? AUDIO_MUTE : VIDEO_MUTE, { enable }));
dispatch(isAudio ? setAudioMuted(enable, /* ensureTrack */ true) dispatch(isAudio ? setAudioMuted(enable, /* ensureTrack */ true)
: setVideoMuted(enable, mediaType, VIDEO_MUTISM_AUTHORITY.USER, /* ensureTrack */ true)); : setVideoMuted(enable, mediaType, VIDEO_MUTISM_AUTHORITY.USER, /* ensureTrack */ true));

View File

@ -132,8 +132,10 @@ function on_message(event)
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync'); module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
return true; return true;
else else
room.av_moderation = {}; if not room.av_moderation then
room.av_moderation_actors = {}; room.av_moderation = {};
room.av_moderation_actors = {};
end
room.av_moderation[mediaType] = {}; room.av_moderation[mediaType] = {};
room.av_moderation_actors[mediaType] = occupant.nick; room.av_moderation_actors[mediaType] = occupant.nick;
end end
@ -147,10 +149,10 @@ function on_message(event)
room.av_moderation_actors[mediaType] = nil; room.av_moderation_actors[mediaType] = nil;
-- clears room.av_moderation if empty -- clears room.av_moderation if empty
local is_empty = false; local is_empty = true;
for key,_ in pairs(room.av_moderation) do for key,_ in pairs(room.av_moderation) do
if room.av_moderation[key] then if room.av_moderation[key] then
is_empty = true; is_empty = false;
end end
end end
if is_empty then if is_empty then