This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { openDialog } from '../../../base/dialog/actions';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
|
||||
import { hideNotification, showNotification } from '../../../notifications/actions';
|
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../../notifications/constants';
|
||||
import { iAmVisitor } from '../../../visitors/functions';
|
||||
import { highlightMeetingMoment } from '../../actions.any';
|
||||
import { PROMPT_RECORDING_NOTIFICATION_ID } from '../../constants';
|
||||
import {
|
||||
getRecordButtonProps,
|
||||
isCloudRecordingRunning,
|
||||
isHighlightMeetingMomentDisabled
|
||||
} from '../../functions';
|
||||
|
||||
import { StartRecordingDialog } from './index';
|
||||
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Indicates whether or not the button is disabled.
|
||||
*/
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether or not a highlight request is in progress.
|
||||
*/
|
||||
_isHighlightInProgress: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether or not the button should be visible.
|
||||
*/
|
||||
_visible: boolean;
|
||||
|
||||
/**
|
||||
* Redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class for the {@code AbstractHighlightButton} component.
|
||||
*/
|
||||
export default class AbstractHighlightButton<P extends IProps, S={}> extends Component<P, S> {
|
||||
/**
|
||||
* Initializes a new AbstractHighlightButton instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClick() {
|
||||
const { _disabled, _isHighlightInProgress, dispatch } = this.props;
|
||||
|
||||
if (_isHighlightInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_disabled) {
|
||||
dispatch(showNotification({
|
||||
descriptionKey: 'recording.highlightMomentDisabled',
|
||||
titleKey: 'recording.highlightMoment',
|
||||
uid: PROMPT_RECORDING_NOTIFICATION_ID,
|
||||
customActionNameKey: [ 'localRecording.start' ],
|
||||
customActionHandler: [ () => {
|
||||
dispatch(hideNotification(PROMPT_RECORDING_NOTIFICATION_ID));
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
dispatch(openDialog(StartRecordingDialog));
|
||||
}
|
||||
} ],
|
||||
appearance: NOTIFICATION_TYPE.NORMAL
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.MEDIUM));
|
||||
} else {
|
||||
dispatch(highlightMeetingMoment());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated
|
||||
* {@code AbstractHighlightButton}'s props.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _disabled: boolean,
|
||||
* _isHighlightInProgress: boolean,
|
||||
* _visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _abstractMapStateToProps(state: IReduxState) {
|
||||
const isRecordingRunning = isCloudRecordingRunning(state);
|
||||
const isButtonDisabled = isHighlightMeetingMomentDisabled(state);
|
||||
const { webhookProxyUrl } = state['features/base/config'];
|
||||
const _iAmVisitor = iAmVisitor(state);
|
||||
const {
|
||||
disabled: isRecordButtonDisabled,
|
||||
visible: isRecordButtonVisible
|
||||
} = getRecordButtonProps(state);
|
||||
|
||||
const canStartRecording = isRecordButtonVisible && !isRecordButtonDisabled;
|
||||
const _visible = Boolean((canStartRecording || isRecordingRunning) && Boolean(webhookProxyUrl) && !_iAmVisitor);
|
||||
|
||||
return {
|
||||
_disabled: !isRecordingRunning,
|
||||
_isHighlightInProgress: isButtonDisabled,
|
||||
_visible
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState } from '../../../app/types';
|
||||
import { IconRecord, IconStop } from '../../../base/icons/svg';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
|
||||
import { canStopRecording, getRecordButtonProps } from '../../functions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractRecordButton}.
|
||||
*/
|
||||
export interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* True if the button needs to be disabled.
|
||||
*/
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* True if there is a running active recording, false otherwise.
|
||||
*/
|
||||
_isRecordingRunning: boolean;
|
||||
|
||||
/**
|
||||
* The tooltip to display when hovering over the button.
|
||||
*/
|
||||
_tooltip?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstract implementation of a button for starting and stopping recording.
|
||||
*/
|
||||
export default class AbstractRecordButton<P extends IProps> extends AbstractButton<P> {
|
||||
override accessibilityLabel = 'dialog.startRecording';
|
||||
override toggledAccessibilityLabel = 'dialog.stopRecording';
|
||||
override icon = IconRecord;
|
||||
override label = 'dialog.startRecording';
|
||||
override toggledLabel = 'dialog.stopRecording';
|
||||
override toggledIcon = IconStop;
|
||||
|
||||
/**
|
||||
* Returns the tooltip that should be displayed when the button is disabled.
|
||||
*
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
override _getTooltip() {
|
||||
return this.props._tooltip ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which should be used
|
||||
* to handle the start recoding button being clicked / pressed.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHandleClick() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { _isRecordingRunning, dispatch } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent(
|
||||
'recording.button',
|
||||
{
|
||||
'is_recording': _isRecordingRunning,
|
||||
type: JitsiRecordingConstants.mode.FILE
|
||||
}));
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
this._onHandleClick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to be implemented by subclasses, which must return a
|
||||
* boolean value indicating if this button is disabled or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isDisabled() {
|
||||
return this.props._disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether this button is in toggled state or not.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
override _isToggled() {
|
||||
return this.props._isRecordingRunning;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code RecordButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _disabled: boolean,
|
||||
* _isRecordingRunning: boolean,
|
||||
* _tooltip: string,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
disabled: _disabled,
|
||||
tooltip: _tooltip,
|
||||
visible
|
||||
} = getRecordButtonProps(state);
|
||||
|
||||
return {
|
||||
_disabled,
|
||||
_isRecordingRunning: canStopRecording(state),
|
||||
_tooltip,
|
||||
visible
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createRecordingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { updateDropboxToken } from '../../../dropbox/actions';
|
||||
import { getDropboxData, getNewAccessToken, isEnabled as isDropboxEnabled } from '../../../dropbox/functions.any';
|
||||
import { showErrorNotification } from '../../../notifications/actions';
|
||||
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
|
||||
import { setSelectedRecordingService, startLocalVideoRecording } from '../../actions';
|
||||
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../constants';
|
||||
import { isRecordingSharingEnabled, shouldAutoTranscribeOnRecord, supportsLocalRecording } from '../../functions';
|
||||
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The app key for the dropbox authentication.
|
||||
*/
|
||||
_appKey: string;
|
||||
|
||||
/**
|
||||
* Requests transcribing when recording is turned on.
|
||||
*/
|
||||
_autoTranscribeOnRecord: boolean;
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* Whether subtitles should be displayed or not.
|
||||
*/
|
||||
_displaySubtitles?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show file recordings service, even if integrations
|
||||
* are enabled.
|
||||
*/
|
||||
_fileRecordingsServiceEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the possibility to share file recording with other people (e.g. Meeting participants), based on
|
||||
* the actual implementation on the backend.
|
||||
*/
|
||||
_fileRecordingsServiceSharingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* If true the dropbox integration is enabled, otherwise - disabled.
|
||||
*/
|
||||
_isDropboxEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not local recording is enabled.
|
||||
*/
|
||||
_localRecordingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The dropbox refresh token.
|
||||
*/
|
||||
_rToken: string;
|
||||
|
||||
/**
|
||||
* Whether the record audio / video option is enabled by default.
|
||||
*/
|
||||
_recordAudioAndVideo: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the local participant is screensharing.
|
||||
*/
|
||||
_screensharing: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the screenshot capture feature is enabled.
|
||||
*/
|
||||
_screenshotCaptureEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The selected language for subtitles.
|
||||
*/
|
||||
_subtitlesLanguage: string | null;
|
||||
|
||||
/**
|
||||
* The dropbox access token.
|
||||
*/
|
||||
_token: string;
|
||||
|
||||
/**
|
||||
* Access token's expiration date as UNIX timestamp.
|
||||
*/
|
||||
_tokenExpireDate?: number;
|
||||
|
||||
/**
|
||||
* The redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
navigation: any;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we have valid oauth token.
|
||||
*/
|
||||
isTokenValid: boolean;
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we are in process of validating the oauth token.
|
||||
*/
|
||||
isValidating: boolean;
|
||||
|
||||
/**
|
||||
* Whether the local recording should record just the local user streams.
|
||||
*/
|
||||
localRecordingOnlySelf: boolean;
|
||||
|
||||
/**
|
||||
* The currently selected recording service of type: RECORDING_TYPES.
|
||||
*/
|
||||
selectedRecordingService: string;
|
||||
|
||||
/**
|
||||
* True if the user requested the service to share the recording with others.
|
||||
*/
|
||||
sharingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* True if the user requested the service to record audio and video.
|
||||
*/
|
||||
shouldRecordAudioAndVideo: boolean;
|
||||
|
||||
/**
|
||||
* True if the user requested the service to record transcription.
|
||||
*/
|
||||
shouldRecordTranscription: boolean;
|
||||
|
||||
/**
|
||||
* Number of MiB of available space in user's Dropbox account.
|
||||
*/
|
||||
spaceLeft?: number;
|
||||
|
||||
/**
|
||||
* The display name of the user's Dropbox account.
|
||||
*/
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for the recording start dialog.
|
||||
*/
|
||||
class AbstractStartRecordingDialog extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new {@code StartRecordingDialog} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
this._onSelectedRecordingServiceChanged
|
||||
= this._onSelectedRecordingServiceChanged.bind(this);
|
||||
this._onSharingSettingChanged = this._onSharingSettingChanged.bind(this);
|
||||
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
|
||||
this._onLocalRecordingSelfChange = this._onLocalRecordingSelfChange.bind(this);
|
||||
this._onTranscriptionChange = this._onTranscriptionChange.bind(this);
|
||||
this._onRecordAudioAndVideoChange = this._onRecordAudioAndVideoChange.bind(this);
|
||||
|
||||
let selectedRecordingService = '';
|
||||
|
||||
// TODO: Potentially check if we need to handle changes of
|
||||
// _fileRecordingsServiceEnabled and _areIntegrationsEnabled()
|
||||
if (this.props._fileRecordingsServiceEnabled
|
||||
|| !this._areIntegrationsEnabled()) {
|
||||
selectedRecordingService = RECORDING_TYPES.JITSI_REC_SERVICE;
|
||||
} else if (this._areIntegrationsEnabled()) {
|
||||
if (props._localRecordingEnabled && supportsLocalRecording()) {
|
||||
selectedRecordingService = RECORDING_TYPES.LOCAL;
|
||||
} else {
|
||||
selectedRecordingService = RECORDING_TYPES.DROPBOX;
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
isTokenValid: false,
|
||||
isValidating: false,
|
||||
userName: undefined,
|
||||
sharingEnabled: true,
|
||||
shouldRecordAudioAndVideo: this.props._recordAudioAndVideo,
|
||||
shouldRecordTranscription: this.props._autoTranscribeOnRecord,
|
||||
spaceLeft: undefined,
|
||||
selectedRecordingService,
|
||||
localRecordingOnlySelf: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the oauth access token.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
if (typeof this.props._token !== 'undefined') {
|
||||
this._onTokenUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the oauth access token.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
if (this.props._token !== prevProps._token) {
|
||||
this._onTokenUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the integrations with third party services are enabled
|
||||
* and false otherwise.
|
||||
*
|
||||
* @returns {boolean} - True if the integrations with third party services
|
||||
* are enabled and false otherwise.
|
||||
*/
|
||||
_areIntegrationsEnabled() {
|
||||
return this.props._isDropboxEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle sharing setting change from the dialog.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSharingSettingChanged() {
|
||||
this.setState({
|
||||
sharingEnabled: !this.state.sharingEnabled
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback to handle local recording only self setting change.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLocalRecordingSelfChange() {
|
||||
this.setState({
|
||||
localRecordingOnlySelf: !this.state.localRecordingOnlySelf
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles selected recording service changes.
|
||||
*
|
||||
* @param {string} selectedRecordingService - The new selected recording
|
||||
* service.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSelectedRecordingServiceChanged(selectedRecordingService: string) {
|
||||
this.setState({ selectedRecordingService }, () => {
|
||||
this.props.dispatch(setSelectedRecordingService(selectedRecordingService));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles transcription switch change.
|
||||
*
|
||||
* @param {boolean} value - The new value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onTranscriptionChange(value: boolean) {
|
||||
this.setState({
|
||||
shouldRecordTranscription: value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles audio and video switch change.
|
||||
*
|
||||
* @param {boolean} value - The new value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRecordAudioAndVideoChange(value: boolean) {
|
||||
this.setState({
|
||||
shouldRecordAudioAndVideo: value
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the dropbox access token and fetches account information.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onTokenUpdated() {
|
||||
const { _appKey, _isDropboxEnabled, _token, _rToken, _tokenExpireDate, dispatch } = this.props;
|
||||
|
||||
if (!_isDropboxEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof _token === 'undefined') {
|
||||
this.setState({
|
||||
isTokenValid: false,
|
||||
isValidating: false
|
||||
});
|
||||
} else { // @ts-ignore
|
||||
if (_tokenExpireDate && Date.now() > new Date(_tokenExpireDate)) {
|
||||
getNewAccessToken(_appKey, _rToken)
|
||||
.then((resp: { expireDate: number; rToken: string; token: string; }) =>
|
||||
dispatch(updateDropboxToken(resp.token, resp.rToken, resp.expireDate)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
isTokenValid: false,
|
||||
isValidating: true
|
||||
});
|
||||
getDropboxData(_token, _appKey).then(data => {
|
||||
if (typeof data === 'undefined') {
|
||||
this.setState({
|
||||
isTokenValid: false,
|
||||
isValidating: false
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
isTokenValid: true,
|
||||
isValidating: false,
|
||||
...data
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a file recording session.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - True (to note that the modal should be closed).
|
||||
*/
|
||||
_onSubmit() {
|
||||
const {
|
||||
_appKey,
|
||||
_conference,
|
||||
_displaySubtitles,
|
||||
_isDropboxEnabled,
|
||||
_rToken,
|
||||
_subtitlesLanguage,
|
||||
_token,
|
||||
dispatch
|
||||
} = this.props;
|
||||
let appData;
|
||||
const attributes: {
|
||||
type?: string;
|
||||
} = {};
|
||||
|
||||
if (this.state.shouldRecordAudioAndVideo) {
|
||||
switch (this.state.selectedRecordingService) {
|
||||
case RECORDING_TYPES.DROPBOX: {
|
||||
if (_isDropboxEnabled && _token) {
|
||||
appData = JSON.stringify({
|
||||
'file_recording_metadata': {
|
||||
'upload_credentials': {
|
||||
'service_name': RECORDING_TYPES.DROPBOX,
|
||||
'token': _token,
|
||||
'r_token': _rToken,
|
||||
'app_key': _appKey
|
||||
}
|
||||
}
|
||||
});
|
||||
attributes.type = RECORDING_TYPES.DROPBOX;
|
||||
} else {
|
||||
dispatch(showErrorNotification({
|
||||
titleKey: 'dialog.noDropboxToken'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RECORDING_TYPES.JITSI_REC_SERVICE: {
|
||||
appData = JSON.stringify({
|
||||
'file_recording_metadata': {
|
||||
'share': this.state.sharingEnabled
|
||||
}
|
||||
});
|
||||
attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE;
|
||||
break;
|
||||
}
|
||||
case RECORDING_TYPES.LOCAL: {
|
||||
dispatch(startLocalVideoRecording(this.state.localRecordingOnlySelf));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
sendAnalytics(
|
||||
createRecordingDialogEvent('start', 'confirm.button', attributes)
|
||||
);
|
||||
|
||||
this._toggleScreenshotCapture();
|
||||
_conference?.startRecording({
|
||||
mode: JitsiRecordingConstants.mode.FILE,
|
||||
appData
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|
||||
&& this.state.shouldRecordTranscription) {
|
||||
dispatch(setRequestingSubtitles(true, _displaySubtitles, _subtitlesLanguage, true));
|
||||
}
|
||||
|
||||
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isTranscribingEnabled: this.state.shouldRecordTranscription
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture feature.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_toggleScreenshotCapture() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the platform specific dialog content.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderDialogContent: () => React.Component;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code StartRecordingDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {any} _ownProps - Component's own props.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState, _ownProps: any) {
|
||||
const {
|
||||
recordingService,
|
||||
dropbox = { appKey: undefined },
|
||||
localRecording,
|
||||
recordings = { recordAudioAndVideo: true }
|
||||
} = state['features/base/config'];
|
||||
const {
|
||||
_displaySubtitles,
|
||||
_language: _subtitlesLanguage
|
||||
} = state['features/subtitles'];
|
||||
|
||||
return {
|
||||
_appKey: dropbox.appKey ?? '',
|
||||
_autoTranscribeOnRecord: shouldAutoTranscribeOnRecord(state),
|
||||
_conference: state['features/base/conference'].conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingsServiceEnabled: recordingService?.enabled ?? false,
|
||||
_fileRecordingsServiceSharingEnabled: isRecordingSharingEnabled(state),
|
||||
_isDropboxEnabled: isDropboxEnabled(state),
|
||||
_localRecordingEnabled: !localRecording?.disable,
|
||||
_rToken: state['features/dropbox'].rToken ?? '',
|
||||
_recordAudioAndVideo: recordings?.recordAudioAndVideo ?? true,
|
||||
_subtitlesLanguage,
|
||||
_tokenExpireDate: state['features/dropbox'].expireDate,
|
||||
_token: state['features/dropbox'].token ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export default AbstractStartRecordingDialog;
|
||||
@@ -0,0 +1,432 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createRecordingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
|
||||
import { _abstractMapStateToProps } from '../../../base/dialog/functions';
|
||||
import { MEET_FEATURES } from '../../../base/jwt/constants';
|
||||
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
|
||||
import { authorizeDropbox, updateDropboxToken } from '../../../dropbox/actions';
|
||||
import { isVpaasMeeting } from '../../../jaas/functions';
|
||||
import { canAddTranscriber } from '../../../transcribing/functions';
|
||||
import { RECORDING_TYPES } from '../../constants';
|
||||
import { supportsLocalRecording } from '../../functions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractStartRecordingDialogContent}.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Whether the local participant can start transcribing.
|
||||
*/
|
||||
_canStartTranscribing: boolean;
|
||||
|
||||
/**
|
||||
* Style of the dialogs feature.
|
||||
*/
|
||||
_dialogStyles: any;
|
||||
|
||||
/**
|
||||
* Whether to hide the storage warning or not.
|
||||
*/
|
||||
_hideStorageWarning: boolean;
|
||||
|
||||
/**
|
||||
* Whether local recording is available or not.
|
||||
*/
|
||||
_localRecordingAvailable: boolean;
|
||||
|
||||
/**
|
||||
* Whether local recording is enabled or not.
|
||||
*/
|
||||
_localRecordingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether we won't notify the other participants about the recording.
|
||||
*/
|
||||
_localRecordingNoNotification: boolean;
|
||||
|
||||
/**
|
||||
* Whether self local recording is enabled or not.
|
||||
*/
|
||||
_localRecordingSelfEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to render recording.
|
||||
*/
|
||||
_renderRecording: boolean;
|
||||
|
||||
/**
|
||||
* The color-schemed stylesheet of this component.
|
||||
*/
|
||||
_styles: any;
|
||||
|
||||
/**
|
||||
* The redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Whether to show file recordings service, even if integrations
|
||||
* are enabled.
|
||||
*/
|
||||
fileRecordingsServiceEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the possibility to share file recording with other people (e.g. Meeting participants), based on
|
||||
* the actual implementation on the backend.
|
||||
*/
|
||||
fileRecordingsServiceSharingEnabled: boolean;
|
||||
|
||||
/**
|
||||
* If true the content related to the integrations will be shown.
|
||||
*/
|
||||
integrationsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we have valid oauth token.
|
||||
*/
|
||||
isTokenValid: boolean;
|
||||
|
||||
/**
|
||||
* <tt>true</tt> if we are in process of validating the oauth token.
|
||||
*/
|
||||
isValidating: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the current meeting is a vpaas one.
|
||||
*/
|
||||
isVpaas: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not we should only record the local streams.
|
||||
*/
|
||||
localRecordingOnlySelf?: boolean;
|
||||
|
||||
/**
|
||||
* The function will be called when there are changes related to the
|
||||
* switches.
|
||||
*/
|
||||
onChange: Function;
|
||||
|
||||
/**
|
||||
* Callback to change the local recording only self setting.
|
||||
*/
|
||||
onLocalRecordingSelfChange?: () => void;
|
||||
|
||||
/**
|
||||
* Callback to change the audio and video recording setting.
|
||||
*/
|
||||
onRecordAudioAndVideoChange: Function;
|
||||
|
||||
/**
|
||||
* Callback to be invoked on sharing setting change.
|
||||
*/
|
||||
onSharingSettingChanged: () => void;
|
||||
|
||||
/**
|
||||
* Callback to change the transcription recording setting.
|
||||
*/
|
||||
onTranscriptionChange: Function;
|
||||
|
||||
/**
|
||||
* The currently selected recording service of type: RECORDING_TYPES.
|
||||
*/
|
||||
selectedRecordingService: string | null;
|
||||
|
||||
/**
|
||||
* Boolean to set file recording sharing on or off.
|
||||
*/
|
||||
sharingSetting: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the audio and video related content.
|
||||
*/
|
||||
shouldRecordAudioAndVideo: boolean;
|
||||
|
||||
/**
|
||||
* Whether to show the transcription related content.
|
||||
*/
|
||||
shouldRecordTranscription: boolean;
|
||||
|
||||
/**
|
||||
* Number of MiB of available space in user's Dropbox account.
|
||||
*/
|
||||
spaceLeft?: number;
|
||||
|
||||
/**
|
||||
* The display name of the user's Dropbox account.
|
||||
*/
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
|
||||
/**
|
||||
* Whether to show the advanced options or not.
|
||||
*/
|
||||
showAdvancedOptions: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to start a recording session.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class AbstractStartRecordingDialogContent extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new {@code AbstractStartRecordingDialogContent} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler; it bounds once for every instance.
|
||||
this._onSignIn = this._onSignIn.bind(this);
|
||||
this._onSignOut = this._onSignOut.bind(this);
|
||||
this._onDropboxSwitchChange = this._onDropboxSwitchChange.bind(this);
|
||||
this._onRecordingServiceSwitchChange = this._onRecordingServiceSwitchChange.bind(this);
|
||||
this._onLocalRecordingSwitchChange = this._onLocalRecordingSwitchChange.bind(this);
|
||||
this._onTranscriptionSwitchChange = this._onTranscriptionSwitchChange.bind(this);
|
||||
this._onRecordAudioAndVideoSwitchChange = this._onRecordAudioAndVideoSwitchChange.bind(this);
|
||||
this._onToggleShowOptions = this._onToggleShowOptions.bind(this);
|
||||
|
||||
this.state = {
|
||||
showAdvancedOptions: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the Component's componentDidMount method.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidMount() {
|
||||
if (!this._shouldRenderNoIntegrationsContent()
|
||||
&& !this._shouldRenderIntegrationsContent()
|
||||
&& !this._shouldRenderFileSharingContent()) {
|
||||
this._onLocalRecordingSwitchChange();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements {@code Component#componentDidUpdate}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
// Auto sign-out when the use chooses another recording service.
|
||||
if (prevProps.selectedRecordingService === RECORDING_TYPES.DROPBOX
|
||||
&& this.props.selectedRecordingService !== RECORDING_TYPES.DROPBOX && this.props.isTokenValid) {
|
||||
this._onSignOut();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the advanced options should be rendered.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_onToggleShowOptions() {
|
||||
this.setState({ showAdvancedOptions: !this.state.showAdvancedOptions });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the file sharing content should be rendered or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderFileSharingContent() {
|
||||
const {
|
||||
fileRecordingsServiceEnabled,
|
||||
fileRecordingsServiceSharingEnabled,
|
||||
isVpaas,
|
||||
selectedRecordingService
|
||||
} = this.props;
|
||||
|
||||
if (!fileRecordingsServiceEnabled
|
||||
|| !fileRecordingsServiceSharingEnabled
|
||||
|| isVpaas
|
||||
|| selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the save transcription content should be rendered or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_canStartTranscribing() {
|
||||
return this.props._canStartTranscribing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the no integrations content should be rendered or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderNoIntegrationsContent() {
|
||||
// show the non integrations part only if fileRecordingsServiceEnabled
|
||||
// is enabled
|
||||
if (!this.props.fileRecordingsServiceEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the integrations content should be rendered or not.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_shouldRenderIntegrationsContent() {
|
||||
if (!this.props.integrationsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for transcription switch change.
|
||||
*
|
||||
* @param {boolean} value - The new value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onTranscriptionSwitchChange(value: boolean | undefined) {
|
||||
this.props.onTranscriptionChange(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for audio and video switch change.
|
||||
*
|
||||
* @param {boolean} value - The new value.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRecordAudioAndVideoSwitchChange(value: boolean | undefined) {
|
||||
this.props.onRecordAudioAndVideoChange(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for onValueChange events from the Switch component.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRecordingServiceSwitchChange() {
|
||||
const {
|
||||
onChange,
|
||||
selectedRecordingService
|
||||
} = this.props;
|
||||
|
||||
// act like group, cannot toggle off
|
||||
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(RECORDING_TYPES.JITSI_REC_SERVICE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for onValueChange events from the Switch component.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDropboxSwitchChange() {
|
||||
const {
|
||||
isTokenValid,
|
||||
onChange,
|
||||
selectedRecordingService
|
||||
} = this.props;
|
||||
|
||||
// act like group, cannot toggle off
|
||||
if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(RECORDING_TYPES.DROPBOX);
|
||||
|
||||
if (!isTokenValid) {
|
||||
this._onSignIn();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for onValueChange events from the Switch component.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLocalRecordingSwitchChange() {
|
||||
const {
|
||||
_localRecordingAvailable,
|
||||
onChange,
|
||||
selectedRecordingService
|
||||
} = this.props;
|
||||
|
||||
if (!_localRecordingAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// act like group, cannot toggle off
|
||||
if (selectedRecordingService
|
||||
=== RECORDING_TYPES.LOCAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(RECORDING_TYPES.LOCAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sings in a user.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSignIn() {
|
||||
sendAnalytics(createRecordingDialogEvent('start', 'signIn.button'));
|
||||
this.props.dispatch(authorizeDropbox());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sings out an user from dropbox.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSignOut() {
|
||||
sendAnalytics(createRecordingDialogEvent('start', 'signOut.button'));
|
||||
this.props.dispatch(updateDropboxToken());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps part of the redux state to the props of this component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState) {
|
||||
const { localRecording, recordingService } = state['features/base/config'];
|
||||
const _localRecordingAvailable = !localRecording?.disable && supportsLocalRecording();
|
||||
|
||||
return {
|
||||
..._abstractMapStateToProps(state),
|
||||
isVpaas: isVpaasMeeting(state),
|
||||
_canStartTranscribing: canAddTranscriber(state),
|
||||
_hideStorageWarning: Boolean(recordingService?.hideStorageWarning),
|
||||
_renderRecording: isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false),
|
||||
_localRecordingAvailable,
|
||||
_localRecordingEnabled: !localRecording?.disable,
|
||||
_localRecordingSelfEnabled: !localRecording?.disableSelfRecording,
|
||||
_localRecordingNoNotification: !localRecording?.notifyAllParticipants,
|
||||
_styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
|
||||
};
|
||||
}
|
||||
|
||||
export default AbstractStartRecordingDialogContent;
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
|
||||
import { createRecordingDialogEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { IJitsiConference } from '../../../base/conference/reducer';
|
||||
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
|
||||
import { setVideoMuted } from '../../../base/media/actions';
|
||||
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
|
||||
import { stopLocalVideoRecording } from '../../actions';
|
||||
import { RECORDING_METADATA_ID } from '../../constants';
|
||||
import { getActiveSession } from '../../functions';
|
||||
import { ISessionData } from '../../reducer';
|
||||
|
||||
import LocalRecordingManager from './LocalRecordingManager';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link AbstractStopRecordingDialog}.
|
||||
*/
|
||||
export interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The {@code JitsiConference} for the current conference.
|
||||
*/
|
||||
_conference?: IJitsiConference;
|
||||
|
||||
/**
|
||||
* Whether subtitles should be displayed or not.
|
||||
*/
|
||||
_displaySubtitles?: boolean;
|
||||
|
||||
/**
|
||||
* The redux representation of the recording session to be stopped.
|
||||
*/
|
||||
_fileRecordingSession?: ISessionData;
|
||||
|
||||
/**
|
||||
* Whether the recording is a local recording or not.
|
||||
*/
|
||||
_localRecording: boolean;
|
||||
|
||||
/**
|
||||
* The selected language for subtitles.
|
||||
*/
|
||||
_subtitlesLanguage: string | null;
|
||||
|
||||
/**
|
||||
* The redux dispatch function.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The user trying to stop the video while local recording is running.
|
||||
*/
|
||||
localRecordingVideoStop?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract React Component for getting confirmation to stop a file recording
|
||||
* session in progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
export default class AbstractStopRecordingDialog<P extends IProps>
|
||||
extends Component<P> {
|
||||
/**
|
||||
* Initializes a new {@code AbstrStopRecordingDialog} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: P) {
|
||||
super(props);
|
||||
|
||||
// Bind event handler so it is only bound once for every instance.
|
||||
this._onSubmit = this._onSubmit.bind(this);
|
||||
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the recording session.
|
||||
*
|
||||
* @private
|
||||
* @returns {boolean} - True (to note that the modal should be closed).
|
||||
*/
|
||||
_onSubmit() {
|
||||
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
|
||||
|
||||
const {
|
||||
_conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingSession,
|
||||
_localRecording,
|
||||
_subtitlesLanguage,
|
||||
dispatch,
|
||||
localRecordingVideoStop
|
||||
} = this.props;
|
||||
|
||||
if (_localRecording) {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
if (localRecordingVideoStop) {
|
||||
dispatch(setVideoMuted(true));
|
||||
}
|
||||
} else if (_fileRecordingSession) {
|
||||
_conference?.stopRecording(_fileRecordingSession.id);
|
||||
this._toggleScreenshotCapture();
|
||||
}
|
||||
|
||||
// TODO: this should be an action in transcribing. -saghul
|
||||
this.props.dispatch(setRequestingSubtitles(Boolean(_displaySubtitles), _displaySubtitles, _subtitlesLanguage));
|
||||
|
||||
this.props._conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
|
||||
isTranscribingEnabled: false
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture feature.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_toggleScreenshotCapture() {
|
||||
// To be implemented by subclass.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code StopRecordingDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {IProps}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const {
|
||||
_displaySubtitles,
|
||||
_language: _subtitlesLanguage
|
||||
} = state['features/subtitles'];
|
||||
|
||||
return {
|
||||
_conference: state['features/base/conference'].conference,
|
||||
_displaySubtitles,
|
||||
_fileRecordingSession:
|
||||
getActiveSession(state, JitsiRecordingConstants.mode.FILE),
|
||||
_localRecording: LocalRecordingManager.isRecordingLocally(),
|
||||
_subtitlesLanguage
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IStore } from '../../../app/types';
|
||||
|
||||
interface ILocalRecordingManager {
|
||||
addAudioTrackToLocalRecording: (track: any) => void;
|
||||
isRecordingLocally: () => boolean;
|
||||
isSupported: () => boolean;
|
||||
selfRecording: {
|
||||
on: boolean;
|
||||
withVideo: boolean;
|
||||
};
|
||||
startLocalRecording: (store: IStore, onlySelf: boolean) => Promise<void>;
|
||||
stopLocalRecording: () => void;
|
||||
}
|
||||
|
||||
const LocalRecordingManager: ILocalRecordingManager = {
|
||||
selfRecording: {
|
||||
on: false,
|
||||
withVideo: false
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds audio track to the recording stream.
|
||||
*
|
||||
* @param {any} track - Track to be added,.
|
||||
* @returns {void}
|
||||
*/
|
||||
addAudioTrackToLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
* @returns {void}
|
||||
* */
|
||||
stopLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Starts a local recording.
|
||||
*
|
||||
* @param {IStore} store - The Redux store.
|
||||
* @returns {void}
|
||||
*/
|
||||
async startLocalRecording() { }, // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
/**
|
||||
* Whether or not local recording is supported.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not we're currently recording locally.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRecordingLocally() {
|
||||
return false;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
export default LocalRecordingManager;
|
||||
@@ -0,0 +1,377 @@
|
||||
// @ts-ignore
|
||||
import * as ebml from 'ts-ebml/dist/EBML.min.js';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
import { IStore } from '../../../app/types';
|
||||
import { getRoomName } from '../../../base/conference/functions';
|
||||
import { isMobileBrowser } from '../../../base/environment/utils';
|
||||
import { browser } from '../../../base/lib-jitsi-meet';
|
||||
import { MEDIA_TYPE } from '../../../base/media/constants';
|
||||
import { getLocalTrack, getTrackState } from '../../../base/tracks/functions';
|
||||
import { isEmbedded } from '../../../base/util/embedUtils';
|
||||
import { stopLocalVideoRecording } from '../../actions.any';
|
||||
import logger from '../../logger';
|
||||
|
||||
interface ISelfRecording {
|
||||
on: boolean;
|
||||
withVideo: boolean;
|
||||
}
|
||||
|
||||
interface ILocalRecordingManager {
|
||||
addAudioTrackToLocalRecording: (track: MediaStreamTrack) => void;
|
||||
audioContext: AudioContext | undefined;
|
||||
audioDestination: MediaStreamAudioDestinationNode | undefined;
|
||||
fileHandle: FileSystemFileHandle | undefined;
|
||||
firstChunk: Blob | undefined;
|
||||
getFilename: () => string;
|
||||
initializeAudioMixer: () => void;
|
||||
isRecordingLocally: () => boolean;
|
||||
isSupported: () => boolean;
|
||||
mediaType: string;
|
||||
mixAudioStream: (stream: MediaStream) => void;
|
||||
recorder: MediaRecorder | undefined;
|
||||
roomName: string;
|
||||
selfRecording: ISelfRecording;
|
||||
startLocalRecording: (store: IStore, onlySelf: boolean) => Promise<void>;
|
||||
startTime: number | undefined;
|
||||
stopLocalRecording: () => void;
|
||||
stream: MediaStream | undefined;
|
||||
writableStream: FileSystemWritableFileStream | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a lot of trial and error, this is the preferred media type for
|
||||
* local recording. It is the only one that works across all platforms, with the
|
||||
* only caveat being that the resulting file wouldn't be seekable.
|
||||
*
|
||||
* We solve that by fixing the first Blob in order to reserve the space for the
|
||||
* corrected metadata, and after the recording is done, we do it again, this time with
|
||||
* the real duration, and overwrite the first part of the file.
|
||||
*/
|
||||
const PREFERRED_MEDIA_TYPE = 'video/webm;codecs=vp8,opus';
|
||||
|
||||
const VIDEO_BIT_RATE = 2500000; // 2.5Mbps in bits
|
||||
|
||||
|
||||
const LocalRecordingManager: ILocalRecordingManager = {
|
||||
recorder: undefined,
|
||||
stream: undefined,
|
||||
audioContext: undefined,
|
||||
audioDestination: undefined,
|
||||
roomName: '',
|
||||
selfRecording: {
|
||||
on: false,
|
||||
withVideo: false
|
||||
},
|
||||
firstChunk: undefined,
|
||||
fileHandle: undefined,
|
||||
startTime: undefined,
|
||||
writableStream: undefined,
|
||||
|
||||
get mediaType() {
|
||||
if (this.selfRecording.on && !this.selfRecording.withVideo) {
|
||||
return 'audio/webm;';
|
||||
}
|
||||
|
||||
return PREFERRED_MEDIA_TYPE;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initializes audio context used for mixing audio tracks.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
initializeAudioMixer() {
|
||||
this.audioContext = new AudioContext();
|
||||
this.audioDestination = this.audioContext.createMediaStreamDestination();
|
||||
},
|
||||
|
||||
/**
|
||||
* Mixes multiple audio tracks to the destination media stream.
|
||||
*
|
||||
* @param {MediaStream} stream - The stream to mix.
|
||||
* @returns {void}
|
||||
* */
|
||||
mixAudioStream(stream) {
|
||||
if (stream.getAudioTracks().length > 0 && this.audioDestination) {
|
||||
this.audioContext?.createMediaStreamSource(stream).connect(this.audioDestination);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds audio track to the recording stream.
|
||||
*
|
||||
* @param {MediaStreamTrack} track - The track to be added.
|
||||
* @returns {void}
|
||||
*/
|
||||
addAudioTrackToLocalRecording(track) {
|
||||
if (this.selfRecording.on) {
|
||||
return;
|
||||
}
|
||||
if (track) {
|
||||
const stream = new MediaStream([ track ]);
|
||||
|
||||
this.mixAudioStream(stream);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns a filename based ono the Jitsi room name in the URL and timestamp.
|
||||
*
|
||||
* @returns {string}
|
||||
* */
|
||||
getFilename() {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
|
||||
return `${this.roomName}_${timestamp}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stops local recording.
|
||||
*
|
||||
* @returns {void}
|
||||
* */
|
||||
stopLocalRecording() {
|
||||
this.recorder?.stop();
|
||||
},
|
||||
|
||||
/**
|
||||
* Starts a local recording.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @param {boolean} onlySelf - Whether to record only self streams.
|
||||
* @returns {void}
|
||||
*/
|
||||
async startLocalRecording(store, onlySelf) {
|
||||
const { dispatch, getState } = store;
|
||||
|
||||
this.roomName = getRoomName(getState()) ?? '';
|
||||
|
||||
// Get a handle to the file we are going to write.
|
||||
const options = {
|
||||
startIn: 'downloads',
|
||||
suggestedName: `${this.getFilename()}.webm`,
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
this.fileHandle = await window.showSaveFilePicker(options);
|
||||
this.writableStream = await this.fileHandle?.createWritable();
|
||||
|
||||
const supportsCaptureHandle = !isEmbedded();
|
||||
const tabId = uuidV4();
|
||||
|
||||
this.selfRecording.on = onlySelf;
|
||||
let gdmStream: MediaStream = new MediaStream();
|
||||
const tracks = getTrackState(getState());
|
||||
|
||||
if (onlySelf) {
|
||||
const audioTrack: MediaStreamTrack | undefined = getLocalTrack(tracks, MEDIA_TYPE.AUDIO)?.jitsiTrack?.track;
|
||||
let videoTrack: MediaStreamTrack | undefined = getLocalTrack(tracks, MEDIA_TYPE.VIDEO)?.jitsiTrack?.track;
|
||||
|
||||
if (videoTrack && videoTrack.readyState !== 'live') {
|
||||
videoTrack = undefined;
|
||||
}
|
||||
|
||||
if (!audioTrack && !videoTrack) {
|
||||
throw new Error('NoLocalStreams');
|
||||
}
|
||||
|
||||
this.selfRecording.withVideo = Boolean(videoTrack);
|
||||
const localTracks: MediaStreamTrack[] = [];
|
||||
|
||||
audioTrack && localTracks.push(audioTrack);
|
||||
videoTrack && localTracks.push(videoTrack);
|
||||
this.stream = new MediaStream(localTracks);
|
||||
} else {
|
||||
if (supportsCaptureHandle) {
|
||||
// @ts-ignore
|
||||
navigator.mediaDevices.setCaptureHandleConfig({
|
||||
handle: `JitsiMeet-${tabId}`,
|
||||
permittedOrigins: [ '*' ]
|
||||
});
|
||||
}
|
||||
|
||||
gdmStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
displaySurface: 'browser',
|
||||
frameRate: 30
|
||||
},
|
||||
audio: {
|
||||
autoGainControl: false,
|
||||
channelCount: 2,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
// @ts-ignore
|
||||
restrictOwnAudio: false,
|
||||
// @ts-ignore
|
||||
suppressLocalAudioPlayback: false,
|
||||
},
|
||||
// @ts-ignore
|
||||
preferCurrentTab: true,
|
||||
surfaceSwitching: 'exclude'
|
||||
});
|
||||
|
||||
const gdmVideoTrack = gdmStream.getVideoTracks()[0];
|
||||
const isBrowser = gdmVideoTrack.getSettings().displaySurface === 'browser';
|
||||
const matchesHandle = (supportsCaptureHandle // @ts-ignore
|
||||
&& gdmVideoTrack.getCaptureHandle()?.handle === `JitsiMeet-${tabId}`);
|
||||
|
||||
if (!isBrowser || !matchesHandle) {
|
||||
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||
throw new Error('WrongSurfaceSelected');
|
||||
}
|
||||
|
||||
this.initializeAudioMixer();
|
||||
|
||||
const gdmAudioTrack = gdmStream.getAudioTracks()[0];
|
||||
|
||||
if (!gdmAudioTrack) {
|
||||
throw new Error('NoAudioTrackFound');
|
||||
}
|
||||
|
||||
this.addAudioTrackToLocalRecording(gdmAudioTrack);
|
||||
|
||||
const localAudioTrack = getLocalTrack(tracks, MEDIA_TYPE.AUDIO)?.jitsiTrack?.track;
|
||||
|
||||
if (localAudioTrack) {
|
||||
this.addAudioTrackToLocalRecording(localAudioTrack);
|
||||
}
|
||||
|
||||
this.stream = new MediaStream([
|
||||
...this.audioDestination?.stream.getAudioTracks() || [],
|
||||
gdmVideoTrack
|
||||
]);
|
||||
}
|
||||
|
||||
this.recorder = new MediaRecorder(this.stream, {
|
||||
// @ts-ignore
|
||||
audioBitrateMode: 'constant',
|
||||
mimeType: this.mediaType,
|
||||
videoBitsPerSecond: VIDEO_BIT_RATE
|
||||
});
|
||||
|
||||
this.recorder.addEventListener('dataavailable', async e => {
|
||||
if (this.recorder && e.data && e.data.size > 0) {
|
||||
let data = e.data;
|
||||
|
||||
if (!this.firstChunk) {
|
||||
this.firstChunk = data = await fixDuration(data, 864000000); // Reserve 24h.
|
||||
}
|
||||
|
||||
await this.writableStream?.write(data);
|
||||
}
|
||||
});
|
||||
|
||||
this.recorder.addEventListener('start', () => {
|
||||
this.startTime = Date.now();
|
||||
});
|
||||
|
||||
this.recorder.addEventListener('stop', async () => {
|
||||
const duration = Date.now() - this.startTime!;
|
||||
|
||||
this.stream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||
gdmStream?.getTracks().forEach((track: MediaStreamTrack) => track.stop());
|
||||
|
||||
// The stop event is emitted when the recorder is done, and _after_ the last buffered
|
||||
// data has been handed over to the dataavailable event.
|
||||
this.recorder = undefined;
|
||||
this.audioContext = undefined;
|
||||
this.audioDestination = undefined;
|
||||
this.startTime = undefined;
|
||||
|
||||
if (this.writableStream) {
|
||||
try {
|
||||
if (this.firstChunk) {
|
||||
await this.writableStream.seek(0);
|
||||
await this.writableStream.write(await fixDuration(this.firstChunk!, duration));
|
||||
}
|
||||
await this.writableStream.close();
|
||||
} catch (e) {
|
||||
logger.error('Error while writing to the local recording file', e);
|
||||
} finally {
|
||||
this.firstChunk = undefined;
|
||||
this.fileHandle = undefined;
|
||||
this.writableStream = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!onlySelf) {
|
||||
gdmStream?.addEventListener('inactive', () => {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
});
|
||||
|
||||
this.stream.addEventListener('inactive', () => {
|
||||
dispatch(stopLocalVideoRecording());
|
||||
});
|
||||
}
|
||||
|
||||
this.recorder.start(5000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not local recording is supported.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isSupported() {
|
||||
return browser.isChromiumBased()
|
||||
&& !browser.isElectron()
|
||||
&& !browser.isReactNative()
|
||||
&& !isMobileBrowser()
|
||||
|
||||
// @ts-expect-error
|
||||
&& Boolean(navigator.mediaDevices.setCaptureHandleConfig)
|
||||
// @ts-expect-error
|
||||
&& typeof window.showSaveFilePicker !== 'undefined'
|
||||
&& MediaRecorder.isTypeSupported(PREFERRED_MEDIA_TYPE);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether or not we're currently recording locally.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isRecordingLocally() {
|
||||
return Boolean(this.recorder);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixes the duration in the WebM container metadata.
|
||||
* Note: cues are omitted.
|
||||
*
|
||||
* @param {Blob} data - The first Blob of WebM data.
|
||||
* @param {number} duration - Actual duration of the video in milliseconds.
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
async function fixDuration(data: Blob, duration: number): Promise<Blob> {
|
||||
const decoder = new ebml.Decoder();
|
||||
const reader = new ebml.Reader();
|
||||
|
||||
reader.logging = false;
|
||||
reader.drop_default_duration = false;
|
||||
|
||||
const dataBuf = await data.arrayBuffer();
|
||||
const elms = decoder.decode(dataBuf);
|
||||
|
||||
for (const elm of elms) {
|
||||
reader.read(elm);
|
||||
}
|
||||
reader.stop();
|
||||
|
||||
const newMetadataBuf = ebml.tools.makeMetadataSeekable(
|
||||
reader.metadatas,
|
||||
duration,
|
||||
[] // No cues
|
||||
);
|
||||
|
||||
const body = new Uint8Array(dataBuf).subarray(reader.metadataSize);
|
||||
|
||||
// @ts-ignore
|
||||
return new Blob([ newMetadataBuf, body ], { type: data.type });
|
||||
}
|
||||
|
||||
export default LocalRecordingManager;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as StartRecordingDialog } from './native/StartRecordingDialog';
|
||||
export { default as RecordingConsentDialog } from './native/RecordingConsentDialog';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as StartRecordingDialog } from './web/StartRecordingDialog';
|
||||
export { default as RecordingConsentDialog } from './web/RecordingConsentDialog';
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { IconHighlight } from '../../../../base/icons/svg';
|
||||
import Label from '../../../../base/label/components/native/Label';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme';
|
||||
import AbstractHighlightButton, {
|
||||
IProps as AbstractProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractHighlightButton';
|
||||
import styles from '../styles.native';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* Flag controlling visibility of the component.
|
||||
*/
|
||||
_visible: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* React {@code Component} responsible for displaying an action that
|
||||
* allows users to highlight a meeting moment.
|
||||
*/
|
||||
export class HighlightButton extends AbstractHighlightButton<IProps> {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_disabled,
|
||||
_visible,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!_visible || _disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
icon = { IconHighlight }
|
||||
iconColor = { BaseTheme.palette.field01 }
|
||||
style = { styles.highlightButton }
|
||||
text = { t('recording.highlight') }
|
||||
textStyle = { styles.highlightButtonText } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_abstractMapStateToProps)(HighlightButton));
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StyleProp, Text, TextStyle, View, ViewStyle } from 'react-native';
|
||||
import { batch, useDispatch } from 'react-redux';
|
||||
|
||||
import { hideSheet } from '../../../../base/dialog/actions';
|
||||
import BottomSheet from '../../../../base/dialog/components/native/BottomSheet';
|
||||
import Button from '../../../../base/ui/components/native/Button';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
|
||||
import { highlightMeetingMoment } from '../../../actions.any';
|
||||
import styles from '../styles.native';
|
||||
|
||||
const HighlightDialog = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const closeDialog = useCallback(() => dispatch(hideSheet()), [ dispatch ]);
|
||||
const highlightMoment = useCallback(() => {
|
||||
batch(() => {
|
||||
dispatch(highlightMeetingMoment());
|
||||
dispatch(hideSheet());
|
||||
});
|
||||
}, [ dispatch ]);
|
||||
|
||||
return (
|
||||
<BottomSheet>
|
||||
<View style = { styles.highlightDialog as StyleProp<ViewStyle> }>
|
||||
<Text style = { styles.highlightDialogHeading as StyleProp<TextStyle> }>
|
||||
{ `${t('recording.highlightMoment')}?` }
|
||||
</Text>
|
||||
<Text style = { styles.highlightDialogText as StyleProp<TextStyle> }>
|
||||
{ t('recording.highlightMomentSucessDescription') }
|
||||
</Text>
|
||||
<View style = { styles.highlightDialogButtonsContainer as StyleProp<ViewStyle> } >
|
||||
<Button
|
||||
accessibilityLabel = 'dialog.Cancel'
|
||||
labelKey = 'dialog.Cancel'
|
||||
onClick = { closeDialog }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
<View style = { styles.highlightDialogButtonsSpace as StyleProp<ViewStyle> } />
|
||||
<Button
|
||||
accessibilityLabel = 'recording.highlight'
|
||||
labelKey = 'recording.highlight'
|
||||
onClick = { highlightMoment }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default HighlightDialog;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Platform } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { IOS_RECORDING_ENABLED, RECORDING_ENABLED } from '../../../../base/flags/constants';
|
||||
import { getFeatureFlag } from '../../../../base/flags/functions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { navigate }
|
||||
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { screen } from '../../../../mobile/navigation/routes';
|
||||
import {
|
||||
IProps, _mapStateToProps as abstractStartLiveStreamDialogMapStateToProps
|
||||
} from '../../LiveStream/AbstractStartLiveStreamDialog';
|
||||
import AbstractRecordButton, {
|
||||
IProps as AbstractProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractRecordButton';
|
||||
|
||||
import StopRecordingDialog from './StopRecordingDialog';
|
||||
|
||||
type Props = IProps & AbstractProps;
|
||||
|
||||
/**
|
||||
* Button for opening a screen where a recording session can be started.
|
||||
*/
|
||||
class RecordButton extends AbstractRecordButton<Props> {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHandleClick() {
|
||||
const { _isRecordingRunning, dispatch } = this.props;
|
||||
|
||||
if (_isRecordingRunning) {
|
||||
dispatch(openDialog(StopRecordingDialog));
|
||||
} else {
|
||||
navigate(screen.conference.recording);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for this component.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @param {Object} ownProps - The properties explicitly passed to the component
|
||||
* instance.
|
||||
* @private
|
||||
* @returns {Props}
|
||||
*/
|
||||
export function mapStateToProps(state: IReduxState) {
|
||||
const enabled = getFeatureFlag(state, RECORDING_ENABLED, true);
|
||||
const iosEnabled = Platform.OS !== 'ios' || getFeatureFlag(state, IOS_RECORDING_ENABLED, false);
|
||||
const abstractProps = _abstractMapStateToProps(state);
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
...abstractStartLiveStreamDialogMapStateToProps(state),
|
||||
visible: Boolean(enabled && iosEnabled && abstractProps.visible)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(RecordButton));
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Dialog from 'react-native-dialog';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { setAudioMuted, setAudioUnmutePermissions, setVideoMuted, setVideoUnmutePermissions } from '../../../../base/media/actions';
|
||||
import { VIDEO_MUTISM_AUTHORITY } from '../../../../base/media/constants';
|
||||
import Link from '../../../../base/react/components/native/Link';
|
||||
import styles from '../styles.native';
|
||||
|
||||
/**
|
||||
* Component that renders the dialog for explicit consent for recordings.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export default function RecordingConsentDialog() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { recordings } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { consentLearnMoreLink } = recordings ?? {};
|
||||
|
||||
|
||||
const consent = useCallback(() => {
|
||||
dispatch(setAudioUnmutePermissions(false, true));
|
||||
dispatch(setVideoUnmutePermissions(false, true));
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const consentAndUnmute = useCallback(() => {
|
||||
dispatch(setAudioUnmutePermissions(false, true));
|
||||
dispatch(setVideoUnmutePermissions(false, true));
|
||||
dispatch(setAudioMuted(false, true));
|
||||
dispatch(setVideoMuted(false, VIDEO_MUTISM_AUTHORITY.USER, true));
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
backLabel = { 'dialog.UnderstandAndUnmute' }
|
||||
confirmLabel = { 'dialog.Understand' }
|
||||
isBackHidden = { false }
|
||||
isCancelHidden = { true }
|
||||
onBack = { consentAndUnmute }
|
||||
onSubmit = { consent }
|
||||
title = { 'dialog.recordingInProgressTitle' }
|
||||
verticalButtons = { true }>
|
||||
<Dialog.Description>
|
||||
{t('dialog.recordingInProgressDescriptionFirstHalf')}
|
||||
{consentLearnMoreLink && (
|
||||
<Link
|
||||
style = { styles.learnMoreLink }
|
||||
url = { consentLearnMoreLink }>
|
||||
{`(${t('dialog.learnMore')})`}
|
||||
</Link>
|
||||
)}
|
||||
{t('dialog.recordingInProgressDescriptionSecondHalf')}
|
||||
</Dialog.Description>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
|
||||
import HeaderNavigationButton
|
||||
from '../../../../mobile/navigation/components/HeaderNavigationButton';
|
||||
import { goBack } from
|
||||
'../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
|
||||
import { RECORDING_TYPES } from '../../../constants';
|
||||
import AbstractStartRecordingDialog, {
|
||||
IProps,
|
||||
mapStateToProps
|
||||
} from '../AbstractStartRecordingDialog';
|
||||
import styles from '../styles.native';
|
||||
|
||||
import StartRecordingDialogContent from './StartRecordingDialogContent';
|
||||
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to start a file recording session in
|
||||
* progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StartRecordingDialog extends AbstractStartRecordingDialog {
|
||||
|
||||
/**
|
||||
* Constructor of the component.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onStartPress = this._onStartPress.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}. Invoked
|
||||
* immediately after this component is mounted.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidMount() {
|
||||
super.componentDidMount();
|
||||
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
disabled = { this.isStartRecordingDisabled() }
|
||||
label = { t('dialog.start') }
|
||||
onPress = { this._onStartPress }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidUpdate()}. Invoked
|
||||
* immediately after this component is updated.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {void}
|
||||
*/
|
||||
override componentDidUpdate(prevProps: IProps) {
|
||||
super.componentDidUpdate(prevProps);
|
||||
|
||||
const { navigation, t } = this.props;
|
||||
|
||||
navigation.setOptions({
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
headerRight: () => (
|
||||
<HeaderNavigationButton
|
||||
disabled = { this.isStartRecordingDisabled() }
|
||||
label = { t('dialog.start') }
|
||||
onPress = { this._onStartPress }
|
||||
twoActions = { true } />
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts recording session and goes back to the previous screen.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartPress() {
|
||||
this._onSubmit() && goBack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables start recording button.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isStartRecordingDisabled() {
|
||||
const {
|
||||
isTokenValid,
|
||||
selectedRecordingService,
|
||||
shouldRecordAudioAndVideo,
|
||||
shouldRecordTranscription
|
||||
} = this.state;
|
||||
|
||||
if (!shouldRecordAudioAndVideo && !shouldRecordTranscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Start button is disabled if recording service is only shown;
|
||||
// When validating dropbox token, if that is not enabled, we either always
|
||||
// show the start button or, if just dropbox is enabled, start button
|
||||
// is available when there is token.
|
||||
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
|
||||
return false;
|
||||
} else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
|
||||
return !isTokenValid;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
selectedRecordingService,
|
||||
sharingEnabled,
|
||||
shouldRecordAudioAndVideo,
|
||||
shouldRecordTranscription,
|
||||
spaceLeft,
|
||||
userName
|
||||
} = this.state;
|
||||
const {
|
||||
_fileRecordingsServiceEnabled,
|
||||
_fileRecordingsServiceSharingEnabled
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<JitsiScreen style = { styles.startRecodingContainer }>
|
||||
<StartRecordingDialogContent
|
||||
fileRecordingsServiceEnabled = { _fileRecordingsServiceEnabled }
|
||||
fileRecordingsServiceSharingEnabled = { _fileRecordingsServiceSharingEnabled }
|
||||
integrationsEnabled = { this._areIntegrationsEnabled() }
|
||||
isTokenValid = { isTokenValid }
|
||||
isValidating = { isValidating }
|
||||
onChange = { this._onSelectedRecordingServiceChanged }
|
||||
onRecordAudioAndVideoChange = { this._onRecordAudioAndVideoChange }
|
||||
onSharingSettingChanged = { this._onSharingSettingChanged }
|
||||
onTranscriptionChange = { this._onTranscriptionChange }
|
||||
selectedRecordingService = { selectedRecordingService }
|
||||
sharingSetting = { sharingEnabled }
|
||||
shouldRecordAudioAndVideo = { shouldRecordAudioAndVideo }
|
||||
shouldRecordTranscription = { shouldRecordTranscription }
|
||||
spaceLeft = { spaceLeft }
|
||||
userName = { userName } />
|
||||
</JitsiScreen>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(StartRecordingDialog));
|
||||
@@ -0,0 +1,383 @@
|
||||
import React from 'react';
|
||||
import { Image, View } from 'react-native';
|
||||
import { Text } from 'react-native-paper';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconArrowDown, IconArrowRight } from '../../../../base/icons/svg';
|
||||
import LoadingIndicator from '../../../../base/react/components/native/LoadingIndicator';
|
||||
import Button from '../../../../base/ui/components/native/Button';
|
||||
import Switch from '../../../../base/ui/components/native/Switch';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
|
||||
import { RECORDING_TYPES } from '../../../constants';
|
||||
import { getRecordingDurationEstimation } from '../../../functions';
|
||||
import AbstractStartRecordingDialogContent, { mapStateToProps } from '../AbstractStartRecordingDialogContent';
|
||||
import {
|
||||
DROPBOX_LOGO,
|
||||
ICON_CLOUD,
|
||||
ICON_INFO,
|
||||
ICON_USERS
|
||||
} from '../styles.native';
|
||||
|
||||
|
||||
/**
|
||||
* The start recording dialog content for the mobile application.
|
||||
*/
|
||||
class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
|
||||
/**
|
||||
* Renders the component.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
override render() {
|
||||
const { _styles: styles } = this.props;
|
||||
|
||||
return (
|
||||
<View style = { styles.container }>
|
||||
{ this._renderNoIntegrationsContent() }
|
||||
{ this._renderFileSharingContent() }
|
||||
{ this._renderUploadToTheCloudInfo() }
|
||||
{ this._renderIntegrationsContent() }
|
||||
{ this._renderAdvancedOptions() }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the save transcription switch.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderAdvancedOptions() {
|
||||
const { selectedRecordingService } = this.props;
|
||||
|
||||
if (selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE || !this._canStartTranscribing()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { showAdvancedOptions } = this.state;
|
||||
const {
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
shouldRecordAudioAndVideo,
|
||||
shouldRecordTranscription,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
style = { styles.header }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t('recording.showAdvancedOptions') }
|
||||
</Text>
|
||||
<Icon
|
||||
ariaPressed = { showAdvancedOptions }
|
||||
onClick = { this._onToggleShowOptions }
|
||||
role = 'button'
|
||||
size = { 24 }
|
||||
src = { showAdvancedOptions ? IconArrowDown : IconArrowRight } />
|
||||
</View>
|
||||
{showAdvancedOptions && (
|
||||
<>
|
||||
<View
|
||||
key = 'transcriptionSetting'
|
||||
style = { styles.header }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t('recording.recordTranscription') }
|
||||
</Text>
|
||||
<Switch
|
||||
checked = { shouldRecordTranscription }
|
||||
onChange = { this._onTranscriptionSwitchChange }
|
||||
style = { styles.switch } />
|
||||
</View>
|
||||
<View
|
||||
key = 'audioVideoSetting'
|
||||
style = { styles.header }>
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t('recording.recordAudioAndVideo') }
|
||||
</Text>
|
||||
<Switch
|
||||
checked = { shouldRecordAudioAndVideo }
|
||||
onChange = { this._onRecordAudioAndVideoSwitchChange }
|
||||
style = { styles.switch } />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content in case no integrations were enabled.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderNoIntegrationsContent() {
|
||||
const {
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
integrationsEnabled,
|
||||
isValidating,
|
||||
selectedRecordingService,
|
||||
shouldRecordAudioAndVideo,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!this._shouldRenderNoIntegrationsContent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const switchContent
|
||||
= integrationsEnabled
|
||||
? (
|
||||
<Switch
|
||||
checked = { selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE }
|
||||
disabled = { isValidating || !shouldRecordAudioAndVideo }
|
||||
onChange = { this._onRecordingServiceSwitchChange }
|
||||
style = { styles.switch } />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View
|
||||
key = 'noIntegrationSetting'
|
||||
style = { styles.header }>
|
||||
<Image
|
||||
source = { ICON_CLOUD }
|
||||
style = { styles.recordingIcon } />
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t('recording.serviceDescription') }
|
||||
</Text>
|
||||
{ switchContent }
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the file recording service sharing options, if enabled.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderFileSharingContent() {
|
||||
if (!this._shouldRenderFileSharingContent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
isValidating,
|
||||
onSharingSettingChanged,
|
||||
sharingSetting,
|
||||
shouldRecordAudioAndVideo,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<View
|
||||
key = 'fileSharingSetting'
|
||||
style = { styles.header }>
|
||||
<Image
|
||||
source = { ICON_USERS }
|
||||
style = { styles.recordingIcon } />
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t('recording.fileSharingdescription') }
|
||||
</Text>
|
||||
<Switch
|
||||
checked = { sharingSetting }
|
||||
disabled = { isValidating || !shouldRecordAudioAndVideo }
|
||||
onChange = { onSharingSettingChanged }
|
||||
style = { styles.switch } />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the info in case recording is uploaded to the cloud.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderUploadToTheCloudInfo() {
|
||||
const {
|
||||
_dialogStyles,
|
||||
_hideStorageWarning,
|
||||
_styles: styles,
|
||||
isVpaas,
|
||||
selectedRecordingService,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!(isVpaas && selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) || _hideStorageWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
key = 'cloudUploadInfo'
|
||||
style = { styles.headerInfo }>
|
||||
<Image
|
||||
source = { ICON_INFO }
|
||||
style = { styles.recordingInfoIcon } />
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.titleInfo
|
||||
}}>
|
||||
{ t('recording.serviceDescriptionCloudInfo') }
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a spinner component.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderSpinner() {
|
||||
return (
|
||||
<LoadingIndicator
|
||||
size = 'small' />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the screen with the account information of a logged in user.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderSignOut() {
|
||||
const { _styles: styles, spaceLeft, t, userName } = this.props;
|
||||
const duration = getRecordingDurationEstimation(spaceLeft);
|
||||
|
||||
return (
|
||||
<View
|
||||
style = { styles.loggedIn }>
|
||||
<Text
|
||||
style = { [
|
||||
styles.text,
|
||||
styles.recordingText
|
||||
] }>
|
||||
{ t('recording.loggedIn', { userName }) }
|
||||
</Text>
|
||||
<Text
|
||||
style = { [
|
||||
styles.text,
|
||||
styles.recordingText
|
||||
] }>
|
||||
{
|
||||
t('recording.availableSpace', {
|
||||
spaceLeft,
|
||||
duration
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content in case integrations were enabled.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderIntegrationsContent() {
|
||||
if (!this._shouldRenderIntegrationsContent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_dialogStyles,
|
||||
_styles: styles,
|
||||
fileRecordingsServiceEnabled,
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
selectedRecordingService,
|
||||
shouldRecordAudioAndVideo,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
let content = null;
|
||||
let switchContent = null;
|
||||
|
||||
if (isValidating) {
|
||||
content = this._renderSpinner();
|
||||
switchContent = <View />;
|
||||
} else if (isTokenValid) {
|
||||
content = this._renderSignOut();
|
||||
switchContent = (
|
||||
<Button
|
||||
accessibilityLabel = 'recording.signOut'
|
||||
labelKey = 'recording.signOut'
|
||||
onClick = { this._onSignOut }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
);
|
||||
|
||||
} else {
|
||||
switchContent = (
|
||||
<Button
|
||||
accessibilityLabel = 'recording.signIn'
|
||||
labelKey = 'recording.signIn'
|
||||
onClick = { this._onSignIn }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
);
|
||||
}
|
||||
|
||||
if (fileRecordingsServiceEnabled) {
|
||||
switchContent = (
|
||||
<Switch
|
||||
checked = { selectedRecordingService === RECORDING_TYPES.DROPBOX }
|
||||
disabled = { isValidating || !shouldRecordAudioAndVideo }
|
||||
onChange = { this._onDropboxSwitchChange }
|
||||
style = { styles.switch } />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
style = { styles.headerIntegrations }>
|
||||
<Image
|
||||
source = { DROPBOX_LOGO }
|
||||
style = { styles.recordingIcon } />
|
||||
<Text
|
||||
style = {{
|
||||
..._dialogStyles.text,
|
||||
...styles.title
|
||||
}}>
|
||||
{ t('recording.authDropboxText') }
|
||||
</Text>
|
||||
{ switchContent }
|
||||
</View>
|
||||
<View>
|
||||
{ content }
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(StartRecordingDialogContent));
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ConfirmDialog from '../../../../base/dialog/components/native/ConfirmDialog';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import AbstractStopRecordingDialog, {
|
||||
IProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractStopRecordingDialog';
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to stop a file recording session in
|
||||
* progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StopRecordingDialog extends AbstractStopRecordingDialog<IProps> {
|
||||
|
||||
/**
|
||||
* Implements {@code Component#render}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
descriptionKey = 'dialog.stopRecordingWarning'
|
||||
onSubmit = { this._onSubmit } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StopRecordingDialog));
|
||||
167
react/features/recording/components/Recording/styles.native.ts
Normal file
167
react/features/recording/components/Recording/styles.native.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import ColorSchemeRegistry from '../../../base/color-scheme/ColorSchemeRegistry';
|
||||
import { schemeColor } from '../../../base/color-scheme/functions';
|
||||
import { BoxModel } from '../../../base/styles/components/styles/BoxModel';
|
||||
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
export const DROPBOX_LOGO = require('../../../../../images/dropboxLogo_square.png');
|
||||
export const ICON_CLOUD = require('../../../../../images/icon-cloud.png');
|
||||
export const ICON_INFO = require('../../../../../images/icon-info.png');
|
||||
export const ICON_USERS = require('../../../../../images/icon-users.png');
|
||||
export const LOCAL_RECORDING = require('../../../../../images/downloadLocalRecording.png');
|
||||
export const TRACK_COLOR = BaseTheme.palette.ui07;
|
||||
/* eslint-enable @typescript-eslint/no-var-requires */
|
||||
|
||||
// XXX The "standard" {@code BoxModel.padding} has been deemed insufficient in
|
||||
// the special case(s) of the recording feature below.
|
||||
const _PADDING = BoxModel.padding * 1.5;
|
||||
|
||||
const header = {
|
||||
alignItems: 'center',
|
||||
flex: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: _PADDING,
|
||||
paddingTop: _PADDING
|
||||
};
|
||||
|
||||
const recordingIcon = {
|
||||
width: BaseTheme.spacing[4],
|
||||
height: BaseTheme.spacing[4]
|
||||
};
|
||||
|
||||
const title = {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'left',
|
||||
paddingLeft: BoxModel.padding
|
||||
};
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Container for the StartRecordingDialog screen.
|
||||
*/
|
||||
startRecodingContainer: {
|
||||
backgroundColor: BaseTheme.palette.ui01,
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingTop: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
/**
|
||||
* Label for the start recording button.
|
||||
*/
|
||||
startRecordingLabel: {
|
||||
color: BaseTheme.palette.text01,
|
||||
marginRight: 12
|
||||
},
|
||||
highlightButton: {
|
||||
backgroundColor: BaseTheme.palette.ui09,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: BaseTheme.spacing[0],
|
||||
marginBottom: BaseTheme.spacing[0],
|
||||
marginRight: BaseTheme.spacing[1]
|
||||
},
|
||||
highlightButtonText: {
|
||||
color: BaseTheme.palette.field01,
|
||||
paddingLeft: BaseTheme.spacing[2],
|
||||
...BaseTheme.typography.labelBold
|
||||
},
|
||||
highlightDialog: {
|
||||
paddingLeft: BaseTheme.spacing[3],
|
||||
paddingRight: BaseTheme.spacing[3],
|
||||
paddingTop: BaseTheme.spacing[4],
|
||||
paddingBottom: BaseTheme.spacing[7]
|
||||
},
|
||||
highlightDialogHeading: {
|
||||
...BaseTheme.typography.heading5,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginBottom: BaseTheme.spacing[3]
|
||||
},
|
||||
highlightDialogText: {
|
||||
...BaseTheme.typography.bodyLongRegularLarge,
|
||||
color: BaseTheme.palette.text01,
|
||||
marginBottom: BaseTheme.spacing[5]
|
||||
},
|
||||
highlightDialogButtonsContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse'
|
||||
},
|
||||
highlightDialogButtonsSpace: {
|
||||
height: 16,
|
||||
width: '100%'
|
||||
},
|
||||
learnMoreLink: {
|
||||
color: BaseTheme.palette.link01,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Color schemed styles for the @{code StartRecordingDialogContent} component.
|
||||
*/
|
||||
ColorSchemeRegistry.register('StartRecordingDialogContent', {
|
||||
|
||||
container: {
|
||||
flex: 0,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
|
||||
controlDisabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
|
||||
header: {
|
||||
...header,
|
||||
marginHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
headerIntegrations: {
|
||||
...header,
|
||||
paddingHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
headerInfo: {
|
||||
...header,
|
||||
backgroundColor: BaseTheme.palette.warning02,
|
||||
marginBottom: BaseTheme.spacing[4],
|
||||
paddingHorizontal: BaseTheme.spacing[3]
|
||||
},
|
||||
|
||||
loggedIn: {
|
||||
paddingHorizontal: _PADDING
|
||||
},
|
||||
|
||||
recordingIcon: {
|
||||
...recordingIcon
|
||||
},
|
||||
|
||||
recordingInfoIcon: {
|
||||
...recordingIcon
|
||||
},
|
||||
|
||||
recordingText: {
|
||||
color: BaseTheme.palette.text01
|
||||
},
|
||||
|
||||
switch: {
|
||||
color: BaseTheme.palette.ui10
|
||||
},
|
||||
|
||||
title: {
|
||||
...title
|
||||
},
|
||||
|
||||
titleInfo: {
|
||||
...title,
|
||||
color: BaseTheme.palette.ui01
|
||||
},
|
||||
|
||||
text: {
|
||||
color: schemeColor('text')
|
||||
}
|
||||
});
|
||||
16
react/features/recording/components/Recording/styles.web.ts
Normal file
16
react/features/recording/components/Recording/styles.web.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// XXX CSS is used on Web, JavaScript styles are use only for mobile. Export an
|
||||
// (empty) object so that styles[*] statements on Web don't trigger errors.
|
||||
|
||||
export default {};
|
||||
|
||||
export const DROPBOX_LOGO = 'images/dropboxLogo_square.png';
|
||||
|
||||
export const LOCAL_RECORDING = 'images/downloadLocalRecording.png';
|
||||
|
||||
export const ICON_CLOUD = 'images/icon-cloud.png';
|
||||
|
||||
export const ICON_INFO = 'images/icon-info.png';
|
||||
|
||||
export const ICON_USERS = 'images/icon-users.png';
|
||||
|
||||
export const ICON_OPTIONS = 'images/icon-info.png';
|
||||
@@ -0,0 +1,225 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import { IconHighlight } from '../../../../base/icons/svg';
|
||||
import { MEET_FEATURES } from '../../../../base/jwt/constants';
|
||||
import Label from '../../../../base/label/components/web/Label';
|
||||
import Tooltip from '../../../../base/tooltip/components/Tooltip';
|
||||
import BaseTheme from '../../../../base/ui/components/BaseTheme.web';
|
||||
import { maybeShowPremiumFeatureDialog } from '../../../../jaas/actions';
|
||||
import StartRecordingDialog from '../../Recording/web/StartRecordingDialog';
|
||||
import AbstractHighlightButton, {
|
||||
IProps as AbstractProps,
|
||||
_abstractMapStateToProps
|
||||
} from '../AbstractHighlightButton';
|
||||
|
||||
interface IProps extends AbstractProps {
|
||||
_disabled: boolean;
|
||||
|
||||
/**
|
||||
* The message to show within the label's tooltip.
|
||||
*/
|
||||
_tooltipKey: string;
|
||||
|
||||
/**
|
||||
* Flag controlling visibility of the component.
|
||||
*/
|
||||
_visible: boolean;
|
||||
|
||||
/**
|
||||
* An object containing the CSS classes.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link HighlightButton}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Whether the notification which prompts for starting recording is open is not.
|
||||
*/
|
||||
isNotificationOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the styles for the component.
|
||||
*
|
||||
* @param {Object} theme - The current UI theme.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative' as const
|
||||
},
|
||||
disabled: {
|
||||
background: theme.palette.text02
|
||||
},
|
||||
regular: {
|
||||
background: theme.palette.ui10
|
||||
},
|
||||
highlightNotification: {
|
||||
backgroundColor: theme.palette.ui10,
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.25)',
|
||||
boxSizing: 'border-box' as const,
|
||||
color: theme.palette.uiBackground,
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
left: '4px',
|
||||
padding: '16px',
|
||||
position: 'absolute' as const,
|
||||
top: '32px',
|
||||
width: 320
|
||||
},
|
||||
highlightNotificationButton: {
|
||||
color: theme.palette.action01,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
marginTop: '8px'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} responsible for displaying an action that
|
||||
* allows users to highlight a meeting moment.
|
||||
*/
|
||||
export class HighlightButton extends AbstractHighlightButton<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new HighlightButton instance.
|
||||
*
|
||||
* @param {Object} props - The read-only properties with which the new
|
||||
* instance is to be initialized.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isNotificationOpen: false
|
||||
};
|
||||
|
||||
this._onOpenDialog = this._onOpenDialog.bind(this);
|
||||
this._onWindowClickListener = this._onWindowClickListener.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentDidMount()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidMount() {
|
||||
window.addEventListener('click', this._onWindowClickListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentWillUnmount()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentWillUnmount() {
|
||||
window.removeEventListener('click', this._onWindowClickListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the start recording button.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOpenDialog() {
|
||||
const { dispatch } = this.props;
|
||||
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
|
||||
|
||||
if (!dialogShown) {
|
||||
dispatch(openDialog(StartRecordingDialog));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the highlight button.
|
||||
*
|
||||
* @override
|
||||
* @param {Event} e - The click event.
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onClick(e?: React.MouseEvent) {
|
||||
e?.stopPropagation();
|
||||
|
||||
const { _disabled } = this.props;
|
||||
|
||||
if (_disabled) {
|
||||
this.setState({
|
||||
isNotificationOpen: true
|
||||
});
|
||||
} else {
|
||||
super._onClick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Window click event listener.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onWindowClickListener() {
|
||||
this.setState({
|
||||
isNotificationOpen: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
_disabled,
|
||||
_visible,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
if (!_visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const className = _disabled ? classes.disabled : classes.regular;
|
||||
const tooltipKey = _disabled ? 'recording.highlightMomentDisabled' : 'recording.highlightMoment';
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<Tooltip
|
||||
content = { t(tooltipKey) }
|
||||
position = { 'bottom' }>
|
||||
<Label
|
||||
className = { className }
|
||||
icon = { IconHighlight }
|
||||
iconColor = { _disabled ? BaseTheme.palette.text03 : BaseTheme.palette.field01 }
|
||||
id = 'highlightMeetingLabel'
|
||||
onClick = { this._onClick } />
|
||||
</Tooltip>
|
||||
{this.state.isNotificationOpen && (
|
||||
<div className = { classes.highlightNotification }>
|
||||
{t('recording.highlightMomentDisabled')}
|
||||
<div
|
||||
className = { classes.highlightNotificationButton }
|
||||
onClick = { this._onOpenDialog }>
|
||||
{t('localRecording.start')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect(_abstractMapStateToProps)(HighlightButton)), styles);
|
||||
@@ -0,0 +1,60 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { openDialog } from '../../../../base/dialog/actions';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import AbstractRecordButton, {
|
||||
IProps,
|
||||
_mapStateToProps as _abstractMapStateToProps
|
||||
} from '../AbstractRecordButton';
|
||||
|
||||
import StartRecordingDialog from './StartRecordingDialog';
|
||||
import StopRecordingDialog from './StopRecordingDialog';
|
||||
|
||||
|
||||
/**
|
||||
* Button for opening a dialog where a recording session can be started.
|
||||
*/
|
||||
class RecordingButton extends AbstractRecordButton<IProps> {
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button.
|
||||
*
|
||||
* @override
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onHandleClick() {
|
||||
const { _isRecordingRunning, dispatch } = this.props;
|
||||
|
||||
dispatch(openDialog(
|
||||
_isRecordingRunning ? StopRecordingDialog : StartRecordingDialog
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated props for the
|
||||
* {@code RecordButton} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _fileRecordingsDisabledTooltipKey: ?string,
|
||||
* _isRecordingRunning: boolean,
|
||||
* _disabled: boolean,
|
||||
* visible: boolean
|
||||
* }}
|
||||
*/
|
||||
export function _mapStateToProps(state: IReduxState) {
|
||||
const abstractProps = _abstractMapStateToProps(state);
|
||||
const { toolbarButtons } = state['features/toolbox'];
|
||||
const visible = Boolean(toolbarButtons?.includes('recording') && abstractProps.visible);
|
||||
|
||||
return {
|
||||
...abstractProps,
|
||||
visible
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(RecordingButton));
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { translateToHTML } from '../../../../base/i18n/functions';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../../actions.web';
|
||||
|
||||
|
||||
/**
|
||||
* Component that renders the dialog for explicit consent for recordings.
|
||||
*
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export default function RecordingConsentDialog() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { recordings } = useSelector((state: IReduxState) => state['features/base/config']);
|
||||
const { consentLearnMoreLink } = recordings ?? {};
|
||||
const learnMore = consentLearnMoreLink
|
||||
? ` (<a href="${consentLearnMoreLink}" target="_blank" rel="noopener noreferrer">${t('dialog.learnMore')}</a>)`
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
APP.API.notifyRecordingConsentDialogOpen(true);
|
||||
|
||||
return () => {
|
||||
APP.API.notifyRecordingConsentDialogOpen(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const consent = useCallback(() => {
|
||||
dispatch(grantRecordingConsent());
|
||||
}, []);
|
||||
|
||||
const consentAndUnmute = useCallback(() => {
|
||||
dispatch(grantRecordingConsentAndUnmute());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
back = {{
|
||||
hidden: false,
|
||||
onClick: consentAndUnmute,
|
||||
translationKey: 'dialog.UnderstandAndUnmute'
|
||||
}}
|
||||
cancel = {{ hidden: true }}
|
||||
disableBackdropClose = { true }
|
||||
disableEscape = { true }
|
||||
hideCloseButton = { true }
|
||||
ok = {{ translationKey: 'dialog.Understand' }}
|
||||
onSubmit = { consent }
|
||||
titleKey = 'dialog.recordingInProgressTitle'>
|
||||
{ translateToHTML(t, 'dialog.recordingInProgressDescription', { learnMore }) }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { toggleScreenshotCaptureSummary } from '../../../../screenshot-capture/actions';
|
||||
import { isScreenshotCaptureEnabled } from '../../../../screenshot-capture/functions';
|
||||
import { RECORDING_TYPES } from '../../../constants';
|
||||
import AbstractStartRecordingDialog, {
|
||||
mapStateToProps as abstractMapStateToProps
|
||||
} from '../AbstractStartRecordingDialog';
|
||||
|
||||
import StartRecordingDialogContent from './StartRecordingDialogContent';
|
||||
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to start a file recording session in
|
||||
* progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StartRecordingDialog extends AbstractStartRecordingDialog {
|
||||
|
||||
/**
|
||||
* Disables start recording button.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isStartRecordingDisabled() {
|
||||
const {
|
||||
isTokenValid,
|
||||
selectedRecordingService,
|
||||
shouldRecordAudioAndVideo,
|
||||
shouldRecordTranscription
|
||||
} = this.state;
|
||||
|
||||
if (!shouldRecordAudioAndVideo && !shouldRecordTranscription) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Start button is disabled if recording service is only shown;
|
||||
// When validating dropbox token, if that is not enabled, we either always
|
||||
// show the start button or, if just dropbox is enabled, start button
|
||||
// is available when there is token.
|
||||
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
|
||||
return false;
|
||||
} else if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
|
||||
return !isTokenValid;
|
||||
} else if (selectedRecordingService === RECORDING_TYPES.LOCAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
localRecordingOnlySelf,
|
||||
selectedRecordingService,
|
||||
sharingEnabled,
|
||||
shouldRecordAudioAndVideo,
|
||||
shouldRecordTranscription,
|
||||
spaceLeft,
|
||||
userName
|
||||
} = this.state;
|
||||
const {
|
||||
_fileRecordingsServiceEnabled,
|
||||
_fileRecordingsServiceSharingEnabled
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{
|
||||
translationKey: 'dialog.startRecording',
|
||||
disabled: this.isStartRecordingDisabled()
|
||||
}}
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'dialog.startRecording'>
|
||||
<StartRecordingDialogContent
|
||||
fileRecordingsServiceEnabled = { _fileRecordingsServiceEnabled }
|
||||
fileRecordingsServiceSharingEnabled = { _fileRecordingsServiceSharingEnabled }
|
||||
integrationsEnabled = { this._areIntegrationsEnabled() }
|
||||
isTokenValid = { isTokenValid }
|
||||
isValidating = { isValidating }
|
||||
localRecordingOnlySelf = { localRecordingOnlySelf }
|
||||
onChange = { this._onSelectedRecordingServiceChanged }
|
||||
onLocalRecordingSelfChange = { this._onLocalRecordingSelfChange }
|
||||
onRecordAudioAndVideoChange = { this._onRecordAudioAndVideoChange }
|
||||
onSharingSettingChanged = { this._onSharingSettingChanged }
|
||||
onTranscriptionChange = { this._onTranscriptionChange }
|
||||
selectedRecordingService = { selectedRecordingService }
|
||||
sharingSetting = { sharingEnabled }
|
||||
shouldRecordAudioAndVideo = { shouldRecordAudioAndVideo }
|
||||
shouldRecordTranscription = { shouldRecordTranscription }
|
||||
spaceLeft = { spaceLeft }
|
||||
userName = { userName } />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture feature.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _toggleScreenshotCapture() {
|
||||
const { dispatch, _screenshotCaptureEnabled } = this.props;
|
||||
|
||||
if (_screenshotCaptureEnabled) {
|
||||
dispatch(toggleScreenshotCaptureSummary(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps redux state to component props.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @param {any} ownProps - Component's own props.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
return {
|
||||
...abstractMapStateToProps(state, ownProps),
|
||||
_screenshotCaptureEnabled: isScreenshotCaptureEnabled(state, true, false)
|
||||
};
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(StartRecordingDialog));
|
||||
@@ -0,0 +1,486 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconArrowDown, IconArrowRight } from '../../../../base/icons/svg';
|
||||
import Container from '../../../../base/react/components/web/Container';
|
||||
import Image from '../../../../base/react/components/web/Image';
|
||||
import LoadingIndicator from '../../../../base/react/components/web/LoadingIndicator';
|
||||
import Text from '../../../../base/react/components/web/Text';
|
||||
import Button from '../../../../base/ui/components/web/Button';
|
||||
import Switch from '../../../../base/ui/components/web/Switch';
|
||||
import { BUTTON_TYPES } from '../../../../base/ui/constants.web';
|
||||
import { RECORDING_TYPES } from '../../../constants';
|
||||
import { getRecordingDurationEstimation } from '../../../functions';
|
||||
import AbstractStartRecordingDialogContent, { mapStateToProps } from '../AbstractStartRecordingDialogContent';
|
||||
import {
|
||||
DROPBOX_LOGO,
|
||||
ICON_CLOUD,
|
||||
ICON_INFO,
|
||||
ICON_USERS,
|
||||
LOCAL_RECORDING
|
||||
} from '../styles.web';
|
||||
|
||||
const EMPTY_FUNCTION = () => {
|
||||
// empty
|
||||
};
|
||||
|
||||
/**
|
||||
* The start recording dialog content for the mobile application.
|
||||
*/
|
||||
class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
|
||||
/**
|
||||
* Renders the component.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
override render() {
|
||||
const _renderRecording = this.props._renderRecording;
|
||||
|
||||
return (
|
||||
<Container className = 'recording-dialog'>
|
||||
{ _renderRecording && (
|
||||
<>
|
||||
{ this._renderNoIntegrationsContent() }
|
||||
{ this._renderFileSharingContent() }
|
||||
{ this._renderUploadToTheCloudInfo() }
|
||||
{ this._renderIntegrationsContent() }
|
||||
</>
|
||||
)}
|
||||
{ this._renderLocalRecordingContent() }
|
||||
{ _renderRecording && <> { this._renderAdvancedOptions() } </> }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the switch for saving the transcription.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderAdvancedOptions() {
|
||||
const { selectedRecordingService } = this.props;
|
||||
|
||||
if (selectedRecordingService !== RECORDING_TYPES.JITSI_REC_SERVICE || !this._canStartTranscribing()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { showAdvancedOptions } = this.state;
|
||||
const { shouldRecordAudioAndVideo, shouldRecordTranscription, t } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className = 'recording-header-line' />
|
||||
<div
|
||||
className = 'recording-header'
|
||||
onClick = { this._onToggleShowOptions }>
|
||||
<label className = 'recording-title-no-space'>
|
||||
{t('recording.showAdvancedOptions')}
|
||||
</label>
|
||||
<Icon
|
||||
ariaPressed = { showAdvancedOptions }
|
||||
onClick = { this._onToggleShowOptions }
|
||||
role = 'button'
|
||||
size = { 24 }
|
||||
src = { showAdvancedOptions ? IconArrowDown : IconArrowRight } />
|
||||
</div>
|
||||
{showAdvancedOptions && (
|
||||
<>
|
||||
<div className = 'recording-header space-top'>
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-transcription'>
|
||||
{ t('recording.recordTranscription') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { shouldRecordTranscription }
|
||||
className = 'recording-switch'
|
||||
id = 'recording-switch-transcription'
|
||||
onChange = { this._onTranscriptionSwitchChange } />
|
||||
</div>
|
||||
<div className = 'recording-header space-top'>
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-transcription'>
|
||||
{ t('recording.recordAudioAndVideo') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { shouldRecordAudioAndVideo }
|
||||
className = 'recording-switch'
|
||||
id = 'recording-switch-transcription'
|
||||
onChange = { this._onRecordAudioAndVideoSwitchChange } />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content in case no integrations were enabled.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderNoIntegrationsContent() {
|
||||
if (!this._shouldRenderNoIntegrationsContent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_localRecordingAvailable,
|
||||
integrationsEnabled,
|
||||
isValidating,
|
||||
isVpaas,
|
||||
selectedRecordingService,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
const switchContent
|
||||
= integrationsEnabled || _localRecordingAvailable
|
||||
? (
|
||||
<Switch
|
||||
checked = { selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE }
|
||||
className = 'recording-switch'
|
||||
disabled = { isValidating || !this.props.shouldRecordAudioAndVideo }
|
||||
id = 'recording-switch-jitsi'
|
||||
onChange = { this._onRecordingServiceSwitchChange } />
|
||||
) : null;
|
||||
|
||||
const label = isVpaas ? t('recording.serviceDescriptionCloud') : t('recording.serviceDescription');
|
||||
const jitsiContentRecordingIconContainer
|
||||
= integrationsEnabled || _localRecordingAvailable
|
||||
? 'jitsi-content-recording-icon-container-with-switch'
|
||||
: 'jitsi-content-recording-icon-container-without-switch';
|
||||
const contentRecordingClass = isVpaas
|
||||
? 'cloud-content-recording-icon-container'
|
||||
: jitsiContentRecordingIconContainer;
|
||||
const jitsiRecordingHeaderClass = !isVpaas && 'jitsi-recording-header';
|
||||
|
||||
return (
|
||||
<Container
|
||||
className = { `recording-header ${jitsiRecordingHeaderClass}` }
|
||||
key = 'noIntegrationSetting'>
|
||||
<Container className = { contentRecordingClass }>
|
||||
<Image
|
||||
alt = ''
|
||||
className = 'content-recording-icon'
|
||||
src = { ICON_CLOUD } />
|
||||
</Container>
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-jitsi'>
|
||||
{ label }
|
||||
</label>
|
||||
{ switchContent }
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the file recording service sharing options, if enabled.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderFileSharingContent() {
|
||||
if (!this._shouldRenderFileSharingContent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
isValidating,
|
||||
onSharingSettingChanged,
|
||||
sharingSetting,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Container
|
||||
className = 'recording-header'
|
||||
key = 'fileSharingSetting'>
|
||||
<Container className = 'recording-icon-container file-sharing-icon-container'>
|
||||
<Image
|
||||
alt = ''
|
||||
className = 'recording-file-sharing-icon'
|
||||
src = { ICON_USERS } />
|
||||
</Container>
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-share'>
|
||||
{ t('recording.fileSharingdescription') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { sharingSetting }
|
||||
className = 'recording-switch'
|
||||
disabled = { isValidating || !this.props.shouldRecordAudioAndVideo }
|
||||
id = 'recording-switch-share'
|
||||
onChange = { onSharingSettingChanged } />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the info in case recording is uploaded to the cloud.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderUploadToTheCloudInfo() {
|
||||
const {
|
||||
_hideStorageWarning,
|
||||
isVpaas,
|
||||
selectedRecordingService,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
if (!(isVpaas && selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) || _hideStorageWarning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
className = 'recording-info'
|
||||
key = 'cloudUploadInfo'>
|
||||
<Image
|
||||
alt = ''
|
||||
className = 'recording-info-icon'
|
||||
src = { ICON_INFO } />
|
||||
<Text className = 'recording-info-title'>
|
||||
{ t('recording.serviceDescriptionCloudInfo') }
|
||||
</Text>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a spinner component.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderSpinner() {
|
||||
return (
|
||||
<LoadingIndicator size = 'small' />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the screen with the account information of a logged in user.
|
||||
*
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderSignOut() {
|
||||
const {
|
||||
spaceLeft,
|
||||
t,
|
||||
userName
|
||||
} = this.props;
|
||||
const duration = getRecordingDurationEstimation(spaceLeft);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container className = 'logged-in-panel'>
|
||||
<Container>
|
||||
<Text>
|
||||
{ t('recording.loggedIn', { userName }) }
|
||||
</Text>
|
||||
</Container>
|
||||
<Container>
|
||||
<Text>
|
||||
{
|
||||
t('recording.availableSpace', {
|
||||
spaceLeft,
|
||||
duration
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
</Container>
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content in case integrations were enabled.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderIntegrationsContent() {
|
||||
if (!this._shouldRenderIntegrationsContent()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
_localRecordingAvailable,
|
||||
fileRecordingsServiceEnabled,
|
||||
isTokenValid,
|
||||
isValidating,
|
||||
selectedRecordingService,
|
||||
t
|
||||
} = this.props;
|
||||
let content = null;
|
||||
let switchContent = null;
|
||||
let labelContent = (
|
||||
<Text className = 'recording-title'>
|
||||
{ t('recording.authDropboxText') }
|
||||
</Text>
|
||||
);
|
||||
|
||||
if (isValidating) {
|
||||
content = this._renderSpinner();
|
||||
switchContent = <Container className = 'recording-switch' />;
|
||||
} else if (isTokenValid) {
|
||||
content = this._renderSignOut();
|
||||
switchContent = (
|
||||
<Container className = 'recording-switch'>
|
||||
<Button
|
||||
accessibilityLabel = { t('recording.signOut') }
|
||||
labelKey = 'recording.signOut'
|
||||
onClick = { this._onSignOut }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
</Container>
|
||||
);
|
||||
|
||||
} else {
|
||||
switchContent = (
|
||||
<Container className = 'recording-switch'>
|
||||
<Button
|
||||
accessibilityLabel = { t('recording.signIn') }
|
||||
labelKey = 'recording.signIn'
|
||||
onClick = { this._onSignIn }
|
||||
type = { BUTTON_TYPES.PRIMARY } />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileRecordingsServiceEnabled || _localRecordingAvailable) {
|
||||
switchContent = (
|
||||
<Switch
|
||||
checked = { selectedRecordingService
|
||||
=== RECORDING_TYPES.DROPBOX }
|
||||
className = 'recording-switch'
|
||||
disabled = { isValidating || !this.props.shouldRecordAudioAndVideo }
|
||||
id = 'recording-switch-integration'
|
||||
onChange = { this._onDropboxSwitchChange } />
|
||||
);
|
||||
labelContent = (
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-integration'>
|
||||
{ t('recording.authDropboxText') }
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container
|
||||
className = { `recording-header ${this._shouldRenderNoIntegrationsContent()
|
||||
? 'recording-header-line' : ''}` }>
|
||||
<Container
|
||||
className = 'recording-icon-container'>
|
||||
<Image
|
||||
alt = ''
|
||||
className = 'recording-icon'
|
||||
src = { DROPBOX_LOGO } />
|
||||
</Container>
|
||||
{ labelContent }
|
||||
{ switchContent }
|
||||
</Container>
|
||||
<Container className = 'authorization-panel'>
|
||||
{ content }
|
||||
</Container>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the content for local recordings.
|
||||
*
|
||||
* @protected
|
||||
* @returns {React$Component}
|
||||
*/
|
||||
_renderLocalRecordingContent() {
|
||||
const {
|
||||
_localRecordingAvailable,
|
||||
_localRecordingNoNotification,
|
||||
_localRecordingSelfEnabled,
|
||||
isValidating,
|
||||
localRecordingOnlySelf,
|
||||
onLocalRecordingSelfChange,
|
||||
t,
|
||||
selectedRecordingService
|
||||
} = this.props;
|
||||
|
||||
if (!_localRecordingAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<Container
|
||||
className = 'recording-header recording-header-line'>
|
||||
<Container
|
||||
className = 'recording-icon-container'>
|
||||
<Image
|
||||
alt = ''
|
||||
className = 'recording-icon'
|
||||
src = { LOCAL_RECORDING } />
|
||||
</Container>
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-local'>
|
||||
{ t('recording.saveLocalRecording') }
|
||||
</label>
|
||||
<Switch
|
||||
checked = { selectedRecordingService
|
||||
=== RECORDING_TYPES.LOCAL }
|
||||
className = 'recording-switch'
|
||||
disabled = { isValidating || !this.props.shouldRecordAudioAndVideo }
|
||||
id = 'recording-switch-local'
|
||||
onChange = { this._onLocalRecordingSwitchChange } />
|
||||
</Container>
|
||||
</Container>
|
||||
{selectedRecordingService === RECORDING_TYPES.LOCAL && (
|
||||
<>
|
||||
{_localRecordingSelfEnabled && (
|
||||
<Container>
|
||||
<Container className = 'recording-header space-top'>
|
||||
<Container className = 'recording-icon-container file-sharing-icon-container'>
|
||||
<Image
|
||||
alt = ''
|
||||
className = 'recording-file-sharing-icon'
|
||||
src = { ICON_USERS } />
|
||||
</Container>
|
||||
<label
|
||||
className = 'recording-title'
|
||||
htmlFor = 'recording-switch-myself'>
|
||||
{t('recording.onlyRecordSelf')}
|
||||
</label>
|
||||
<Switch
|
||||
checked = { Boolean(localRecordingOnlySelf) }
|
||||
className = 'recording-switch'
|
||||
disabled = { isValidating || !this.props.shouldRecordAudioAndVideo }
|
||||
id = 'recording-switch-myself'
|
||||
onChange = { onLocalRecordingSelfChange ?? EMPTY_FUNCTION } />
|
||||
</Container>
|
||||
</Container>
|
||||
)}
|
||||
<Text className = 'local-recording-warning text'>
|
||||
{t('recording.localRecordingWarning')}
|
||||
</Text>
|
||||
{_localRecordingNoNotification && !localRecordingOnlySelf
|
||||
&& <Text className = 'local-recording-warning notification'>
|
||||
{t('recording.localRecordingNoNotificationWarning')}
|
||||
</Text>
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(mapStateToProps)(StartRecordingDialogContent));
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../../base/i18n/functions';
|
||||
import Dialog from '../../../../base/ui/components/web/Dialog';
|
||||
import { toggleScreenshotCaptureSummary } from '../../../../screenshot-capture/actions';
|
||||
import AbstractStopRecordingDialog, {
|
||||
IProps,
|
||||
_mapStateToProps
|
||||
} from '../AbstractStopRecordingDialog';
|
||||
|
||||
/**
|
||||
* React Component for getting confirmation to stop a file recording session in
|
||||
* progress.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class StopRecordingDialog extends AbstractStopRecordingDialog<IProps> {
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const { t, localRecordingVideoStop } = this.props;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{ translationKey: 'dialog.confirm' }}
|
||||
onSubmit = { this._onSubmit }
|
||||
titleKey = 'dialog.recording'>
|
||||
{t(localRecordingVideoStop ? 'recording.localRecordingVideoStop' : 'dialog.stopRecordingWarning') }
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles screenshot capture.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _toggleScreenshotCapture() {
|
||||
this.props.dispatch(toggleScreenshotCaptureSummary(false));
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(StopRecordingDialog));
|
||||
Reference in New Issue
Block a user