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:
parent
3e8f725c62
commit
64ae9c7953
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// @flow
|
||||
|
||||
import '../authentication/middleware';
|
||||
import '../av-moderation/middleware';
|
||||
import '../base/devices/middleware';
|
||||
import '../e2ee/middleware';
|
||||
import '../external-api/middleware';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// @flow
|
||||
|
||||
import '../av-moderation/reducer';
|
||||
import '../base/devices/reducer';
|
||||
import '../e2ee/reducer';
|
||||
import '../feedback/reducer';
|
||||
|
|
|
@ -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';
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
};
|
|
@ -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);
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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;
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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 */
|
||||
|
||||
/**
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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'];
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -90,7 +90,7 @@ export type Props = {
|
|||
/**
|
||||
* The unique identifier for the notification.
|
||||
*/
|
||||
uid: number
|
||||
uid: string
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>);
|
||||
}
|
|
@ -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>);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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')}
|
||||
|
|
|
@ -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 }>
|
||||
|
|
|
@ -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 } />
|
||||
|
|
|
@ -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 = () => {
|
|||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
`}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
{
|
||||
"colors": {
|
||||
"moderationDisabled": "#E54B4B"
|
||||
},
|
||||
"contextFontSize": 14,
|
||||
"contextFontWeight": 400,
|
||||
"headerSize": 60,
|
||||
|
@ -7,4 +10,4 @@
|
|||
"participantItemHeight": 48,
|
||||
"participantsPaneWidth": 315,
|
||||
"rangeInputThumbSize": 14
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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') } />)
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue