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

View File

@ -521,6 +521,7 @@
"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",
"invitedOneMember": "{{name}} has been invited",
"invitedThreePlusMembers": "{{name}} and {{count}} others 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 ",
"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",
"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"
},
"participantsPane": {
@ -560,8 +573,12 @@
"participantsList": "Meeting participants ({{count}})"
},
"actions": {
"allow": "Allow attendees to:",
"invite": "Invite Someone",
"askUnmute": "Ask to unmute",
"muteAll": "Mute all",
"startModeration": "Unmute themselves or start video",
"stopEveryonesVideo": "Stop everyone's video",
"stopVideo": "Stop video"
}
},

View File

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

View File

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

View File

@ -1,5 +1,6 @@
// @flow
import '../av-moderation/reducer';
import '../base/devices/reducer';
import '../e2ee/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 { showModeratedNotification } from '../../av-moderation/actions';
import { shouldShowModeratedNotification } from '../../av-moderation/functions';
import {
SET_AUDIO_MUTED,
SET_AUDIO_AVAILABLE,
@ -12,8 +15,8 @@ import {
TOGGLE_CAMERA_FACING_MODE
} from './actionTypes';
import {
CAMERA_FACING_MODE,
MEDIA_TYPE,
type MediaType,
VIDEO_MUTISM_AUTHORITY
} from './constants';
@ -64,7 +67,7 @@ export function setAudioMuted(muted: boolean, ensureTrack: boolean = false) {
* cameraFacingMode: CAMERA_FACING_MODE
* }}
*/
export function setCameraFacingMode(cameraFacingMode: CAMERA_FACING_MODE) {
export function setCameraFacingMode(cameraFacingMode: string) {
return {
type: SET_CAMERA_FACING_MODE,
cameraFacingMode
@ -102,11 +105,20 @@ export function setVideoAvailable(available: boolean) {
*/
export function setVideoMuted(
muted: boolean,
mediaType: MEDIA_TYPE = MEDIA_TYPE.VIDEO,
mediaType: MediaType = MEDIA_TYPE.VIDEO,
authority: number = VIDEO_MUTISM_AUTHORITY.USER,
ensureTrack: boolean = false) {
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
const newValue = muted ? oldValue | authority : oldValue & ~authority;

View File

@ -1,3 +1,5 @@
// @flow
/**
* The set of facing modes for camera.
*
@ -8,17 +10,20 @@ export const CAMERA_FACING_MODE = {
USER: 'user'
};
export type MediaType = 'audio' | 'video' | 'presenter';
/**
* The set of media types.
*
* @enum {string}
*/
export const MEDIA_TYPE = {
export const MEDIA_TYPE: { AUDIO: MediaType, PRESENTER: MediaType, VIDEO: MediaType} = {
AUDIO: 'audio',
PRESENTER: 'presenter',
VIDEO: 'video'
};
/* 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';
/**
* 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,
GRANT_MODERATOR,
KICK_PARTICIPANT,
LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_ID_CHANGED,
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,
GRANT_MODERATOR,
KICK_PARTICIPANT,
LOCAL_PARTICIPANT_RAISE_HAND,
MUTE_REMOTE_PARTICIPANT,
PARTICIPANT_DISPLAY_NAME_CHANGED,
PARTICIPANT_JOINED,
@ -110,6 +111,29 @@ MiddlewareRegistry.register(store => next => action => {
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: {
const { conference } = store.getState()['features/base/conference'];

View File

@ -1,6 +1,8 @@
// @flow
import UIEvents from '../../../../service/UI/UIEvents';
import { showModeratedNotification } from '../../av-moderation/actions';
import { shouldShowModeratedNotification } from '../../av-moderation/functions';
import { hideNotification } from '../../notifications';
import { isPrejoinPageVisible } from '../../prejoin/functions';
import { getAvailableDevices } from '../devices/actions';
@ -135,6 +137,14 @@ 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())) {
store.dispatch(showModeratedNotification(MEDIA_TYPE.PRESENTER));
return;
}
APP.UI.emitEvent(UIEvents.TOGGLE_SCREENSHARING, action.audioOnly);
}
break;

View File

@ -4,6 +4,7 @@ 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';
@ -228,7 +229,11 @@ class Conference extends AbstractConference<Props, *> {
<Notice />
<div id = 'videospace'>
<LargeVideo />
{!_isParticipantsPaneVisible && <KnockingParticipantList />}
{!_isParticipantsPaneVisible
&& <div id = 'notification-participant-list'>
<KnockingParticipantList />
<AudioModerationNotifications />
</div>}
<Filmstrip />
</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.
*

View File

@ -2,11 +2,10 @@
import React from 'react';
import { Avatar } from '../../../base/avatar';
import { translate } from '../../../base/i18n';
import { connect } from '../../../base/redux';
import { isToolboxVisible } from '../../../toolbox/functions.web';
import { HIDDEN_EMAILS } from '../../constants';
import NotificationWithParticipants from '../../../notifications/components/web/NotificationWithParticipants';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../../actions';
import AbstractKnockingParticipantList, {
mapStateToProps as abstractMapStateToProps,
type Props as AbstractProps
@ -17,7 +16,7 @@ type Props = AbstractProps & {
/**
* 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
*/
render() {
const { _participants, _toolboxVisible, _visible, t } = this.props;
const { _participants, _visible, t } = this.props;
if (!_visible) {
return null;
}
return (
<div
className = { _toolboxVisible ? 'toolbox-visible' : '' }
id = 'knocking-participant-list'>
<span className = 'title'>
<div id = 'knocking-participant-list'>
<div className = 'title'>
{ t('lobby.knockingParticipantList') }
</span>
<ul className = 'knocking-participants-container'>
{ _participants.map(p => (
<li
className = 'knocking-participant'
key = { p.id }>
<Avatar
displayName = { p.name }
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>
<NotificationWithParticipants
approveButtonText = { t('lobby.allow') }
onApprove = { approveKnockingParticipant }
onReject = { rejectKnockingParticipant }
participants = { _participants }
rejectButtonText = { t('lobby.reject') }
testIdPrefix = 'lobby' />
</div>
);
}
@ -87,17 +54,4 @@ class KnockingParticipantList extends AbstractKnockingParticipantList<Props> {
_onRespondToParticipant: (string, boolean) => Function;
}
/**
* 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));
export default translate(connect(abstractMapStateToProps)(KnockingParticipantList));

View File

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

View File

@ -33,10 +33,10 @@ export function clearNotifications() {
* removed.
* @returns {{
* type: HIDE_NOTIFICATION,
* uid: number
* uid: string
* }}
*/
export function hideNotification(uid: number) {
export function hideNotification(uid: string) {
return {
type: HIDE_NOTIFICATION,
uid
@ -95,7 +95,7 @@ export function showNotification(props: Object = {}, timeout: ?number) {
type: SHOW_NOTIFICATION,
props,
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.
*/
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 { useDispatch } from 'react-redux';
import { setKnockingParticipantApproval } from '../../lobby/actions';
import { ActionTrigger, MediaState } from '../constants';
import { approveKnockingParticipant, rejectKnockingParticipant } from '../../lobby/actions';
import { ACTION_TRIGGER, MEDIA_STATE } from '../constants';
import { ParticipantItem } from './ParticipantItem';
import { ParticipantActionButton } from './styled';
@ -20,17 +20,17 @@ type Props = {
export const LobbyParticipantItem = ({ participant: p }: Props) => {
const dispatch = useDispatch();
const admit = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, true), [ dispatch ]));
const reject = useCallback(() => dispatch(setKnockingParticipantApproval(p.id, false), [ dispatch ]));
const admit = useCallback(() => dispatch(approveKnockingParticipant(p.id), [ dispatch ]));
const reject = useCallback(() => dispatch(rejectKnockingParticipant(p.id), [ dispatch ]));
const { t } = useTranslation();
return (
<ParticipantItem
actionsTrigger = { ActionTrigger.Permanent }
audioMuteState = { MediaState.None }
actionsTrigger = { ACTION_TRIGGER.PERMANENT }
audioMediaState = { MEDIA_STATE.NONE }
name = { p.name }
participant = { p }
videoMuteState = { MediaState.None }>
videoMuteState = { MEDIA_STATE.NONE }>
<ParticipantActionButton
onClick = { reject }>
{t('lobby.reject')}

View File

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

View File

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

View File

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

View File

@ -13,10 +13,11 @@ import {
IconMicrophoneEmptySlash
} from '../../base/icons';
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 {
ColoredIcon,
ParticipantActionsHover,
ParticipantActionsPermanent,
ParticipantContainer,
@ -30,52 +31,56 @@ import {
* Participant actions component mapping depending on trigger type.
*/
const Actions = {
[ActionTrigger.Hover]: ParticipantActionsHover,
[ActionTrigger.Permanent]: ParticipantActionsPermanent
[ACTION_TRIGGER.HOVER]: ParticipantActionsHover,
[ACTION_TRIGGER.PERMANENT]: ParticipantActionsPermanent
};
/**
* Icon mapping for possible participant audio states.
*/
const AudioStateIcons = {
[MediaState.ForceMuted]: (
const AudioStateIcons: {[MediaState]: React$Element<any> | null} = {
[MEDIA_STATE.FORCE_MUTED]: (
<ColoredIcon color = '#E04757'>
<Icon
size = { 16 }
src = { IconMicrophoneEmptySlash } />
</ColoredIcon>
),
[MEDIA_STATE.MUTED]: (
<Icon
size = { 16 }
src = { IconMicrophoneEmptySlash } />
),
[MediaState.Muted]: (
<Icon
size = { 16 }
src = { IconMicrophoneEmptySlash } />
[MEDIA_STATE.UNMUTED]: (
<ColoredIcon color = '#1EC26A'>
<Icon
size = { 16 }
src = { IconMicrophoneEmpty } />
</ColoredIcon>
),
[MediaState.Unmuted]: (
<Icon
size = { 16 }
src = { IconMicrophoneEmpty } />
),
[MediaState.None]: null
[MEDIA_STATE.NONE]: null
};
/**
* Icon mapping for possible participant video states.
*/
const VideoStateIcons = {
[MediaState.ForceMuted]: (
[MEDIA_STATE.FORCE_MUTED]: (
<Icon
size = { 16 }
src = { IconCameraEmptyDisabled } />
),
[MediaState.Muted]: (
[MEDIA_STATE.MUTED]: (
<Icon
size = { 16 }
src = { IconCameraEmptyDisabled } />
),
[MediaState.Unmuted]: (
[MEDIA_STATE.UNMUTED]: (
<Icon
size = { 16 }
src = { IconCameraEmpty } />
),
[MediaState.None]: null
[MEDIA_STATE.NONE]: null
};
type Props = {
@ -88,7 +93,7 @@ type Props = {
/**
* Media state for audio
*/
audioMuteState: MediaState,
audioMediaState: MediaState,
/**
* React children
@ -125,9 +130,9 @@ export const ParticipantItem = ({
children,
isHighlighted,
onLeave,
actionsTrigger = ActionTrigger.Hover,
audioMuteState = MediaState.None,
videoMuteState = MediaState.None,
actionsTrigger = ACTION_TRIGGER.HOVER,
audioMediaState = MEDIA_STATE.NONE,
videoMuteState = MEDIA_STATE.NONE,
name,
participant: p
}: Props) => {
@ -155,7 +160,7 @@ export const ParticipantItem = ({
<ParticipantStates>
{p.raisedHand && <RaisedHandIndicator />}
{VideoStateIcons[videoMuteState]}
{AudioStateIcons[audioMuteState]}
{AudioStateIcons[audioMediaState]}
</ParticipantStates>
</ParticipantContent>
</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
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { openDialog } from '../../base/dialog';
import { isLocalParticipantModerator } from '../../base/participants';
import {
getParticipantCount,
isEveryoneModerator,
isLocalParticipantModerator
} from '../../base/participants';
import { MuteEveryoneDialog } from '../../video-menu/components/';
import { close } from '../actions';
import { classList, getParticipantsPaneOpen } from '../functions';
import { classList, findStyledAncestor, getParticipantsPaneOpen } from '../functions';
import theme from '../theme.json';
import { FooterContextMenu } from './FooterContextMenu';
import { LobbyParticipantList } from './LobbyParticipantList';
import { MeetingParticipantList } from './MeetingParticipantList';
import {
@ -20,6 +25,8 @@ import {
Container,
Footer,
FooterButton,
FooterEllipsisButton,
FooterEllipsisContainer,
Header
} from './styled';
@ -27,6 +34,11 @@ export const ParticipantsPane = () => {
const dispatch = useDispatch();
const paneOpen = useSelector(getParticipantsPaneOpen);
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 closePane = useCallback(() => dispatch(close(), [ dispatch ]));
@ -38,13 +50,23 @@ export const ParticipantsPane = () => {
}, [ closePane ]);
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 (
<ThemeProvider theme = { theme }>
<div
className = { classList(
'participants_pane',
!paneOpen && 'participants_pane--closed'
) }>
<div className = { classList('participants_pane', !paneOpen && 'participants_pane--closed') }>
<div className = 'participants_pane-content'>
<Header>
<Close
@ -64,6 +86,14 @@ export const ParticipantsPane = () => {
<FooterButton onClick = { muteAll }>
{t('participantsPane.actions.muteAll')}
</FooterButton>
{showContextMenu && (
<FooterEllipsisContainer>
<FooterEllipsisButton
id = 'participants-pane-context-menu'
onClick = { toggleContext } />
{contextOpen && <FooterContextMenu onMouseLeave = { toggleContext } />}
</FooterEllipsisContainer>
)}
</Footer>
)}
</div>

View File

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

View File

@ -1,22 +1,48 @@
// @flow
/**
* Reducer key for the feature.
*/
export const REDUCER_KEY = 'features/participants-pane';
export type ActionTrigger = 'Hover' | 'Permanent'
/**
* Enum of possible participant action triggers.
*/
export const ActionTrigger = {
Hover: 'ActionTrigger.Hover',
Permanent: 'ActionTrigger.Permanent'
export const ACTION_TRIGGER: {HOVER: ActionTrigger, PERMANENT: ActionTrigger} = {
HOVER: 'Hover',
PERMANENT: 'Permanent'
};
export type MediaState = 'Muted' | 'ForceMuted' | 'Unmuted' | 'None';
/**
* Enum of possible participant media states.
*/
export const MediaState = {
Muted: 'MediaState.Muted',
ForceMuted: 'MediaState.ForceMuted',
Unmuted: 'MediaState.Unmuted',
None: 'MediaState.None'
export const MEDIA_STATE: {
MUTED: MediaState,
FORCE_MUTED: MediaState,
UNMUTED: MediaState,
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 { MEDIA_TYPE, type MediaType } from '../base/media/constants';
import {
getParticipantCount,
isLocalParticipantModerator,
isParticipantModerator
} from '../base/participants/functions';
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.
@ -10,7 +22,7 @@ import { REDUCER_KEY } from './constants';
* @param {Iterable<string>} args - String iterable.
* @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.
* @returns {Element|null} Ancestor.
*/
export const findStyledAncestor = (target, component) => {
export const findStyledAncestor = (target: Object, component: any) => {
if (!target || target.matches(`.${component.styledComponentId}`)) {
return target;
}
@ -28,6 +40,50 @@ export const findStyledAncestor = (target, 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.
*
@ -35,7 +91,7 @@ export const findStyledAncestor = (target, component) => {
* @param {string} name - Property name.
* @returns {number} Float value.
*/
export const getFloatStyleProperty = (styles, name) =>
export const getFloatStyleProperty = (styles: Object, name: string) =>
parseFloat(styles.getPropertyValue(name));
/**
@ -44,7 +100,7 @@ export const getFloatStyleProperty = (styles, name) =>
* @param {Element} element - Target element.
* @returns {number} Computed height.
*/
export const getComputedOuterHeight = element => {
export const getComputedOuterHeight = (element: HTMLElement) => {
const computedStyle = getComputedStyle(element);
return element.offsetHeight
@ -58,7 +114,7 @@ export const getComputedOuterHeight = element => {
* @param {Object} state - Global state.
* @returns {Object} Feature state.
*/
const getState = state => state[REDUCER_KEY];
const getState = (state: Object) => state[REDUCER_KEY];
/**
* Is the participants pane open.
@ -66,7 +122,29 @@ const getState = state => state[REDUCER_KEY];
* @param {Object} state - Global state.
* @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.
@ -74,7 +152,7 @@ export const getParticipantsPaneOpen = state => Boolean(getState(state)?.isOpen)
* @param {Object} state - Global state.
* @returns {boolean}
*/
export const shouldRenderInviteButton = state => {
export const shouldRenderInviteButton = (state: Object) => {
const { disableInviteFunctions } = toState(state)['features/base/config'];
const flagEnabled = getFeatureFlag(state, INVITE_ENABLED, true);

View File

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

View File

@ -11,7 +11,7 @@ import { translate } from '../../../base/i18n';
import { IconRaisedHand } from '../../../base/icons';
import {
getLocalParticipant,
participantUpdated
raiseHand
} from '../../../base/participants';
import { connect } from '../../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../../base/toolbox/components';
@ -78,17 +78,7 @@ class RaiseHandButton extends AbstractButton<Props, *> {
sendAnalytics(createToolbarEvent('raise.hand', { enable }));
this.props.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: this.props._localParticipant.id,
local: true,
raisedHand: enable
}));
this.props.dispatch(raiseHand(enable));
}
}

View File

@ -32,7 +32,7 @@ import JitsiMeetJS from '../../../base/lib-jitsi-meet';
import {
getLocalParticipant,
getParticipants,
participantUpdated
raiseHand
} from '../../../base/participants';
import { connect } from '../../../base/redux';
import { OverflowMenuItem } from '../../../base/toolbox/components';
@ -522,17 +522,7 @@ class Toolbox extends Component<Props> {
const { _localParticipantID, _raisedHand } = this.props;
const newRaisedStatus = !_raisedHand;
this.props.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: _localParticipantID,
local: true,
raisedHand: newRaisedStatus
}));
this.props.dispatch(raiseHand(newRaisedStatus));
APP.API.notifyRaiseHandUpdated(_localParticipantID, newRaisedStatus);
}
@ -1276,6 +1266,7 @@ class Toolbox extends Component<Props> {
<ToolbarButton
accessibilityLabel = { t('toolbar.accessibilityLabel.participants') }
icon = { IconParticipants }
key = 'participants'
onClick = { this._onToolbarToggleParticipantsPane }
toggled = { this.props._participantsPaneOpen }
tooltip = { t('toolbar.participants') } />)

View File

@ -10,6 +10,8 @@ import {
sendAnalytics,
VIDEO_MUTE
} from '../analytics';
import { showModeratedNotification } from '../av-moderation/actions';
import { shouldShowModeratedNotification } from '../av-moderation/functions';
import {
MEDIA_TYPE,
setAudioMuted,
@ -33,7 +35,7 @@ const logger = getLogger(__filename);
* @returns {Function}
*/
export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
return (dispatch: Dispatch<any>) => {
return (dispatch: Dispatch<any>, getState: Function) => {
const isAudio = mediaType === MEDIA_TYPE.AUDIO;
if (!isAudio && mediaType !== MEDIA_TYPE.VIDEO) {
@ -41,6 +43,14 @@ export function muteLocal(enable: boolean, mediaType: MEDIA_TYPE) {
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 }));
dispatch(isAudio ? setAudioMuted(enable, /* 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');
return true;
else
room.av_moderation = {};
room.av_moderation_actors = {};
if not room.av_moderation then
room.av_moderation = {};
room.av_moderation_actors = {};
end
room.av_moderation[mediaType] = {};
room.av_moderation_actors[mediaType] = occupant.nick;
end
@ -147,10 +149,10 @@ function on_message(event)
room.av_moderation_actors[mediaType] = nil;
-- clears room.av_moderation if empty
local is_empty = false;
local is_empty = true;
for key,_ in pairs(room.av_moderation) do
if room.av_moderation[key] then
is_empty = true;
is_empty = false;
end
end
if is_empty then