diff --git a/config.js b/config.js index 6445b4b2a9..82c1b84e5f 100644 --- a/config.js +++ b/config.js @@ -393,6 +393,9 @@ var config = { // // showPrejoinWarning: true, // // If true, the notification for recording start will display a link to download the cloud recording. // // showRecordingLink: true, + // // If true, mutes audio and video when a recording begins and displays a dialog + // // explaining the effect of unmuting. + // // requireConsent: true, // }, // recordingService: { diff --git a/lang/main.json b/lang/main.json index 0f87c55222..8fd24dd0da 100644 --- a/lang/main.json +++ b/lang/main.json @@ -263,6 +263,7 @@ "Remove": "Remove", "Share": "Share", "Submit": "Submit", + "Understand": "I understand", "WaitForHostMsg": "The conference has not yet started because no moderators have yet arrived. If you'd like to become a moderator please log-in. Otherwise, please wait.", "WaitForHostNoAuthMsg": "The conference has not yet started because no moderators have yet arrived. Please wait.", "WaitingForHostButton": "Wait for moderator", @@ -393,6 +394,8 @@ "recentlyUsedObjects": "Your recently used objects", "recording": "Recording", "recordingDisabledBecauseOfActiveLiveStreamingTooltip": "Not possible while a live stream is active", + "recordingInProgressDescription": "This meeting is being recorded. Your audio and video have been muted. If you choose to unmute, you consent to being recorded.", + "recordingInProgressTitle": "Recording in progress", "rejoinNow": "Rejoin now", "remoteControlAllowedMessage": "{{user}} accepted your remote control request!", "remoteControlDeniedMessage": "{{user}} rejected your remote control request!", diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index 8f8351da4d..db683dbab3 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -542,6 +542,7 @@ export interface IConfig { recordingSharingUrl?: string; recordings?: { recordAudioAndVideo?: boolean; + requireConsent?: boolean; showPrejoinWarning?: boolean; showRecordingLink?: boolean; suggestRecording?: boolean; diff --git a/react/features/base/config/configWhitelist.ts b/react/features/base/config/configWhitelist.ts index ced250292f..dd2014c15e 100644 --- a/react/features/base/config/configWhitelist.ts +++ b/react/features/base/config/configWhitelist.ts @@ -205,7 +205,10 @@ export default [ 'remoteVideoMenu', 'roomPasswordNumberOfDigits', 'readOnlyName', - 'recordings', + 'recordings.recordAudioAndVideo', + 'recordings.showPrejoinWarning', + 'recordings.showRecordingLink', + 'recordings.suggestRecording', 'replaceParticipant', 'resolution', 'screenshotCapture', diff --git a/react/features/base/dialog/components/native/ConfirmDialog.tsx b/react/features/base/dialog/components/native/ConfirmDialog.tsx index 263108f7b3..bc383f4654 100644 --- a/react/features/base/dialog/components/native/ConfirmDialog.tsx +++ b/react/features/base/dialog/components/native/ConfirmDialog.tsx @@ -36,6 +36,11 @@ interface IProps extends AbstractProps, WithTranslation { */ descriptionKey?: string | { key: string; params: string; }; + /** + * Whether the cancel button is hidden. + */ + isCancelHidden?: Boolean; + /** * Whether or not the nature of the confirm button is destructive. */ @@ -100,6 +105,7 @@ class ConfirmDialog extends AbstractDialog { cancelLabel, children, confirmLabel, + isCancelHidden, isConfirmDestructive, isConfirmHidden, t, @@ -121,10 +127,12 @@ class ConfirmDialog extends AbstractDialog { } { this._renderDescription() } { children } - + { + !isCancelHidden && + } { !isConfirmHidden && ; showGiphyIntegration?: boolean; supportUrl?: string; @@ -186,6 +187,7 @@ ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STA premeetingBackground, sharedVideoAllowedURLDomains, showGiphyIntegration, + requireRecordingConsent, supportUrl, virtualBackgrounds } = action.value; @@ -205,6 +207,7 @@ ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STA premeetingBackground, sharedVideoAllowedURLDomains, showGiphyIntegration, + requireRecordingConsent, supportUrl, customizationFailed: false, customizationReady: true, diff --git a/react/features/recording/components/Recording/index.native.ts b/react/features/recording/components/Recording/index.native.ts index 286cb973db..7032898244 100644 --- a/react/features/recording/components/Recording/index.native.ts +++ b/react/features/recording/components/Recording/index.native.ts @@ -1 +1,2 @@ export { default as StartRecordingDialog } from './native/StartRecordingDialog'; +export { default as RecordingConsentDialog } from './native/RecordingConsentDialog'; diff --git a/react/features/recording/components/Recording/index.web.ts b/react/features/recording/components/Recording/index.web.ts index a65408d556..105e86c378 100644 --- a/react/features/recording/components/Recording/index.web.ts +++ b/react/features/recording/components/Recording/index.web.ts @@ -1 +1,2 @@ export { default as StartRecordingDialog } from './web/StartRecordingDialog'; +export { default as RecordingConsentDialog } from './web/RecordingConsentDialog'; diff --git a/react/features/recording/components/Recording/native/RecordingConsentDialog.tsx b/react/features/recording/components/Recording/native/RecordingConsentDialog.tsx new file mode 100644 index 0000000000..0a5cba98ef --- /dev/null +++ b/react/features/recording/components/Recording/native/RecordingConsentDialog.tsx @@ -0,0 +1,28 @@ +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog'; +import { setAudioUnmutePermissions, setVideoUnmutePermissions } from '../../../../base/media/actions'; + +/** + * Component that renders the dialog for explicit consent for recordings. + * + * @returns {JSX.Element} + */ +export default function RecordingConsentDialog() { + const dispatch = useDispatch(); + + const consent = useCallback(() => { + dispatch(setAudioUnmutePermissions(false, true)); + dispatch(setVideoUnmutePermissions(false, true)); + }, []); + + return ( + + ); +} diff --git a/react/features/recording/components/Recording/web/RecordingConsentDialog.tsx b/react/features/recording/components/Recording/web/RecordingConsentDialog.tsx new file mode 100644 index 0000000000..d8b922fb07 --- /dev/null +++ b/react/features/recording/components/Recording/web/RecordingConsentDialog.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { setAudioUnmutePermissions, setVideoUnmutePermissions } from '../../../../base/media/actions'; +import Dialog from '../../../../base/ui/components/web/Dialog'; + +/** + * Component that renders the dialog for explicit consent for recordings. + * + * @returns {JSX.Element} + */ +export default function RecordingConsentDialog() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const consent = useCallback(() => { + dispatch(setAudioUnmutePermissions(false, true)); + dispatch(setVideoUnmutePermissions(false, true)); + }, []); + + return ( + +
+ {t('dialog.recordingInProgressDescription')} +
+
+ ); +} diff --git a/react/features/recording/functions.ts b/react/features/recording/functions.ts index 03a36ed37f..88752b129a 100644 --- a/react/features/recording/functions.ts +++ b/react/features/recording/functions.ts @@ -437,3 +437,26 @@ export function isLiveStreamingButtonVisible({ }) { return !isInBreakoutRoom && liveStreamingEnabled && liveStreamingAllowed; } + +/** + * Whether the RecordingConsentDialog should be displayed. + * + * @param {any} recorderSession - The recorder session. + * @param {IReduxState} state - The Redux state. + * @returns {boolean} + */ +export function shouldRequireRecordingConsent(recorderSession: any, state: IReduxState) { + const { requireRecordingConsent } = state['features/dynamic-branding'] || {}; + const { requireConsent } = state['features/base/config'].recordings || {}; + + if (!requireConsent && !requireRecordingConsent) { + return false; + } + + if (!recorderSession._initiator + || recorderSession._statusFromJicofo === JitsiRecordingConstants.status.OFF) { + return false; + } + + return recorderSession._initiator !== getLocalParticipant(state)?.id; +} diff --git a/react/features/recording/middleware.ts b/react/features/recording/middleware.ts index b8af7dc677..cac598dfe0 100644 --- a/react/features/recording/middleware.ts +++ b/react/features/recording/middleware.ts @@ -1,13 +1,22 @@ +import { batch } from 'react-redux'; + import { createRecordingEvent } from '../analytics/AnalyticsEvents'; import { sendAnalytics } from '../analytics/functions'; import { IStore } from '../app/types'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes'; import { CONFERENCE_JOIN_IN_PROGRESS } from '../base/conference/actionTypes'; import { getCurrentConference } from '../base/conference/functions'; +import { openDialog } from '../base/dialog/actions'; import JitsiMeetJS, { JitsiConferenceEvents, JitsiRecordingConstants } from '../base/lib-jitsi-meet'; +import { + setAudioMuted, + setAudioUnmutePermissions, + setVideoMuted, + setVideoUnmutePermissions +} from '../base/media/actions'; import { MEDIA_TYPE } from '../base/media/constants'; import { PARTICIPANT_UPDATED } from '../base/participants/actionTypes'; import { updateLocalRecordingStatus } from '../base/participants/actions'; @@ -37,6 +46,7 @@ import { showStoppedRecordingNotification, updateRecordingSessionData } from './actions'; +import { RecordingConsentDialog } from './components/Recording'; import LocalRecordingManager from './components/Recording/LocalRecordingManager'; import { LIVE_STREAMING_OFF_SOUND_ID, @@ -49,6 +59,7 @@ import { getResourceId, getSessionById, registerRecordingAudioFiles, + shouldRequireRecordingConsent, unregisterRecordingAudioFiles } from './functions'; import logger from './logger'; @@ -101,7 +112,11 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => { (recorderSession: any) => { if (recorderSession) { recorderSession.getID() && dispatch(updateRecordingSessionData(recorderSession)); - recorderSession.getError() && _showRecordingErrorNotification(recorderSession, dispatch, getState); + if (recorderSession.getError()) { + _showRecordingErrorNotification(recorderSession, dispatch, getState); + } else { + _showExplicitConsentDialog(recorderSession, dispatch, getState); + } } return; @@ -390,3 +405,25 @@ function _showRecordingErrorNotification(session: any, dispatch: IStore['dispatc APP.API.notifyRecordingStatusChanged(false, mode, error, isRecorderTranscriptionsRunning(getState())); } } + +/** + * Mutes audio and video and displays the RecordingConsentDialog when the conditions are met. + * + * @param {any} recorderSession - The recording session. + * @param {Function} dispatch - The Redux dispatch function. + * @param {Function} getState - The Redux getState function. + * @returns {void} + */ +function _showExplicitConsentDialog(recorderSession: any, dispatch: IStore['dispatch'], getState: IStore['getState']) { + if (!shouldRequireRecordingConsent(recorderSession, getState())) { + return; + } + + batch(() => { + dispatch(setAudioUnmutePermissions(true, true)); + dispatch(setVideoUnmutePermissions(true, true)); + dispatch(setAudioMuted(true)); + dispatch(setVideoMuted(true)); + dispatch(openDialog(RecordingConsentDialog)); + }); +}