mirror of https://github.com/jitsi/jitsi-meet
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>pull/9438/head jitsi-meet_6014
parent
3e8f725c62
commit
64ae9c7953
@ -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; |
||||
}); |
@ -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> |
||||
); |
||||
}; |
@ -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,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' |
||||
}; |
||||
|
Loading…
Reference in new issue