diff --git a/modules/API/API.js b/modules/API/API.js index e7bc475ef..8d2a13d7e 100644 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -6,6 +6,17 @@ import { createApiEvent, sendAnalytics } from '../../react/features/analytics'; +import { + approveParticipantAudio, + approveParticipantVideo, + rejectParticipantAudio, + rejectParticipantVideo, + requestDisableAudioModeration, + requestDisableVideoModeration, + requestEnableAudioModeration, + requestEnableVideoModeration +} from '../../react/features/av-moderation/actions'; +import { isEnabledFromState } from '../../react/features/av-moderation/functions'; import { getCurrentConference, sendTones, @@ -25,7 +36,8 @@ import { pinParticipant, kickParticipant, raiseHand, - isParticipantModerator + isParticipantModerator, + isLocalParticipantModerator } from '../../react/features/base/participants'; import { updateSettings } from '../../react/features/base/settings'; import { isToggleCameraEnabled, toggleCamera } from '../../react/features/base/tracks'; @@ -50,6 +62,7 @@ import { resizeLargeVideo } from '../../react/features/large-video/actions.web'; import { toggleLobbyMode } from '../../react/features/lobby/actions'; +import { isForceMuted } from '../../react/features/participants-pane/functions'; import { RECORDING_TYPES } from '../../react/features/recording/constants'; import { getActiveSession } from '../../react/features/recording/functions'; import { isScreenAudioSupported } from '../../react/features/screen-share'; @@ -100,6 +113,20 @@ let videoAvailable = true; */ function initCommands() { commands = { + 'approve-video': participantId => { + if (!isLocalParticipantModerator(APP.store.getState())) { + return; + } + + APP.store.dispatch(approveParticipantVideo(participantId)); + }, + 'ask-to-unmute': participantId => { + if (!isLocalParticipantModerator(APP.store.getState())) { + return; + } + + APP.store.dispatch(approveParticipantAudio(participantId)); + }, 'display-name': displayName => { sendAnalytics(createApiEvent('display.name.changed')); APP.conference.changeLocalDisplayName(displayName); @@ -150,6 +177,15 @@ function initCommands() { 'proxy-connection-event': event => { APP.conference.onProxyConnectionEvent(event); }, + 'reject-participant': (participantId, mediaType) => { + if (!isLocalParticipantModerator(APP.store.getState())) { + return; + } + + const reject = mediaType === MEDIA_TYPE.VIDEO ? rejectParticipantVideo : rejectParticipantAudio; + + APP.store.dispatch(reject(participantId)); + }, 'resize-large-video': (width, height) => { logger.debug('Resize large video command received'); sendAnalytics(createApiEvent('largevideo.resized')); @@ -218,6 +254,24 @@ function initCommands() { sendAnalytics(createApiEvent('chat.toggled')); APP.store.dispatch(toggleChat()); }, + 'toggle-moderation': (enabled, mediaType) => { + const state = APP.store.getState(); + + if (!isLocalParticipantModerator(state)) { + return; + } + + const enable = mediaType === MEDIA_TYPE.VIDEO + ? requestEnableVideoModeration : requestEnableAudioModeration; + const disable = mediaType === MEDIA_TYPE.VIDEO + ? requestDisableVideoModeration : requestDisableAudioModeration; + + if (enabled) { + APP.store.dispatch(enable()); + } else { + APP.store.dispatch(disable()); + } + }, 'toggle-raise-hand': () => { const localParticipant = getLocalParticipant(APP.store.getState()); @@ -541,6 +595,22 @@ function initCommands() { case 'is-audio-muted': callback(APP.conference.isLocalAudioMuted()); break; + case 'is-moderation-on': { + const { mediaType } = request; + const type = mediaType || MEDIA_TYPE.AUDIO; + + callback(isEnabledFromState(type, APP.store.getState())); + break; + } + case 'is-participant-force-muted': { + const state = APP.store.getState(); + const { participantId, mediaType } = request; + const type = mediaType || MEDIA_TYPE.AUDIO; + const participant = getParticipantById(state, participantId); + + callback(isForceMuted(participant, type, state)); + break; + } case 'is-video-muted': callback(APP.conference.isLocalVideoMuted()); break; @@ -806,6 +876,51 @@ class API { }); } + /** + * Notify the external application that the moderation status has changed. + * + * @param {string} mediaType - Media type for which the moderation changed. + * @param {boolean} enabled - Whether or not the new moderation status is enabled. + * @returns {void} + */ + notifyModerationChanged(mediaType: string, enabled: boolean) { + this._sendEvent({ + name: 'moderation-status-changed', + mediaType, + enabled + }); + } + + /** + * Notify the external application that a participant was approved on moderation. + * + * @param {string} participantId - The ID of the participant that got approved. + * @param {string} mediaType - Media type for which the participant was approved. + * @returns {void} + */ + notifyParticipantApproved(participantId: string, mediaType: string) { + this._sendEvent({ + name: 'moderation-participant-approved', + id: participantId, + mediaType + }); + } + + /** + * Notify the external application that a participant was rejected on moderation. + * + * @param {string} participantId - The ID of the participant that got rejected. + * @param {string} mediaType - Media type for which the participant was rejected. + * @returns {void} + */ + notifyParticipantRejected(participantId: string, mediaType: string) { + this._sendEvent({ + name: 'moderation-participant-rejected', + id: participantId, + mediaType + }); + } + /** * Notify external application that the video quality setting has changed. * diff --git a/modules/API/external/external_api.js b/modules/API/external/external_api.js index f79b11c6c..970d7452e 100644 --- a/modules/API/external/external_api.js +++ b/modules/API/external/external_api.js @@ -27,6 +27,8 @@ const ALWAYS_ON_TOP_FILENAMES = [ * commands expected by jitsi-meet */ const commands = { + approveVideo: 'approve-video', + askToUnmute: 'ask-to-unmute', avatarUrl: 'avatar-url', cancelPrivateChat: 'cancel-private-chat', displayName: 'display-name', @@ -40,6 +42,7 @@ const commands = { overwriteConfig: 'overwrite-config', password: 'password', pinParticipant: 'pin-participant', + rejectParticipant: 'reject-participant', resizeLargeVideo: 'resize-large-video', sendChatMessage: 'send-chat-message', sendEndpointTextMessage: 'send-endpoint-text-message', @@ -60,6 +63,7 @@ const commands = { toggleCameraMirror: 'toggle-camera-mirror', toggleChat: 'toggle-chat', toggleFilmStrip: 'toggle-film-strip', + toggleModeration: 'toggle-moderation', toggleRaiseHand: 'toggle-raise-hand', toggleShareAudio: 'toggle-share-audio', toggleShareScreen: 'toggle-share-screen', @@ -92,6 +96,9 @@ const events = { 'incoming-message': 'incomingMessage', 'log': 'log', 'mic-error': 'micError', + 'moderation-participant-approved': 'moderationParticipantApproved', + 'moderation-participant-rejected': 'moderationParticipantRejected', + 'moderation-status-changed': 'moderationStatusChanged', 'mouse-enter': 'mouseEnter', 'mouse-leave': 'mouseLeave', 'mouse-move': 'mouseMove', @@ -896,6 +903,36 @@ export default class JitsiMeetExternalAPI extends EventEmitter { }); } + /** + * Returns the moderation on status on the given mediaType. + * + * @param {string} mediaType - The media type for which to check moderation. + * @returns {Promise} - Resolves with the moderation on status and rejects on + * failure. + */ + isModerationOn(mediaType) { + return this._transport.sendRequest({ + name: 'is-moderation-on', + mediaType + }); + } + + /** + * Returns force muted status of the given participant id for the given media type. + * + * @param {string} participantId - The id of the participant to check. + * @param {string} mediaType - The media type for which to check. + * @returns {Promise} - Resolves with the force muted status and rejects on + * failure. + */ + isParticipantForceMuted(participantId, mediaType) { + return this._transport.sendRequest({ + name: 'is-participant-force-muted', + participantId, + mediaType + }); + } + /** * Returns screen sharing status. * diff --git a/react/features/av-moderation/actions.js b/react/features/av-moderation/actions.js index 153a09971..eae6d9df8 100644 --- a/react/features/av-moderation/actions.js +++ b/react/features/av-moderation/actions.js @@ -23,28 +23,53 @@ import { import { isEnabledFromState } from './functions'; /** - * Action used by moderator to approve audio and video for a participant. + * Action used by moderator to approve audio 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) => { +export const approveParticipantAudio = (id: string) => (dispatch: Function, getState: Function) => { const state = getState(); const { conference } = getConferenceState(state); - const participant = getParticipantById(state, id); - const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state); const isAudioModerationOn = isEnabledFromState(MEDIA_TYPE.AUDIO, state); const isVideoModerationOn = isEnabledFromState(MEDIA_TYPE.VIDEO, state); if (isAudioModerationOn || !isVideoModerationOn) { conference.avModerationApprove(MEDIA_TYPE.AUDIO, id); } +}; + +/** + * Action used by moderator to approve video for a participant. + * + * @param {staring} id - The id of the participant to be approved. + * @returns {void} + */ +export const approveParticipantVideo = (id: string) => (dispatch: Function, getState: Function) => { + const state = getState(); + const { conference } = getConferenceState(state); + const participant = getParticipantById(state, id); + + const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state); + const isVideoModerationOn = isEnabledFromState(MEDIA_TYPE.VIDEO, state); + if (isVideoModerationOn && isVideoForceMuted) { conference.avModerationApprove(MEDIA_TYPE.VIDEO, id); } }; +/** + * 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) => { + dispatch(approveParticipantAudio(id)); + dispatch(approveParticipantVideo(id)); +}; + /** * Action used by moderator to reject audio for a participant. * diff --git a/react/features/av-moderation/middleware.js b/react/features/av-moderation/middleware.js index 2f0234f6b..1dad96117 100644 --- a/react/features/av-moderation/middleware.js +++ b/react/features/av-moderation/middleware.js @@ -22,7 +22,13 @@ import { import { muteLocal } from '../video-menu/actions.any'; import { + DISABLE_MODERATION, + ENABLE_MODERATION, + LOCAL_PARTICIPANT_APPROVED, LOCAL_PARTICIPANT_MODERATION_NOTIFICATION, + LOCAL_PARTICIPANT_REJECTED, + PARTICIPANT_APPROVED, + PARTICIPANT_REJECTED, REQUEST_DISABLE_AUDIO_MODERATION, REQUEST_DISABLE_VIDEO_MODERATION, REQUEST_ENABLE_AUDIO_MODERATION, @@ -51,6 +57,8 @@ import { } from './functions'; import { ASKED_TO_UNMUTE_FILE } from './sounds'; +declare var APP: Object; + MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { const { type } = action; const { conference } = getConferenceState(getState()); @@ -148,6 +156,46 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { break; } + case ENABLE_MODERATION: { + if (typeof APP !== 'undefined') { + APP.API.notifyModerationChanged(action.mediaType, true); + } + break; + } + case DISABLE_MODERATION: { + if (typeof APP !== 'undefined') { + APP.API.notifyModerationChanged(action.mediaType, false); + } + break; + } + case LOCAL_PARTICIPANT_APPROVED: { + if (typeof APP !== 'undefined') { + const local = getLocalParticipant(getState()); + + APP.API.notifyParticipantApproved(local.id, action.mediaType); + } + break; + } + case PARTICIPANT_APPROVED: { + if (typeof APP !== 'undefined') { + APP.API.notifyParticipantApproved(action.id, action.mediaType); + } + break; + } + case LOCAL_PARTICIPANT_REJECTED: { + if (typeof APP !== 'undefined') { + const local = getLocalParticipant(getState()); + + APP.API.notifyParticipantRejected(local.id, action.mediaType); + } + break; + } + case PARTICIPANT_REJECTED: { + if (typeof APP !== 'undefined') { + APP.API.notifyParticipantRejected(action.id, action.mediaType); + } + break; + } } return next(action);