snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
Some checks failed
Prosody Plugin Tests / test (push) Failing after 10m11s
Close stale issues and PRs / stale (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-15 15:56:32 +05:00
commit 1d2bfcac0c
3159 changed files with 464524 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState } from '../../app/types';
import { JitsiRecordingConstants } from '../../base/lib-jitsi-meet';
import { isRecorderTranscriptionsRunning } from '../../transcribing/functions';
import {
getSessionStatusToShow,
isLiveStreamingRunning,
isRecordingRunning,
isRemoteParticipantRecordingLocally
} from '../functions';
export interface IProps extends WithTranslation {
/**
* Whether this is the Jibri recorder participant.
*/
_iAmRecorder: boolean;
/**
* Whether this meeting is being transcribed.
*/
_isTranscribing: boolean;
/**
* Whether the recording/livestreaming/transcriber is currently running.
*/
_isVisible: boolean;
/**
* The status of the higher priority session.
*/
_status?: string;
/**
* The recording mode this indicator should display.
*/
mode: string;
}
/**
* Abstract class for the {@code RecordingLabel} component.
*/
export default class AbstractRecordingLabel<P extends IProps = IProps> extends Component<P> {
/**
* Implements React {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
const { _iAmRecorder, _isVisible } = this.props;
return _isVisible && !_iAmRecorder ? this._renderLabel() : null;
}
/**
* Renders the platform specific label component.
*
* @protected
* @returns {React$Element}
*/
_renderLabel(): React.ReactNode | null {
return null;
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code AbstractRecordingLabel}'s props.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The component's own props.
* @private
* @returns {{
* _status: ?string
* }}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const { mode } = ownProps;
const isLiveStreamingLabel = mode === JitsiRecordingConstants.mode.STREAM;
const _isTranscribing = isRecorderTranscriptionsRunning(state);
const _isLivestreamingRunning = isLiveStreamingRunning(state);
const _isVisible = isLiveStreamingLabel
? _isLivestreamingRunning // this is the livestreaming label
: isRecordingRunning(state) || isRemoteParticipantRecordingLocally(state); // this is the recording label
return {
_isVisible,
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
_isTranscribing,
_status: getSessionStatusToShow(state, mode)
};
}

View File

@@ -0,0 +1,156 @@
import { IReduxState } from '../../../app/types';
import { IconSites } from '../../../base/icons/svg';
import { MEET_FEATURES } from '../../../base/jwt/constants';
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
import { isCloudRecordingRunning, isLiveStreamingButtonVisible, isLiveStreamingRunning } from '../../functions';
import { getLiveStreaming } from './functions';
/**
* The type of the React {@code Component} props of
* {@link AbstractLiveStreamButton}.
*/
export interface IProps extends AbstractButtonProps {
/**
* True if the button needs to be disabled.
*/
_disabled: boolean;
/**
* True if there is a running active live stream, false otherwise.
*/
_isLiveStreamRunning: boolean;
/**
* The tooltip to display when hovering over the button.
*/
_tooltip?: string;
}
/**
* An abstract class of a button for starting and stopping live streaming.
*/
export default class AbstractLiveStreamButton<P extends IProps> extends AbstractButton<P> {
override accessibilityLabel = 'dialog.startLiveStreaming';
override toggledAccessibilityLabel = 'dialog.stopLiveStreaming';
override icon = IconSites;
override label = 'dialog.startLiveStreaming';
override toggledLabel = 'dialog.stopLiveStreaming';
/**
* 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 live stream 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 { dispatch } = this.props;
const dialogShown = dispatch(maybeShowPremiumFeatureDialog(MEET_FEATURES.RECORDING));
if (!dialogShown) {
this._onHandleClick();
}
}
/**
* Returns a boolean value indicating if this button is disabled or not.
*
* @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._isLiveStreamRunning;
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code AbstractLiveStreamButton} component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the Component.
* @private
* @returns {{
* _disabled: boolean,
* _isLiveStreamRunning: boolean,
* visible: boolean
* }}
*/
export function _mapStateToProps(state: IReduxState, ownProps: IProps) {
let { visible } = ownProps;
// A button can be disabled/enabled only if enableFeaturesBasedOnToken
// is on or if the recording is running.
let _disabled = false;
let _tooltip = '';
if (typeof visible === 'undefined') {
// If the containing component provides the visible prop, that is one
// above all, but if not, the button should be autonomous and decide on
// its own to be visible or not.
const liveStreaming = getLiveStreaming(state);
visible = isLiveStreamingButtonVisible({
liveStreamingAllowed: isJwtFeatureEnabled(state, MEET_FEATURES.LIVESTREAMING, false),
liveStreamingEnabled: liveStreaming?.enabled,
isInBreakoutRoom: isInBreakoutRoom(state)
});
}
// disable the button if cloud recording is running.
if (visible && isCloudRecordingRunning(state)) {
_disabled = true;
_tooltip = 'dialog.liveStreamingDisabledBecauseOfActiveRecordingTooltip';
}
// disable the button if we are in a breakout room.
if (isInBreakoutRoom(state)) {
_disabled = true;
}
return {
_disabled,
_isLiveStreamRunning: isLiveStreamingRunning(state),
_tooltip,
visible
};
}

View File

@@ -0,0 +1,251 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createLiveStreamingDialogEvent } 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';
/**
* The type of the React {@code Component} props of
* {@link AbstractStartLiveStreamDialog}.
*/
export interface IProps extends WithTranslation {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference?: IJitsiConference;
/**
* The current state of interactions with the Google API. Determines what
* Google related UI should display.
*/
_googleAPIState: number;
/**
* The email of the user currently logged in to the Google web client
* application.
*/
_googleProfileEmail: string;
/**
* The live stream key that was used before.
*/
_streamKey?: string;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
navigation?: any;
}
/**
* The type of the React {@code Component} state of
* {@link AbstractStartLiveStreamDialog}.
*/
export interface IState {
/**
* Details about the broadcasts available for use for the logged in Google
* user's YouTube account.
*/
broadcasts?: Array<any>;
/**
* The error type, as provided by Google, for the most recent error
* encountered by the Google API.
*/
errorType?: string;
/**
* The boundStreamID of the broadcast currently selected in the broadcast
* dropdown.
*/
selectedBoundStreamID?: string;
/**
* The selected or entered stream key to use for YouTube live streaming.
*/
streamKey?: string;
}
/**
* Implements an abstract class for the StartLiveStreamDialog on both platforms.
*
* NOTE: Google log-in is not supported for mobile yet for later implementation
* but the abstraction of its properties are already present in this abstract
* class.
*/
export default class AbstractStartLiveStreamDialog<P extends IProps>
extends Component<P, IState> {
_isMounted: boolean;
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this.state = {
broadcasts: undefined,
errorType: undefined,
selectedBoundStreamID: undefined,
streamKey: ''
};
/**
* Instance variable used to flag whether the component is or is not
* mounted. Used as a hack to avoid setting state on an unmounted
* component.
*
* @private
* @type {boolean}
*/
this._isMounted = false;
this._onCancel = this._onCancel.bind(this);
this._onStreamKeyChange = this._onStreamKeyChange.bind(this);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
this._isMounted = true;
}
/**
* Implements React's {@link Component#componentWillUnmount()}. Invoked
* immediately before this component is unmounted and destroyed.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._isMounted = false;
}
/**
* Invokes the passed in {@link onCancel} callback and closes
* {@code StartLiveStreamDialog}.
*
* @private
* @returns {boolean} True is returned to close the modal.
*/
_onCancel() {
sendAnalytics(createLiveStreamingDialogEvent('start', 'cancel.button'));
return true;
}
/**
* Asks the user to sign in, if not already signed in, and then requests a
* list of the user's YouTube broadcasts.
*
* NOTE: To be implemented by platforms.
*
* @private
* @returns {Promise}
*/
_onGetYouTubeBroadcasts(): Promise<any> | void {
// to be overwritten by child classes.
}
/**
* Callback invoked to update the {@code StartLiveStreamDialog} component's
* display of the entered YouTube stream key.
*
* @param {string} streamKey - The stream key entered in the field.
* @private
* @returns {void}
*/
_onStreamKeyChange(streamKey: string) {
this._setStateIfMounted({
streamKey,
selectedBoundStreamID: undefined
});
}
/**
* Invokes the passed in {@link onSubmit} callback with the entered stream
* key, and then closes {@code StartLiveStreamDialog}.
*
* @private
* @returns {boolean} False if no stream key is entered to preventing
* closing, true to close the modal.
*/
_onSubmit() {
const { broadcasts, selectedBoundStreamID } = this.state;
const key
= (this.state.streamKey || this.props._streamKey || '').trim();
if (!key) {
return false;
}
let selectedBroadcastID = null;
if (selectedBoundStreamID) {
const selectedBroadcast = broadcasts?.find(
broadcast => broadcast.boundStreamID === selectedBoundStreamID);
selectedBroadcastID = selectedBroadcast?.id;
}
sendAnalytics(
createLiveStreamingDialogEvent('start', 'confirm.button'));
this.props._conference?.startRecording({
broadcastId: selectedBroadcastID,
mode: JitsiRecordingConstants.mode.STREAM,
streamId: key
});
return true;
}
/**
* Updates the internal state if the component is still mounted. This is a
* workaround for all the state setting that occurs after ajax.
*
* @param {Object} newState - The new state to merge into the existing
* state.
* @private
* @returns {void}
*/
_setStateIfMounted(newState: IState) {
if (this._isMounted) {
this.setState(newState);
}
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _conference: Object,
* _googleAPIState: number,
* _googleProfileEmail: string,
* _streamKey: string
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_conference: state['features/base/conference'].conference,
_googleAPIState: state['features/google-api'].googleAPIState,
_googleProfileEmail: state['features/google-api'].profileEmail,
_streamKey: state['features/recording'].streamKey
};
}

View File

@@ -0,0 +1,82 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createLiveStreamingDialogEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { IJitsiConference } from '../../../base/conference/reducer';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { getActiveSession } from '../../functions';
import { ISessionData } from '../../reducer';
/**
* The type of the React {@code Component} props of
* {@link StopLiveStreamDialog}.
*/
interface IProps extends WithTranslation {
/**
* The {@code JitsiConference} for the current conference.
*/
_conference?: IJitsiConference;
/**
* The redux representation of the live streaming to be stopped.
*/
_session?: ISessionData;
}
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @augments Component
*/
export default class AbstractStopLiveStreamDialog extends Component<IProps> {
/**
* Initializes a new {@code StopLiveStreamDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handler so it is only bound once for every instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Callback invoked when stopping of live streaming is confirmed.
*
* @private
* @returns {boolean} True to close the modal.
*/
_onSubmit() {
sendAnalytics(createLiveStreamingDialogEvent('stop', 'confirm.button'));
const { _session } = this.props;
this.props._conference?.stopRecording(_session?.id);
return true;
}
}
/**
* Maps (parts of) the redux state to the React {@code Component} props of
* {@code StopLiveStreamDialog}.
*
* @param {Object} state - The redux state.
* @private
* @returns {{
* _conference: Object,
* _session: Object
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_conference: state['features/base/conference'].conference,
_session: getActiveSession(state, JitsiRecordingConstants.mode.STREAM)
};
}

View File

@@ -0,0 +1,179 @@
import { type DebouncedFunc, debounce } from 'lodash-es';
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState } from '../../../app/types';
import { getLiveStreaming } from './functions';
export type LiveStreaming = {
// Terms link
dataPrivacyLink: string;
enabled: boolean;
helpLink: string;
// Documentation reference for the live streaming feature.
termsLink: string; // Data privacy link
validatorRegExpString: string; // RegExp string that validates the stream key input field
};
export type LiveStreamingProps = {
dataPrivacyURL?: string;
enabled: boolean;
helpURL?: string;
streamLinkRegexp: RegExp;
termsURL?: string;
};
/**
* The props of the component.
*/
export interface IProps extends WithTranslation {
/**
* The live streaming dialog properties.
*/
_liveStreaming: LiveStreamingProps;
/**
* Callback invoked when the entered stream key has changed.
*/
onChange: Function;
/**
* The stream key value to display as having been entered so far.
*/
value: string;
}
/**
* The state of the component.
*/
interface IState {
/**
* Whether or not to show the warnings that the passed in value seems like
* an improperly formatted stream key.
*/
showValidationError: boolean;
}
/**
* An abstract React Component for entering a key for starting a YouTube live
* stream.
*
* @augments Component
*/
export default class AbstractStreamKeyForm<P extends IProps>
extends Component<P, IState> {
_debouncedUpdateValidationErrorVisibility: DebouncedFunc<() => void>;
/**
* Constructor for the component.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this.state = {
showValidationError: Boolean(this.props.value)
&& !this._validateStreamKey(this.props.value)
};
this._debouncedUpdateValidationErrorVisibility = debounce(
this._updateValidationErrorVisibility.bind(this),
800,
{ leading: false }
);
// Bind event handlers so they are only bound once per instance.
this._onInputChange = this._onInputChange.bind(this);
}
/**
* Implements React Component's componentDidUpdate.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: P) {
if (this.props.value !== prevProps.value) {
this._debouncedUpdateValidationErrorVisibility();
}
}
/**
* Implements React Component's componentWillUnmount.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._debouncedUpdateValidationErrorVisibility.cancel();
}
/**
* Callback invoked when the value of the input field has updated through
* user input. This forwards the value (string only, even if it was a dom
* event) to the onChange prop provided to the component.
*
* @param {Object | string} change - DOM Event for value change or the
* changed text.
* @private
* @returns {void}
*/
_onInputChange(change: any) {
const value = typeof change === 'object' ? change.target.value : change;
this.props.onChange(value);
}
/**
* Checks if the stream key value seems like a valid stream key and sets the
* state for showing or hiding the notification about the stream key seeming
* invalid.
*
* @private
* @returns {boolean}
*/
_updateValidationErrorVisibility() {
const newShowValidationError = Boolean(this.props.value)
&& !this._validateStreamKey(this.props.value);
if (newShowValidationError !== this.state.showValidationError) {
this.setState({
showValidationError: newShowValidationError
});
}
}
/**
* Checks if a passed in stream key appears to be in a valid format.
*
* @param {string} streamKey - The stream key to check for valid formatting.
* @returns {void}
* @returns {boolean}
*/
_validateStreamKey(streamKey = '') {
const trimmedKey = streamKey.trim();
const match = this.props._liveStreaming.streamLinkRegexp.exec(trimmedKey);
return Boolean(match);
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _liveStreaming: LiveStreamingProps
* }}
*/
export function _mapStateToProps(state: IReduxState) {
return {
_liveStreaming: getLiveStreaming(state)
};
}

View File

@@ -0,0 +1,26 @@
/**
* The URL for Google Privacy Policy.
*/
export const GOOGLE_PRIVACY_POLICY = 'https://policies.google.com/privacy';
/**
* The URL that is the main landing page for YouTube live streaming and should
* have a user's live stream key.
*/
export const YOUTUBE_LIVE_DASHBOARD_URL = 'https://www.youtube.com/live_dashboard';
/**
* The URL for YouTube terms and conditions.
*/
export const YOUTUBE_TERMS_URL = 'https://www.youtube.com/t/terms';
/**
* The live streaming help link to display.
*/
export const JITSI_LIVE_STREAMING_HELP_LINK = 'https://jitsi.org/live';
/**
* The YouTube stream link RegExp.
*/
export const FOUR_GROUPS_DASH_SEPARATED = /^(?:[a-zA-Z0-9]{4}(?:-(?!$)|$)){4}/;

View File

@@ -0,0 +1,28 @@
import { IReduxState } from '../../../app/types';
import { sanitizeUrl } from '../../../base/util/uri';
import {
FOUR_GROUPS_DASH_SEPARATED,
GOOGLE_PRIVACY_POLICY,
JITSI_LIVE_STREAMING_HELP_LINK,
YOUTUBE_TERMS_URL
} from './constants';
/**
* Get the live streaming options.
*
* @param {Object} state - The global state.
* @returns {LiveStreaming}
*/
export function getLiveStreaming(state: IReduxState) {
const { liveStreaming = {} } = state['features/base/config'];
const regexp = liveStreaming.validatorRegExpString && new RegExp(liveStreaming.validatorRegExpString);
return {
enabled: Boolean(liveStreaming.enabled),
helpURL: sanitizeUrl(liveStreaming.helpLink || JITSI_LIVE_STREAMING_HELP_LINK)?.toString(),
termsURL: sanitizeUrl(liveStreaming.termsLink || YOUTUBE_TERMS_URL)?.toString(),
dataPrivacyURL: sanitizeUrl(liveStreaming.dataPrivacyLink || GOOGLE_PRIVACY_POLICY)?.toString(),
streamLinkRegexp: regexp || FOUR_GROUPS_DASH_SEPARATED
};
}

View File

@@ -0,0 +1,229 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../../app/types';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import { setGoogleAPIState } from '../../../../google-api/actions';
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.native';
import {
GOOGLE_API_STATES,
GOOGLE_SCOPE_YOUTUBE
} from '../../../../google-api/constants';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import googleApi from '../../../../google-api/googleApi.native';
import logger from '../../../logger';
import styles from './styles';
/**
* Prop type of the component {@code GoogleSigninForm}.
*/
interface IProps extends WithTranslation {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* The Redux dispatch Function.
*/
dispatch: IStore['dispatch'];
/**
* The current state of the Google api as defined in {@code constants.js}.
*/
googleAPIState: number;
/**
* The recently received Google response.
*/
googleResponse: any;
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*/
onUserChanged: Function;
}
/**
* Class to render a google sign in form, or a google stream picker dialog.
*
* @augments Component
*/
class GoogleSigninForm extends Component<IProps> {
/**
* Instantiates a new {@code GoogleSigninForm} component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._logGoogleError = this._logGoogleError.bind(this);
this._onGoogleButtonPress = this._onGoogleButtonPress.bind(this);
}
/**
* Implements React's Component.componentDidMount.
*
* @inheritdoc
*/
override componentDidMount() {
googleApi.hasPlayServices()
.then(() => {
googleApi.configure({
offlineAccess: false,
scopes: [ GOOGLE_SCOPE_YOUTUBE ]
});
googleApi.signInSilently().then((response: any) => {
this._setApiState(response
? GOOGLE_API_STATES.SIGNED_IN
: GOOGLE_API_STATES.LOADED,
response);
}, () => {
this._setApiState(GOOGLE_API_STATES.LOADED);
});
})
.catch((error: Error) => {
this._logGoogleError(error);
this._setApiState(GOOGLE_API_STATES.NOT_AVAILABLE);
});
}
/**
* Renders the component.
*
* @inheritdoc
*/
override render() {
const { _dialogStyles, t } = this.props;
const { googleAPIState, googleResponse } = this.props;
const signedInUser = googleResponse?.user?.email;
if (googleAPIState === GOOGLE_API_STATES.NOT_AVAILABLE
|| googleAPIState === GOOGLE_API_STATES.NEEDS_LOADING
|| typeof googleAPIState === 'undefined') {
return null;
}
const userInfo = signedInUser
? `${t('liveStreaming.signedInAs')} ${signedInUser}`
: t('liveStreaming.signInCTA');
return (
<View style = { styles.formWrapper as ViewStyle }>
<View style = { styles.helpText as ViewStyle }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ userInfo }
</Text>
</View>
<GoogleSignInButton
onClick = { this._onGoogleButtonPress }
signedIn = {
googleAPIState === GOOGLE_API_STATES.SIGNED_IN } />
</View>
);
}
/**
* A helper function to log developer related errors.
*
* @private
* @param {Object} error - The error to be logged.
* @returns {void}
*/
_logGoogleError(error: Error) {
// NOTE: This is a developer error message, not intended for the
// user to see.
logger.error('Google API error. Possible cause: bad config.', error);
}
/**
* Callback to be invoked when the user presses the Google button,
* regardless of being logged in or out.
*
* @private
* @returns {void}
*/
_onGoogleButtonPress() {
const { googleResponse } = this.props;
if (googleResponse?.user) {
// the user is signed in
this._onSignOut();
} else {
this._onSignIn();
}
}
/**
* Initiates a sign in if the user is not signed in yet.
*
* @private
* @returns {void}
*/
_onSignIn() {
googleApi.signIn().then((response: any) => {
this._setApiState(GOOGLE_API_STATES.SIGNED_IN, response);
}, this._logGoogleError);
}
/**
* Initiates a sign out if the user is signed in.
*
* @private
* @returns {void}
*/
_onSignOut() {
googleApi.signOut().then((response: any) => {
this._setApiState(GOOGLE_API_STATES.LOADED, response);
}, this._logGoogleError);
}
/**
* Updates the API (Google Auth) state.
*
* @private
* @param {number} apiState - The state of the API.
* @param {?Object} googleResponse - The response from the API.
* @returns {void}
*/
_setApiState(apiState: number, googleResponse?: Object) {
this.props.onUserChanged(googleResponse);
this.props.dispatch(setGoogleAPIState(apiState, googleResponse));
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code GoogleSigninForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* googleAPIState: number,
* googleResponse: Object
* }}
*/
function _mapStateToProps(state: IReduxState) {
const { googleAPIState, googleResponse } = state['features/google-api'];
return {
..._abstractMapStateToProps(state),
googleAPIState,
googleResponse
};
}
export default translate(connect(_mapStateToProps)(GoogleSigninForm));

View File

@@ -0,0 +1,63 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { LIVE_STREAMING_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 AbstractLiveStreamButton, {
IProps as AbstractProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractLiveStreamButton';
import { IProps } from '../AbstractStartLiveStreamDialog';
import StopLiveStreamDialog from './StopLiveStreamDialog';
type Props = IProps & AbstractProps;
/**
* Button for opening the live stream settings screen.
*/
class LiveStreamButton extends AbstractLiveStreamButton<Props> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
_onHandleClick() {
const { _isLiveStreamRunning, dispatch } = this.props;
if (_isLiveStreamRunning) {
dispatch(openDialog('StopLiveStreamDialog', StopLiveStreamDialog));
} else {
navigate(screen.conference.liveStream);
}
}
}
/**
* 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, ownProps: any) {
const enabled = getFeatureFlag(state, LIVE_STREAMING_ENABLED, true);
const abstractProps = _abstractMapStateToProps(state, ownProps);
return {
...abstractProps,
visible: enabled && abstractProps.visible
};
}
export default translate(connect(mapStateToProps)(LiveStreamButton));

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../../base/i18n/functions';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import { StyleType } from '../../../../base/styles/functions.any';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import googleApi from '../../../../google-api/googleApi.native';
import HeaderNavigationButton
from '../../../../mobile/navigation/components/HeaderNavigationButton';
import { goBack }
from '../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import { setLiveStreamKey } from '../../../actions';
import AbstractStartLiveStreamDialog, { IProps, _mapStateToProps } from '../AbstractStartLiveStreamDialog';
import GoogleSigninForm from './GoogleSigninForm';
import StreamKeyForm from './StreamKeyForm';
import StreamKeyPicker from './StreamKeyPicker';
import styles from './styles';
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*/
class StartLiveStreamDialog extends AbstractStartLiveStreamDialog<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onStartPress = this._onStartPress.bind(this);
this._onStreamKeyChangeNative
= this._onStreamKeyChangeNative.bind(this);
this._onStreamKeyPick = this._onStreamKeyPick.bind(this);
this._onUserChanged = this._onUserChanged.bind(this);
}
/**
* Implements React's {@link Component#componentDidMount()}. Invoked
* immediately after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
const { navigation, t } = this.props;
navigation.setOptions({
headerRight: () => (
<HeaderNavigationButton
label = { t('dialog.start') }
onPress = { this._onStartPress }
twoActions = { true } />
)
});
}
/**
* Starts live stream session and goes back to the previous screen.
*
* @returns {void}
*/
_onStartPress() {
this._onSubmit() && goBack();
}
/**
* Implements {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
return (
<JitsiScreen style = { styles.startLiveStreamContainer as StyleType }>
<GoogleSigninForm
onUserChanged = { this._onUserChanged } />
<StreamKeyPicker
broadcasts = { this.state.broadcasts }
onChange = { this._onStreamKeyPick } />
<StreamKeyForm
onChange = { this._onStreamKeyChangeNative }
value = {
this.state.streamKey || this.props._streamKey || ''
} />
</JitsiScreen>
);
}
/**
* Callback to handle stream key changes.
*
* FIXME: This is a temporary method to store the streaming key on mobile
* for easier use, until the Google sign-in is implemented. We don't store
* the key on web for security reasons (e.g. We don't want to have the key
* stored if the used signed out).
*
* @private
* @param {string} streamKey - The new key value.
* @returns {void}
*/
_onStreamKeyChangeNative(streamKey: string) {
this.props.dispatch(setLiveStreamKey(streamKey));
this._onStreamKeyChange(streamKey);
}
/**
* Callback to be invoked when the user selects a stream from the picker.
*
* @private
* @param {string} streamKey - The key of the selected stream.
* @returns {void}
*/
_onStreamKeyPick(streamKey: string) {
this.setState({
streamKey
});
}
/**
* A callback to be invoked when an authenticated user changes, so
* then we can get (or clear) the YouTube stream key.
*
* TODO: Handle errors by showing some indication to the user.
*
* @private
* @param {Object} response - The retrieved signin response.
* @returns {void}
*/
_onUserChanged(response: Object) {
if (response) {
googleApi.getTokens()
.then((tokens: any) => {
googleApi.getYouTubeLiveStreams(tokens.accessToken)
.then((broadcasts: any) => {
this.setState({
broadcasts
});
});
})
.catch(() => {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
});
} else {
this.setState({
broadcasts: undefined,
streamKey: undefined
});
}
}
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@@ -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 AbstractStopLiveStreamDialog, {
_mapStateToProps
} from '../AbstractStopLiveStreamDialog';
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @augments Component
*/
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ConfirmDialog
descriptionKey = 'dialog.stopStreamingWarning'
onSubmit = { this._onSubmit } />
);
}
}
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { Linking, Text, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import Button from '../../../../base/ui/components/native/Button';
import Input from '../../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../../base/ui/constants.native';
import AbstractStreamKeyForm, {
IProps as AbstractProps
} from '../AbstractStreamKeyForm';
import { getLiveStreaming } from '../functions';
import styles from './styles';
interface IProps extends AbstractProps {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
}
/**
* A React Component for entering a key for starting a YouTube live stream.
*
* @augments Component
*/
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
/**
* Initializes a new {@code StreamKeyForm} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StreamKeyForm} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onOpenGooglePrivacyPolicy = this._onOpenGooglePrivacyPolicy.bind(this);
this._onOpenHelp = this._onOpenHelp.bind(this);
this._onOpenYoutubeTerms = this._onOpenYoutubeTerms.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _dialogStyles, t } = this.props;
return (
<>
<View style = { styles.formWrapper as ViewStyle }>
<Input
customStyles = {{
input: styles.streamKeyInput,
container: styles.streamKeyContainer }}
onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
value = { this.props.value } />
<View style = { styles.formValidationItem as ViewStyle }>
{
this.state.showValidationError && <Text
style = { [
_dialogStyles.text,
styles.warningText
] }>
{ t('liveStreaming.invalidStreamKey') }
</Text>
}
</View>
</View>
<View style = { styles.formButtonsWrapper as ViewStyle }>
<Button
accessibilityLabel = 'liveStreaming.streamIdHelp'
labelKey = 'liveStreaming.streamIdHelp'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenHelp }
type = { BUTTON_TYPES.TERTIARY } />
<Button
accessibilityLabel = 'liveStreaming.youtubeTerms'
labelKey = 'liveStreaming.youtubeTerms'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenYoutubeTerms }
type = { BUTTON_TYPES.TERTIARY } />
<Button
accessibilityLabel = 'liveStreaming.googlePrivacyPolicy'
labelKey = 'liveStreaming.googlePrivacyPolicy'
labelStyle = { styles.buttonLabelStyle }
onClick = { this._onOpenGooglePrivacyPolicy }
type = { BUTTON_TYPES.TERTIARY } />
</View>
</>
);
}
/**
* Opens the Google Privacy Policy web page.
*
* @private
* @returns {void}
*/
_onOpenGooglePrivacyPolicy() {
const url = this.props._liveStreaming.dataPrivacyURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
/**
* Opens the information link on how to manually locate a YouTube broadcast
* stream key.
*
* @private
* @returns {void}
*/
_onOpenHelp() {
const url = this.props._liveStreaming.helpURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
/**
* Opens the YouTube terms and conditions web page.
*
* @private
* @returns {void}
*/
_onOpenYoutubeTerms() {
const url = this.props._liveStreaming.termsURL;
if (typeof url === 'string') {
Linking.openURL(url);
}
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code StreamKeyForm} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _liveStreaming: LiveStreamingProps
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
_liveStreaming: getLiveStreaming(state)
};
}
export default translate(connect(_mapStateToProps)(StreamKeyForm));

View File

@@ -0,0 +1,168 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import {
Linking,
Text,
TouchableHighlight,
TouchableOpacity,
View,
ViewStyle
} from 'react-native';
import { connect } from 'react-redux';
import { _abstractMapStateToProps } from '../../../../base/dialog/functions';
import { translate } from '../../../../base/i18n/functions';
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
import styles, { ACTIVE_OPACITY, TOUCHABLE_UNDERLAY } from './styles';
interface IProps extends WithTranslation {
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* The list of broadcasts the user can pick from.
*/
broadcasts?: Array<{ key: string; title: string; }>;
/**
* Callback to be invoked when the user picked a broadcast. To be invoked
* with a single key (string).
*/
onChange: Function;
}
interface IState {
/**
* The key of the currently selected stream.
*/
streamKey?: string | null;
}
/**
* Class to implement a stream key picker (dropdown) component to allow the user
* to choose from the available Google Broadcasts/Streams.
*
* NOTE: This component is currently only used on mobile, but it is advised at
* a later point to unify mobile and web logic for this functionality. But it's
* out of the scope for now of the mobile live streaming functionality.
*/
class StreamKeyPicker extends Component<IProps, IState> {
/**
* Instantiates a new instance of StreamKeyPicker.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
streamKey: null
};
this._onOpenYoutubeDashboard = this._onOpenYoutubeDashboard.bind(this);
this._onStreamPick = this._onStreamPick.bind(this);
}
/**
* Renders the component.
*
* @inheritdoc
*/
override render() {
const { _dialogStyles, broadcasts } = this.props;
if (!broadcasts) {
return null;
}
if (!broadcasts.length) {
return (
<View style = { styles.formWrapper as ViewStyle }>
<TouchableOpacity
onPress = { this._onOpenYoutubeDashboard }>
<Text
style = { [
_dialogStyles.text,
styles.warningText
] }>
{ this.props.t(
'liveStreaming.getStreamKeyManually') }
</Text>
</TouchableOpacity>
</View>
);
}
return (
<View style = { styles.formWrapper as ViewStyle }>
<View style = { styles.streamKeyPickerCta as ViewStyle }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ this.props.t('liveStreaming.choose') }
</Text>
</View>
<View style = { styles.streamKeyPickerWrapper as ViewStyle } >
{ broadcasts.map((broadcast, index) =>
(<TouchableHighlight
activeOpacity = { ACTIVE_OPACITY }
key = { index }
onPress = { this._onStreamPick(broadcast.key) }
style = { [
styles.streamKeyPickerItem,
this.state.streamKey === broadcast.key
? styles.streamKeyPickerItemHighlight : null
] as ViewStyle[] }
underlayColor = { TOUCHABLE_UNDERLAY }>
<Text
style = { [
_dialogStyles.text,
styles.text
] }>
{ broadcast.title }
</Text>
</TouchableHighlight>))
}
</View>
</View>
);
}
/**
* Opens the link which should display the YouTube broadcast live stream
* key.
*
* @private
* @returns {void}
*/
_onOpenYoutubeDashboard() {
Linking.openURL(YOUTUBE_LIVE_DASHBOARD_URL);
}
/**
* Callback to be invoked when the user picks a stream from the list.
*
* @private
* @param {string} streamKey - The key of the stream selected.
* @returns {Function}
*/
_onStreamPick(streamKey: string) {
return () => {
this.setState({
streamKey
});
this.props.onChange(streamKey);
};
}
}
export default translate(
connect(_abstractMapStateToProps)(StreamKeyPicker));

View File

@@ -0,0 +1,146 @@
import { createStyleSheet } from '../../../../base/styles/functions.native';
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
/**
* Opacity of the TouchableHighlight.
*/
export const ACTIVE_OPACITY = 0.3;
/**
* Underlay of the TouchableHighlight.
*/
export const TOUCHABLE_UNDERLAY = BaseTheme.palette.ui06;
/**
* The styles of the React {@code Components} of LiveStream.
*/
export default createStyleSheet({
/**
* Generic component to wrap form sections into achieving a unified look.
*/
formWrapper: {
alignItems: 'stretch',
flexDirection: 'column',
paddingHorizontal: BaseTheme.spacing[2]
},
formValidationItem: {
alignSelf: 'flex-start',
flexDirection: 'row',
height: BaseTheme.spacing[4],
marginTop: BaseTheme.spacing[2]
},
formButtonsWrapper: {
alignSelf: 'center',
display: 'flex',
maxWidth: 200
},
buttonLabelStyle: {
color: BaseTheme.palette.link01
},
/**
* Explaining text on the top of the sign in form.
*/
helpText: {
marginBottom: BaseTheme.spacing[2]
},
/**
* Container for the live stream screen.
*/
startLiveStreamContainer: {
backgroundColor: BaseTheme.palette.ui01,
display: 'flex',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: BaseTheme.spacing[2],
paddingVertical: BaseTheme.spacing[3]
},
/**
* Helper link text.
*/
streamKeyHelp: {
alignSelf: 'flex-end'
},
/**
* Input field to manually enter stream key.
*/
streamKeyInput: {
alignSelf: 'stretch',
color: BaseTheme.palette.text01,
textAlign: 'left'
},
streamKeyContainer: {
marginTop: BaseTheme.spacing[3]
},
/**
* Custom component to pick a broadcast from the list fetched from Google.
*/
streamKeyPicker: {
alignSelf: 'stretch',
flex: 1,
height: 40,
marginHorizontal: BaseTheme.spacing[1],
width: 300
},
/**
* CTA (label) of the picker.
*/
streamKeyPickerCta: {
marginBottom: BaseTheme.spacing[2]
},
/**
* Style of a single item in the list.
*/
streamKeyPickerItem: {
padding: BaseTheme.spacing[1]
},
/**
* Additional style for the selected item.
*/
streamKeyPickerItemHighlight: {
backgroundColor: BaseTheme.palette.ui04
},
/**
* Overall wrapper for the picker.
*/
streamKeyPickerWrapper: {
borderColor: BaseTheme.palette.ui07,
borderRadius: BaseTheme.shape.borderRadius,
borderWidth: 1,
flexDirection: 'column'
},
/**
* Terms and Conditions texts.
*/
tcText: {
textAlign: 'right'
},
text: {
color: BaseTheme.palette.text01,
fontSize: 14,
textAlign: 'left'
},
/**
* A different colored text to indicate information needing attention.
*/
warningText: {
color: BaseTheme.palette.warning02
}
});

View File

@@ -0,0 +1,65 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { translate } from '../../../../base/i18n/functions';
import AbstractLiveStreamButton, {
IProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractLiveStreamButton';
import StartLiveStreamDialog from './StartLiveStreamDialog';
import StopLiveStreamDialog from './StopLiveStreamDialog';
/**
* Button for opening the live stream settings dialog.
*/
class LiveStreamButton extends AbstractLiveStreamButton<IProps> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
override _onHandleClick() {
const { _isLiveStreamRunning, dispatch } = this.props;
const dialogComponent = _isLiveStreamRunning ? StopLiveStreamDialog : StartLiveStreamDialog;
const dialogName = _isLiveStreamRunning ? 'StopLiveStreamDialog' : 'StartLiveStreamDialog';
dispatch(openDialog(dialogName, dialogComponent));
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code LiveStreamButton} component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the Component.
* @private
* @returns {{
* _conference: Object,
* _isLiveStreamRunning: boolean,
* _disabled: boolean,
* visible: boolean
* }}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const abstractProps = _abstractMapStateToProps(state, ownProps);
const { toolbarButtons } = state['features/toolbox'];
let { visible } = ownProps;
if (typeof visible === 'undefined') {
visible = Boolean(toolbarButtons?.includes('livestreaming') && abstractProps.visible);
}
return {
...abstractProps,
visible
};
}
export default translate(connect(_mapStateToProps)(LiveStreamButton));

View File

@@ -0,0 +1,360 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { translate } from '../../../../base/i18n/functions.web';
import Dialog from '../../../../base/ui/components/web/Dialog';
import Spinner from '../../../../base/ui/components/web/Spinner';
import {
loadGoogleAPI,
requestAvailableYouTubeBroadcasts,
requestLiveStreamsForYouTubeBroadcast,
showAccountSelection,
signIn,
updateProfile
} from '../../../../google-api/actions';
import GoogleSignInButton from '../../../../google-api/components/GoogleSignInButton.web';
import { GOOGLE_API_STATES } from '../../../../google-api/constants';
import AbstractStartLiveStreamDialog, {
IProps as AbstractProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractStartLiveStreamDialog';
import StreamKeyForm from './StreamKeyForm';
import StreamKeyPicker from './StreamKeyPicker';
interface IProps extends AbstractProps {
/**
* The ID for the Google client application used for making stream key
* related requests.
*/
_googleApiApplicationClientID?: string;
}
/**
* A React Component for requesting a YouTube stream key to use for live
* streaming of the current conference.
*
* @augments Component
*/
class StartLiveStreamDialog
extends AbstractStartLiveStreamDialog<IProps> {
/**
* Initializes a new {@code StartLiveStreamDialog} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StartLiveStreamDialog} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onGetYouTubeBroadcasts = this._onGetYouTubeBroadcasts.bind(this);
this._onInitializeGoogleApi = this._onInitializeGoogleApi.bind(this);
this._onGoogleSignIn = this._onGoogleSignIn.bind(this);
this._onRequestGoogleSignIn = this._onRequestGoogleSignIn.bind(this);
this._onYouTubeBroadcastIDSelected
= this._onYouTubeBroadcastIDSelected.bind(this);
}
/**
* Implements {@link Component#componentDidMount()}. Invoked immediately
* after this component is mounted.
*
* @inheritdoc
* @returns {void}
*/
override componentDidMount() {
super.componentDidMount();
if (this.props._googleApiApplicationClientID) {
this._onInitializeGoogleApi();
}
}
/**
* Implements {@code Component}'s render.
*
* @inheritdoc
*/
override render() {
const { _googleApiApplicationClientID, t } = this.props;
return (
<Dialog
ok = {{ translationKey: 'dialog.startLiveStreaming' }}
onCancel = { this._onCancel }
onSubmit = { this._onSubmit }
titleKey = 'liveStreaming.start'>
<div className = 'live-stream-dialog'>
{ _googleApiApplicationClientID
? this._renderYouTubePanel() : null }
<div className = 'youtube-go-live-warning'>
{ t('liveStreaming.youTubeGoLiveWarning') }
</div>
<StreamKeyForm
onChange = { this._onStreamKeyChange }
value = {
this.state.streamKey || this.props._streamKey || ''
} />
</div>
</Dialog>
);
}
/**
* Loads the Google web client application used for fetching stream keys.
* If the user is already logged in, then a request for available YouTube
* broadcasts is also made.
*
* @private
* @returns {void}
*/
_onInitializeGoogleApi() {
this.props.dispatch(loadGoogleAPI())
.catch((response: any) => this._parseErrorFromResponse(response));
}
/**
* Automatically selects the input field's value after starting to edit the
* display name.
*
* @inheritdoc
* @returns {void}
*/
override componentDidUpdate(previousProps: IProps) {
if (previousProps._googleAPIState === GOOGLE_API_STATES.LOADED
&& this.props._googleAPIState === GOOGLE_API_STATES.SIGNED_IN) {
this._onGetYouTubeBroadcasts();
}
}
/**
* Asks the user to sign in, if not already signed in, and then requests a
* list of the user's YouTube broadcasts.
*
* @private
* @returns {void}
*/
override _onGetYouTubeBroadcasts() {
this.props.dispatch(updateProfile())
.catch((response: any) => this._parseErrorFromResponse(response));
this.props.dispatch(requestAvailableYouTubeBroadcasts())
.then((broadcasts: { boundStreamID: string; }[]) => {
this._setStateIfMounted({
broadcasts
});
if (broadcasts.length === 1) {
const broadcast = broadcasts[0];
this._onYouTubeBroadcastIDSelected(broadcast.boundStreamID);
}
})
.catch((response: any) => this._parseErrorFromResponse(response));
}
/**
* Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts.
*
* @private
* @returns {Promise}
*/
_onGoogleSignIn() {
this.props.dispatch(signIn())
.catch((response: any) => this._parseErrorFromResponse(response));
}
/**
* Forces the Google web client application to prompt for a sign in, such as
* when changing account, and will then fetch available YouTube broadcasts.
*
* @private
* @returns {Promise}
*/
_onRequestGoogleSignIn() {
// when there is an error we show the google sign-in button.
// once we click it we want to clear the error from the state
this.props.dispatch(showAccountSelection())
.then(() =>
this._setStateIfMounted({
broadcasts: undefined,
errorType: undefined
}))
.then(() => this._onGetYouTubeBroadcasts());
}
/**
* Fetches the stream key for a YouTube broadcast and updates the internal
* state to display the associated stream key as being entered.
*
* @param {string} boundStreamID - The bound stream ID associated with the
* broadcast from which to get the stream key.
* @private
* @returns {Promise}
*/
_onYouTubeBroadcastIDSelected(boundStreamID: string) {
this.props.dispatch(
requestLiveStreamsForYouTubeBroadcast(boundStreamID))
.then(({ streamKey, selectedBoundStreamID }: { selectedBoundStreamID: string; streamKey: string; }) =>
this._setStateIfMounted({
streamKey,
selectedBoundStreamID
}));
}
/**
* Only show an error if an external request was made with the Google api.
* Do not error if the login in canceled.
* And searches in a Google API error response for the error type.
*
* @param {Object} response - The Google API response that may contain an
* error.
* @private
* @returns {string|null}
*/
_parseErrorFromResponse(response: any) {
if (!response?.result) {
return;
}
const result = response.result;
const error = result.error;
const errors = error?.errors;
const firstError = errors?.[0];
this._setStateIfMounted({
errorType: firstError?.reason || null
});
}
/**
* Renders a React Element for authenticating with the Google web client.
*
* @private
* @returns {ReactElement}
*/
_renderYouTubePanel() {
const {
t,
_googleProfileEmail
} = this.props;
const {
broadcasts,
selectedBoundStreamID
} = this.state;
let googleContent, helpText;
switch (this.props._googleAPIState) {
case GOOGLE_API_STATES.LOADED:
googleContent
= <GoogleSignInButton onClick = { this._onGoogleSignIn } />;
helpText = t('liveStreaming.signInCTA');
break;
case GOOGLE_API_STATES.SIGNED_IN:
if (broadcasts) {
googleContent = (
<StreamKeyPicker
broadcasts = { broadcasts }
onBroadcastSelected
= { this._onYouTubeBroadcastIDSelected }
selectedBoundStreamID = { selectedBoundStreamID } />
);
} else {
googleContent
= <Spinner />
;
}
helpText = (
<Trans
components = { [ <a
key = 'change-sign-in'
onClick = { this._onRequestGoogleSignIn } /> ] }
i18nKey = 'liveStreaming.chooseCTAWithChangeSignIn'
t = { t }
values = {{ email: _googleProfileEmail }} />
);
break;
case GOOGLE_API_STATES.NEEDS_LOADING:
default:
googleContent
= <Spinner />
;
break;
}
if (this.state.errorType !== undefined) {
googleContent = (
<GoogleSignInButton
onClick = { this._onRequestGoogleSignIn } />
);
helpText = this._getGoogleErrorMessageToDisplay();
}
return (
<div className = 'google-panel'>
<div className = 'live-stream-cta'>
{ helpText }
</div>
<div className = 'google-api'>
{ googleContent }
</div>
</div>
);
}
/**
* Returns the error message to display for the current error state.
*
* @private
* @returns {string} The error message to display.
*/
_getGoogleErrorMessageToDisplay() {
let text;
switch (this.state.errorType) {
case 'liveStreamingNotEnabled':
text = this.props.t(
'liveStreaming.errorLiveStreamNotEnabled',
{ email: this.props._googleProfileEmail });
break;
default:
text = this.props.t('liveStreaming.errorAPI');
break;
}
return <div className = 'google-error'>{ text }</div>;
}
}
/**
* Maps part of the Redux state to the component's props.
*
* @param {Object} state - The Redux state.
* @returns {{
* _googleApiApplicationClientID: string
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
..._abstractMapStateToProps(state),
_googleApiApplicationClientID:
state['features/base/config'].googleApiApplicationClientID
};
}
export default translate(connect(_mapStateToProps)(StartLiveStreamDialog));

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../../base/i18n/functions';
import Dialog from '../../../../base/ui/components/web/Dialog';
import AbstractStopLiveStreamDialog, {
_mapStateToProps
} from '../AbstractStopLiveStreamDialog';
/**
* A React Component for confirming the participant wishes to stop the currently
* active live stream of the conference.
*
* @augments Component
*/
class StopLiveStreamDialog extends AbstractStopLiveStreamDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.stopLiveStreaming' }}
onSubmit = { this._onSubmit }
titleKey = 'dialog.liveStreaming'>
{ this.props.t('dialog.stopStreamingWarning') }
</Dialog>
);
}
}
export default translate(connect(_mapStateToProps)(StopLiveStreamDialog));

View File

@@ -0,0 +1,112 @@
import { Theme } from '@mui/material';
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import Input from '../../../../base/ui/components/web/Input';
import AbstractStreamKeyForm, {
IProps as AbstractProps,
_mapStateToProps
} from '../AbstractStreamKeyForm';
interface IProps extends AbstractProps {
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
}
const styles = (theme: Theme) => {
return {
helperLink: {
cursor: 'pointer',
color: theme.palette.link01,
transition: 'color .2s ease',
...theme.typography.labelBold,
marginLeft: 'auto',
marginTop: theme.spacing(1),
'&:hover': {
textDecoration: 'underline',
color: theme.palette.link01Hover
},
'&:active': {
color: theme.palette.link01Active
}
}
};
};
/**
* A React Component for entering a key for starting a YouTube live stream.
*
* @augments Component
*/
class StreamKeyForm extends AbstractStreamKeyForm<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { t, value } = this.props;
const classes = withStyles.getClasses(this.props);
return (
<div className = 'stream-key-form'>
<Input
autoFocus = { true }
id = 'streamkey-input'
label = { t('dialog.streamKey') }
name = 'streamId'
onChange = { this._onInputChange }
placeholder = { t('liveStreaming.enterStreamKey') }
type = 'text'
value = { value } />
<div className = 'form-footer'>
<div className = 'help-container'>
{
this.state.showValidationError
? <span className = 'warning-text'>
{ t('liveStreaming.invalidStreamKey') }
</span>
: null
}
{ this.props._liveStreaming.helpURL
? <a
className = { classes.helperLink }
href = { this.props._liveStreaming.helpURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.streamIdHelp') }
</a>
: null
}
</div>
<a
className = { classes.helperLink }
href = { this.props._liveStreaming.termsURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.youtubeTerms') }
</a>
<a
className = { classes.helperLink }
href = { this.props._liveStreaming.dataPrivacyURL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.googlePrivacyPolicy') }
</a>
</div>
</div>
);
}
}
export default translate(connect(_mapStateToProps)(withStyles(StreamKeyForm, styles)));

View File

@@ -0,0 +1,126 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { translate } from '../../../../base/i18n/functions';
import Select from '../../../../base/ui/components/web/Select';
import { YOUTUBE_LIVE_DASHBOARD_URL } from '../constants';
/**
* The type of the React {@code Component} props of {@link StreamKeyPicker}.
*/
interface IProps extends WithTranslation {
/**
* Broadcasts available for selection. Each broadcast item should be an
* object with a title for display in the dropdown and a boundStreamID to
* return in the {@link onBroadcastSelected} callback.
*/
broadcasts: Array<{
boundStreamID: string;
title: string;
}>;
/**
* Callback invoked when an item in the dropdown is selected. The selected
* broadcast's boundStreamID will be passed back.
*/
onBroadcastSelected: Function;
/**
* The boundStreamID of the broadcast that should display as selected in the
* dropdown.
*/
selectedBoundStreamID?: string;
}
/**
* A dropdown to select a YouTube broadcast.
*
* @augments Component
*/
class StreamKeyPicker extends PureComponent<IProps> {
/**
* Default values for {@code StreamKeyForm} component's properties.
*
* @static
*/
static defaultProps = {
broadcasts: []
};
/**
* The initial state of a {@code StreamKeyForm} instance.
*/
override state = {
isDropdownOpen: false
};
/**
* Initializes a new {@code StreamKeyPicker} instance.
*
* @param {IProps} props - The React {@code Component} props to initialize
* the new {@code StreamKeyPicker} instance with.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onSelect = this._onSelect.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { broadcasts, selectedBoundStreamID, t } = this.props;
if (!broadcasts.length) {
return (
<a
className = 'warning-text'
href = { YOUTUBE_LIVE_DASHBOARD_URL }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('liveStreaming.getStreamKeyManually') }
</a>
);
}
const dropdownItems
= broadcasts.map(broadcast => {
return {
value: broadcast.boundStreamID,
label: broadcast.title
};
});
return (
<div className = 'broadcast-dropdown dropdown-menu'>
<Select
id = 'streamkeypicker-select'
label = { t('liveStreaming.choose') }
onChange = { this._onSelect }
options = { dropdownItems }
value = { selectedBoundStreamID ?? '' } />
</div>
);
}
/**
* Callback invoked when an item has been clicked in the dropdown menu.
*
* @param {Object} e - The key event to handle.
*
* @returns {void}
*/
_onSelect(e: React.ChangeEvent<HTMLSelectElement>) {
const streamId = e.target.value;
this.props.onBroadcastSelected(streamId);
}
}
export default translate(StreamKeyPicker);

View File

@@ -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 { RecordingTranscriptionDialog } 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('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
}
} ],
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
};
}

View File

@@ -0,0 +1,159 @@
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 { isJwtFeatureEnabled } from '../../../base/jwt/functions';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { maybeShowPremiumFeatureDialog } from '../../../jaas/actions';
import { canAddTranscriber } from '../../../transcribing/functions';
import { canStopRecording, getRecordButtonProps, supportsLocalRecording } from '../../functions';
/**
* The type of the React {@code Component} props of
* {@link AbstractRecordButton}.
*/
export interface IProps extends AbstractButtonProps {
/**
* True if the local participant can start/stop transcription.
*/
_canTranscribe: boolean;
/**
* 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 = 'toolbar.recordAndTranscribe';
override toggledAccessibilityLabel = 'toolbar.recordAndTranscribe';
override icon = IconRecord;
override label = 'toolbar.recordAndTranscribe';
override toggledLabel = 'toolbar.recordAndTranscribe';
override toggledIcon = IconStop;
override _getLabel() {
return this.props._canTranscribe ? super._getLabel() : 'toolbar.record';
}
override _getAccessibilityLabel() {
return this.props._canTranscribe ? super._getAccessibilityLabel() : 'toolbar.record';
}
/**
* 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);
const { localRecording } = state['features/base/config'];
const localRecordingEnabled = !localRecording?.disable && supportsLocalRecording();
const isModerator = isLocalParticipantModerator(state);
const hasRecordingJwt = isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false);
return {
_canTranscribe: canAddTranscriber(state),
_disabled,
_isRecordingRunning: canStopRecording(state),
_tooltip,
visible: visible && (isModerator || localRecordingEnabled || hasRecordingJwt)
};
}

View File

@@ -0,0 +1,844 @@
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 { DEFAULT_LANGUAGE } from '../../../base/i18n/i18next';
import { MEET_FEATURES } from '../../../base/jwt/constants';
import { isJwtFeatureEnabled } from '../../../base/jwt/functions';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import { updateDropboxToken } from '../../../dropbox/actions';
import { getNewAccessToken, isEnabled as isDropboxEnabled } from '../../../dropbox/functions';
import { getDropboxData } from '../../../dropbox/functions.any';
import { showErrorNotification } from '../../../notifications/actions';
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
import { canAddTranscriber, isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import {
setSelectedRecordingService,
setStartRecordingIntent,
setStopRecordingIntent,
startLocalVideoRecording,
stopLocalVideoRecording
} from '../../actions.any';
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../constants';
import {
getActiveSession,
hasRecordingOrTranscriptionFeature,
isRecordingRunning,
isRecordingSharingEnabled,
shouldAutoTranscribeOnRecord,
supportsLocalRecording
} from '../../functions';
import { ISessionData } from '../../reducer';
/**
* The set of start/stop operations to apply on the running services. Each
* operation is applied only when its flag is true.
*/
interface IRecordingChanges {
startRecording?: boolean;
startTranscription?: boolean;
stopRecording?: boolean;
stopTranscription?: boolean;
}
export interface IProps extends WithTranslation {
/**
* The app key for the dropbox authentication.
*/
_appKey: string;
/**
* Requests transcribing when recording is turned on.
*/
_autoTranscribeOnRecord: boolean;
/**
* Whether the local participant can start/stop transcription.
*/
_canTranscribe: boolean;
/**
* The {@code JitsiConference} for the current conference.
*/
_conference?: IJitsiConference;
/**
* Whether subtitles should be displayed or not.
*/
_displaySubtitles?: boolean;
/**
* Active FILE recording session, needed to stop recording.
*/
_fileRecordingSession?: ISessionData;
/**
* 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 the local participant is a moderator.
*/
_isModerator: boolean;
/**
* Whether a local recording is currently active.
*/
_localRecording?: boolean;
/**
* Whether or not local recording is enabled.
*/
_localRecordingEnabled: boolean;
/**
* The dropbox refresh token.
*/
_rToken: string;
/**
* Whether file recording is currently running.
*/
_recordingRunning?: 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;
/**
* Whether transcription is currently running.
*/
_transcriptionRunning?: boolean;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
navigation: any;
/**
* When false, the recording section is collapsed on open, putting the
* focus on the transcription section (e.g. subtitles/nudge flows).
*/
recordAudioAndVideo?: boolean;
}
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 language ("translation-languages:" prefixed) the transcription will
* be saved in.
*/
selectedLanguage: string | null;
/**
* 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;
/**
* 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 & transcription dialog. Exposes independent
* start/stop handlers for each service plus combined "both" handlers; each
* handler applies its changes immediately.
*/
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._onSelectedRecordingServiceChanged
= this._onSelectedRecordingServiceChanged.bind(this);
this._onSharingSettingChanged = this._onSharingSettingChanged.bind(this);
this._toggleScreenshotCapture = this._toggleScreenshotCapture.bind(this);
this._onLocalRecordingSelfChange = this._onLocalRecordingSelfChange.bind(this);
this._onSubtitlesLanguageChanged = this._onSubtitlesLanguageChanged.bind(this);
this._onStartRecording = this._onStartRecording.bind(this);
this._onStopRecording = this._onStopRecording.bind(this);
this._onStartTranscription = this._onStartTranscription.bind(this);
this._onStopTranscription = this._onStopTranscription.bind(this);
this._onStartBoth = this._onStartBoth.bind(this);
this._onStopBoth = this._onStopBoth.bind(this);
this._onStartRecordingPress = this._onStartRecordingPress.bind(this);
this._onStopRecordingPress = this._onStopRecordingPress.bind(this);
this._onStartTranscriptionPress = this._onStartTranscriptionPress.bind(this);
this._onStopTranscriptionPress = this._onStopTranscriptionPress.bind(this);
this._onStartBothPress = this._onStartBothPress.bind(this);
this._onStopBothPress = this._onStopBothPress.bind(this);
this.state = {
isTokenValid: false,
isValidating: false,
userName: undefined,
sharingEnabled: true,
selectedLanguage: props._subtitlesLanguage
?? `translation-languages:${DEFAULT_LANGUAGE}`,
spaceLeft: undefined,
selectedRecordingService: this._getDefaultRecordingService(props),
localRecordingOnlySelf: false
};
}
/**
* Picks the default recording service to preselect from the given props: the file recordings
* service takes priority, then local recording (unless an integration is enabled and preferred
* instead), then Dropbox, in that order. Returns '' if none is available, in which case the
* start recording button is disabled.
*
* @param {IProps} props - The props to compute the default from.
* @returns {string} - One of RECORDING_TYPES, or ''.
*/
_getDefaultRecordingService(props: IProps): string {
if (props._fileRecordingsServiceEnabled) {
return RECORDING_TYPES.JITSI_REC_SERVICE;
}
if (this._areIntegrationsEnabled(props)) {
if (props._localRecordingEnabled && supportsLocalRecording()) {
return RECORDING_TYPES.LOCAL;
}
return RECORDING_TYPES.DROPBOX;
}
if (props._localRecordingEnabled && supportsLocalRecording()) {
return RECORDING_TYPES.LOCAL;
}
return '';
}
/**
* 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();
}
// selectedRecordingService is otherwise only ever set once, from props, in the
// constructor. If none of the services this dialog cares about were available/known yet
// at construction time (e.g. base/config hadn't finished loading when this instance
// mounted), it gets stuck at '' — permanently disabling the start button — even after the
// props that actually determine availability catch up moments later. Recompute it
// whenever those specific props change, but only while nothing has been selected yet, so
// this never overrides an explicit choice made via _onSelectedRecordingServiceChanged.
if (this.state.selectedRecordingService === ''
&& (prevProps._fileRecordingsServiceEnabled !== this.props._fileRecordingsServiceEnabled
|| prevProps._isDropboxEnabled !== this.props._isDropboxEnabled
|| prevProps._localRecordingEnabled !== this.props._localRecordingEnabled)) {
const selectedRecordingService = this._getDefaultRecordingService(this.props);
if (selectedRecordingService) {
this.setState({ selectedRecordingService });
}
}
}
/**
* Returns true if the integrations with third party services are enabled
* and false otherwise.
*
* @param {IProps} props - The props to check against; defaults to this.props.
* @returns {boolean} - True if the integrations with third party services
* are enabled and false otherwise.
*/
_areIntegrationsEnabled(props: IProps = this.props) {
return props._isDropboxEnabled
&& !props._recordingRunning
&& !props._transcriptionRunning;
}
/**
* 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 language changes.
*
* @param {string} selectedLanguage - The new ("translation-languages:"
* prefixed) language.
* @returns {void}
*/
_onSubtitlesLanguageChanged(selectedLanguage: string) {
this.setState({ selectedLanguage });
}
/**
* 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
});
}
});
}
}
/**
* Returns true when the start recording button should be disabled: either
* no recording service is available or the selected one is not ready to
* be used (e.g. Dropbox without a valid sign-in).
*
* @returns {boolean}
*/
_isStartRecordingDisabled() {
const { isTokenValid, selectedRecordingService } = this.state;
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|| selectedRecordingService === RECORDING_TYPES.LOCAL) {
return false;
}
if (selectedRecordingService === RECORDING_TYPES.DROPBOX) {
return !isTokenValid;
}
return true;
}
/**
* Starts the audio & video recording. When the deployment is configured
* with {@code transcription.autoTranscribeOnRecord} the transcription is
* started along with it.
*
* @returns {boolean} - True when the action was applied.
*/
_onStartRecording() {
const { _autoTranscribeOnRecord, _canTranscribe, _transcriptionRunning } = this.props;
return this._applyChanges({
startRecording: true,
startTranscription: _autoTranscribeOnRecord && _canTranscribe && !_transcriptionRunning
});
}
/**
* Stops the audio & video recording, leaving a running transcription
* untouched.
*
* @returns {boolean} - True when the action was applied.
*/
_onStopRecording() {
return this._applyChanges({ stopRecording: true });
}
/**
* Starts the transcription, leaving the recording state untouched.
*
* @returns {boolean} - True when the action was applied.
*/
_onStartTranscription() {
return this._applyChanges({ startTranscription: true });
}
/**
* Stops the transcription, leaving a running recording untouched.
*
* @returns {boolean} - True when the action was applied.
*/
_onStopTranscription() {
return this._applyChanges({ stopTranscription: true });
}
/**
* Starts every service which is not running yet.
*
* @returns {boolean} - True when the action was applied.
*/
_onStartBoth() {
const { _recordingRunning, _transcriptionRunning } = this.props;
return this._applyChanges({
startRecording: !_recordingRunning,
startTranscription: !_transcriptionRunning
});
}
/**
* Stops every running service.
*
* @returns {boolean} - True when the action was applied.
*/
_onStopBoth() {
const { _recordingRunning, _transcriptionRunning } = this.props;
return this._applyChanges({
stopRecording: _recordingRunning,
stopTranscription: _transcriptionRunning
});
}
/**
* Applies the requested recording/transcription changes by starting or
* stopping each service accordingly.
*
* @param {IRecordingChanges} changes - Which services to start/stop.
* @returns {boolean} - True when the changes were applied, false when the
* action could not be performed (e.g. validation failure).
*/
_applyChanges({
startRecording = false,
startTranscription = false,
stopRecording = false,
stopTranscription = false
}: IRecordingChanges) {
const {
_appKey,
_conference,
_displaySubtitles,
_fileRecordingSession,
_isDropboxEnabled,
_localRecording,
_recordingRunning = false,
_rToken,
_subtitlesLanguage,
_token,
_transcriptionRunning = false,
dispatch
} = this.props;
const {
localRecordingOnlySelf,
selectedLanguage,
selectedRecordingService,
sharingEnabled
} = this.state;
// Pre-seed intents synchronously — must happen before any async operations
// so the sound/notification coordinator knows what to wait for.
if (startRecording || startTranscription) {
dispatch(setStartRecordingIntent({
recording: startRecording,
transcription: startTranscription
}));
}
if ((stopRecording || stopTranscription) && !_localRecording) {
dispatch(setStopRecordingIntent({
recording: stopRecording && Boolean(_fileRecordingSession),
transcription: stopTranscription
}));
}
// === Stop recording ===
if (stopRecording) {
sendAnalytics(createRecordingDialogEvent('stop', 'confirm.button'));
if (_localRecording) {
dispatch(stopLocalVideoRecording());
} else if (_fileRecordingSession) {
_conference?.stopRecording(_fileRecordingSession.id);
this._toggleScreenshotCapture();
// Keep isTranscribingEnabled in the metadata if transcription is still running,
// so the metadata listener does not see a false transition on that field.
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isRecordingRequested: false,
...(_transcriptionRunning && !stopTranscription && { isTranscribingEnabled: true })
});
}
}
// === Stop transcription ===
if (stopTranscription) {
const recordingStillRunning = _recordingRunning && !stopRecording;
// When recording is still running, skip the subtitles-internal metadata write and do
// a single write ourselves. This prevents two consecutive metadata transitions
// (false→false on isRecordingRequested, then false→true) from firing spurious
// "recording stopped" and "recording started" sounds and notifications.
// Spread existing recording metadata so we only change isTranscribingEnabled —
// hardcoding isRecordingRequested: true would cause a spurious recordingStarting
// transition if that field was not previously set in the server metadata.
dispatch(setRequestingSubtitles(false, _displaySubtitles, _subtitlesLanguage, true, false, recordingStillRunning));
if (recordingStillRunning) {
const existingRecMeta = _conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID] ?? {};
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
...existingRecMeta,
isTranscribingEnabled: false
});
}
}
// === Start recording ===
if (startRecording) {
let appData;
const attributes: { type?: string; } = {};
switch (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 false;
}
break;
}
case RECORDING_TYPES.JITSI_REC_SERVICE: {
appData = JSON.stringify({
'file_recording_metadata': {
'share': sharingEnabled
}
});
attributes.type = RECORDING_TYPES.JITSI_REC_SERVICE;
break;
}
case RECORDING_TYPES.LOCAL: {
dispatch(startLocalVideoRecording(localRecordingOnlySelf));
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled:
startTranscription || (_transcriptionRunning && !stopTranscription)
});
return true;
}
}
sendAnalytics(createRecordingDialogEvent('start', 'confirm.button', attributes));
this._toggleScreenshotCapture();
_conference?.startRecording({
mode: JitsiRecordingConstants.mode.FILE,
appData
});
}
// === Handle transcription start ===
// JITSI_REC_SERVICE uses setRequestingSubtitles; other services update metadata directly.
if (startTranscription) {
if (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE) {
dispatch(setRequestingSubtitles(
true, _displaySubtitles, selectedLanguage, true, startRecording || _recordingRunning));
} else {
// Spread the existing metadata so that starting transcription while a non-Jitsi
// recording is already running does not drop isRecordingRequested — that would
// read as recording having stopped to the metadata listener (see the stopRecording
// and stopTranscription branches above, which spread for the same reason).
const existingRecMeta = _conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID] ?? {};
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
...existingRecMeta,
...(startRecording && { isRecordingRequested: true }),
isTranscribingEnabled: true
});
}
} else if (startRecording) {
// Recording started without a transcription change: announce it in room metadata so the
// other participants get the recording start notification/sound (this is what the
// metadata listener turns into the remote notification). Preserve any existing
// isTranscribingEnabled so a running transcription is not signalled as stopped.
const existingRecMeta = _conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID] ?? {};
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
...existingRecMeta,
isRecordingRequested: true
});
}
return true;
}
/**
* Toggles screenshot capture feature.
*
* @returns {void}
*/
_toggleScreenshotCapture() {
// To be implemented by subclass.
}
/**
* Dismisses the dialog/screen once an action from the list below has been applied.
*
* @returns {void}
*/
_dismiss() {
// To be implemented by subclass.
}
/**
* Starts the recording and dismisses the dialog/screen.
*
* @returns {void}
*/
_onStartRecordingPress() {
this._onStartRecording() && this._dismiss();
}
/**
* Stops the recording and dismisses the dialog/screen.
*
* @returns {void}
*/
_onStopRecordingPress() {
this._onStopRecording() && this._dismiss();
}
/**
* Starts the transcription and dismisses the dialog/screen.
*
* @returns {void}
*/
_onStartTranscriptionPress() {
this._onStartTranscription() && this._dismiss();
}
/**
* Stops the transcription and dismisses the dialog/screen.
*
* @returns {void}
*/
_onStopTranscriptionPress() {
this._onStopTranscription() && this._dismiss();
}
/**
* Starts every service which is not running yet and dismisses the dialog/screen.
*
* @returns {void}
*/
_onStartBothPress() {
this._onStartBoth() && this._dismiss();
}
/**
* Stops every running service and dismisses the dialog/screen.
*
* @returns {void}
*/
_onStopBothPress() {
this._onStopBoth() && this._dismiss();
}
/**
* 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'];
// Only treat cloud recordings as "running" for users who can actually control them.
// Non-mods without the recording/transcription JWT feature cannot stop cloud sessions,
// so from their perspective only their own local recording counts as "running".
const canManageRecordingOrTranscription = isLocalParticipantModerator(state)
|| hasRecordingOrTranscriptionFeature(state);
return {
_appKey: dropbox.appKey ?? '',
_autoTranscribeOnRecord: shouldAutoTranscribeOnRecord(state),
_canTranscribe: canAddTranscriber(state),
_conference: state['features/base/conference'].conference,
_displaySubtitles,
_fileRecordingSession: getActiveSession(state, JitsiRecordingConstants.mode.FILE),
_fileRecordingsServiceEnabled: recordingService?.enabled ?? false,
_fileRecordingsServiceSharingEnabled: isRecordingSharingEnabled(state),
_isModerator: isLocalParticipantModerator(state),
_isDropboxEnabled: isDropboxEnabled(state),
_localRecording: Boolean(state['features/recording'].localRecordingRunning),
_localRecordingEnabled: !localRecording?.disable,
_recordingRunning: canManageRecordingOrTranscription
? isRecordingRunning(state)
: Boolean(state['features/recording'].localRecordingRunning),
_rToken: state['features/dropbox'].rToken ?? '',
_transcriptionRunning: canManageRecordingOrTranscription
? isRecorderTranscriptionsRunning(state)
: false,
recordAudioAndVideo:
isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false)
? _ownProps.recordAudioAndVideo ?? recordings?.recordAudioAndVideo ?? true : false,
_subtitlesLanguage,
_tokenExpireDate: state['features/dropbox'].expireDate,
_token: state['features/dropbox'].token ?? ''
};
}
export default AbstractStartRecordingDialog;

View File

@@ -0,0 +1,668 @@
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 { isLocalParticipantModerator } from '../../../base/participants/functions';
import { authorizeDropbox, updateDropboxToken } from '../../../dropbox/actions';
import { setFollowMeRecorderExclusive } from '../../../follow-me/actions';
import { isFollowMeActive, isFollowMeRecorderActive } from '../../../follow-me/functions';
import { isVpaasMeeting } from '../../../jaas/functions';
import { getAvailableSubtitlesLanguages } from '../../../subtitles/functions.any';
import { canAddTranscriber, isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { RECORDING_TYPES } from '../../constants';
import { hasRecordingOrTranscriptionFeature, isLiveStreamingRunning, supportsLocalRecording } from '../../functions';
/**
* The type of the React {@code Component} props of
* {@link AbstractStartRecordingDialogContent}.
*/
export interface IProps extends WithTranslation {
/**
* The available ("translation-languages:" prefixed) transcription
* languages.
*/
_availableLanguages: Array<string>;
/**
* Whether the local participant can manage recording/transcription (moderator or holds the
* recording/transcription feature claim).
*/
_canManageRecordingOrTranscription: boolean;
/**
* Whether the local participant can start transcribing.
*/
_canStartTranscribing: boolean;
/**
* Style of the dialogs feature.
*/
_dialogStyles: any;
/**
* Whether any moderator has the regular Follow Me feature active.
*/
_followMeActive: boolean;
/**
* Whether any moderator has the recorder Follow Me feature active.
*/
_followMeRecorderActive: boolean;
/**
* Whether the local participant has the recorder Follow Me setting enabled.
*/
_followMeRecorderEnabled: boolean;
/**
* Whether to hide the storage warning or not.
*/
_hideStorageWarning: boolean;
/**
* Whether a live stream session is currently active.
*/
_isLiveStreamRunning: boolean;
/**
* Whether the local participant is a moderator.
*/
_isModerator: 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 a local recording is currently in progress.
*/
_localRecordingRunning: 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;
/**
* Whether transcription is currently running.
*/
_transcriptionRunning: boolean;
/**
* CSS classes object (web only).
*/
classes?: Partial<Record<string, string>>;
/**
* 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;
/**
* Callback to change the local recording only self setting.
*/
onLocalRecordingSelfChange?: () => void;
/**
* The function will be called when the selected recording service changes.
*/
onRecordingServiceChange: (service: string) => void;
/**
* Callback to be invoked on sharing setting change.
*/
onSharingSettingChanged: () => void;
/**
* Starts every service which is not running.
*/
onStartBoth: () => void;
/**
* Starts the audio & video recording.
*/
onStartRecording: () => void;
/**
* Starts the transcription.
*/
onStartTranscription: () => void;
/**
* Stops every running service.
*/
onStopBoth: () => void;
/**
* Stops the audio & video recording.
*/
onStopRecording: () => void;
/**
* Stops the transcription.
*/
onStopTranscription: () => void;
/**
* Callback to be invoked when the transcription language changes.
*/
onSubtitlesLanguageChange: (language: string) => void;
/**
* When false, the recording section starts collapsed and the
* transcription section starts expanded, putting the focus on
* transcription (e.g. subtitles/nudge flows).
*/
recordAudioAndVideo?: boolean;
/**
* When true, audio/video recording is specifically in progress.
*/
recordingRunning: boolean;
/**
* The ("translation-languages:" prefixed) language the transcription will
* be saved in.
*/
selectedLanguage: string | null;
/**
* The currently selected recording service of type: RECORDING_TYPES.
*/
selectedRecordingService: string | null;
/**
* Boolean to set file recording sharing on or off.
*/
sharingSetting: boolean;
/**
* Number of MiB of available space in user's Dropbox account.
*/
spaceLeft?: number;
/**
* Whether the start recording action is currently unavailable (no usable
* service selected).
*/
startRecordingDisabled: boolean;
/**
* Whether transcription is currently running (from the dialog's point of
* view, i.e. Only when the local participant can control it).
*/
transcriptionRunning: boolean;
/**
* The display name of the user's Dropbox account.
*/
userName?: string;
}
export interface IState {
/**
* Whether the language list is expanded (used by the native inline
* picker; the web renders a combobox instead).
*/
showLanguageList: boolean;
/**
* Whether the recording section options are expanded.
*/
showRecordingOptions: boolean;
/**
* Whether the transcription section options are expanded.
*/
showTranscriptionOptions: boolean;
}
/**
* React Component for the recording & transcription dialog content: two
* sections (audio & video recording, transcription), each with its own
* start/stop button and a collapsible options area, plus footer buttons
* acting on both services at once.
*
* @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._onRecordingServiceChange = this._onRecordingServiceChange.bind(this);
this._onFollowMeRecorderChange = this._onFollowMeRecorderChange.bind(this);
this._onToggleRecordingOptions = this._onToggleRecordingOptions.bind(this);
this._onToggleTranscriptionOptions = this._onToggleTranscriptionOptions.bind(this);
this._onToggleLanguageList = this._onToggleLanguageList.bind(this);
// Both sections start collapsed, except transcription options are pre-expanded when a
// caller (e.g. the transcription nudge) explicitly asks to focus transcription instead
// of recording by passing recordAudioAndVideo: false.
this.state = {
showLanguageList: false,
showRecordingOptions: false,
showTranscriptionOptions: props.recordAudioAndVideo === false
};
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
// Auto sign-out when the user chooses another recording service.
if (prevProps.selectedRecordingService === RECORDING_TYPES.DROPBOX
&& this.props.selectedRecordingService !== RECORDING_TYPES.DROPBOX && this.props.isTokenValid) {
this._onSignOut();
}
}
/**
* Toggles the recording section options.
*
* @returns {void}
*/
_onToggleRecordingOptions() {
this.setState({ showRecordingOptions: !this.state.showRecordingOptions });
}
/**
* Toggles the transcription section options.
*
* @returns {void}
*/
_onToggleTranscriptionOptions() {
this.setState({ showTranscriptionOptions: !this.state.showTranscriptionOptions });
}
/**
* Toggles the inline language list.
*
* @returns {void}
*/
_onToggleLanguageList() {
this.setState({ showLanguageList: !this.state.showLanguageList });
}
/**
* Returns the list of recording services (RECORDING_TYPES values) the
* participant can currently pick from.
*
* Cloud based services (Jitsi recording service, Dropbox) require the
* recording JWT feature and are unavailable while a live stream runs
* (both use Jibri). Dropbox is additionally unavailable while a session
* is running (integrationsEnabled covers that).
*
* @returns {Array<string>}
*/
_getRecordingServiceOptions(): Array<string> {
const {
_isLiveStreamRunning,
_localRecordingAvailable,
_renderRecording,
fileRecordingsServiceEnabled,
integrationsEnabled
} = this.props;
const options = [];
if (_renderRecording && !_isLiveStreamRunning) {
if (fileRecordingsServiceEnabled) {
options.push(RECORDING_TYPES.JITSI_REC_SERVICE);
}
if (integrationsEnabled) {
options.push(RECORDING_TYPES.DROPBOX);
}
}
if (_localRecordingAvailable) {
options.push(RECORDING_TYPES.LOCAL);
}
return options;
}
/**
* Whether the audio & video recording section should be rendered.
*
* @param {Array<string>} [options] - The result of {@code _getRecordingServiceOptions()},
* when the caller already computed it (avoids recomputing it a second time).
* @returns {boolean}
*/
_shouldRenderRecordingSection(options: Array<string> = this._getRecordingServiceOptions()) {
return this.props.recordingRunning || options.length > 0;
}
/**
* Whether the transcription section should be rendered.
*
* @returns {boolean}
*/
_shouldRenderTranscriptionSection() {
return this._canStartTranscribing() || this.props.transcriptionRunning;
}
/**
* Whether the footer buttons (start both / stop both) should be rendered.
* They only make sense when the participant can act on both services.
*
* @returns {boolean}
*/
_shouldRenderFooter() {
return this._shouldRenderRecordingSection() && this._shouldRenderTranscriptionSection();
}
/**
* 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 recorder follow me option should be rendered: moderators
* only, and only for the cloud based services which spawn a recorder
* participant that can follow (local recordings record the own view).
*
* @returns {boolean}
*/
_shouldRenderFollowMeRecorder() {
const { _isModerator, selectedRecordingService } = this.props;
return _isModerator
&& (selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
|| selectedRecordingService === RECORDING_TYPES.DROPBOX);
}
/**
* Whether the save transcription content should be rendered or not.
*
* @returns {boolean}
*/
_canStartTranscribing() {
return this.props._canStartTranscribing;
}
/**
* Returns the translation key labelling the given recording service in
* the storage service picker.
*
* @param {string} service - One of the RECORDING_TYPES values.
* @returns {string}
*/
_getServiceLabelKey(service: string | null) {
switch (service) {
case RECORDING_TYPES.JITSI_REC_SERVICE:
return this.props.isVpaas ? 'recording.serviceDescriptionCloud' : 'recording.serviceName';
case RECORDING_TYPES.DROPBOX:
return 'recording.dropbox';
case RECORDING_TYPES.LOCAL:
return 'recording.localRecording';
}
return '';
}
/**
* Returns the one line summary of the recording options, shown in the
* collapsed accordion header: the selected storage service followed by
* the state of its suboptions.
*
* @returns {string}
*/
_getRecordingSummary() {
const {
isTokenValid,
localRecordingOnlySelf,
selectedRecordingService,
sharingSetting,
t,
userName
} = this.props;
if (!selectedRecordingService) {
return '';
}
const parts = [ t(this._getServiceLabelKey(selectedRecordingService)) ];
switch (selectedRecordingService) {
case RECORDING_TYPES.JITSI_REC_SERVICE:
if (this._shouldRenderFileSharingContent()) {
parts.push(t(sharingSetting ? 'recording.linkShared' : 'recording.linkNotShared'));
}
break;
case RECORDING_TYPES.DROPBOX:
parts.push(isTokenValid ? t('recording.loggedIn', { userName }) : t('recording.notSignedIn'));
break;
case RECORDING_TYPES.LOCAL:
if (localRecordingOnlySelf) {
parts.push(t('recording.onlyRecordSelf'));
}
break;
}
if (this._shouldRenderFollowMeRecorder() && this._isFollowMeRecorderChecked()) {
parts.push(t('settings.followMeRecorder'));
}
return parts.join(' · ');
}
/**
* Returns the one line summary of the transcription options, shown in the
* collapsed accordion header: the selected language.
*
* @returns {string}
*/
_getTranscriptionSummary() {
const { selectedLanguage, t } = this.props;
return selectedLanguage ? t(selectedLanguage) : '';
}
/**
* Handler for recording service selection changes.
*
* @param {string} service - The newly selected service.
* @returns {void}
*/
_onRecordingServiceChange(service: string) {
const { isTokenValid, onRecordingServiceChange, selectedRecordingService } = this.props;
if (service === selectedRecordingService) {
return;
}
onRecordingServiceChange(service);
if (service === RECORDING_TYPES.DROPBOX && !isTokenValid) {
this._onSignIn();
}
}
/**
* Handler for the recorder follow me setting.
*
* @param {boolean} enabled - The new value.
* @returns {void}
*/
_onFollowMeRecorderChange(enabled?: boolean) {
const value = enabled ?? !this.props._followMeRecorderEnabled;
this.props.dispatch(setFollowMeRecorderExclusive(value));
}
/**
* Whether the recorder follow me option is checked.
*
* @returns {boolean}
*/
_isFollowMeRecorderChecked() {
const { _followMeRecorderActive, _followMeRecorderEnabled } = this.props;
return _followMeRecorderEnabled && !_followMeRecorderActive;
}
/**
* Whether the recorder follow me option is disabled: another moderator
* has one of the follow me modes active, or a recording is already in
* progress — the recorder cannot pick up the change after it started.
*
* @returns {boolean}
*/
_isFollowMeRecorderDisabled() {
const { _followMeActive, _followMeRecorderActive, recordingRunning } = this.props;
return _followMeActive || _followMeRecorderActive || recordingRunning;
}
/**
* 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();
const canManageRecordingOrTranscription
= isLocalParticipantModerator(state) || hasRecordingOrTranscriptionFeature(state);
const { followMeRecorderEnabled } = state['features/follow-me'];
const subtitlesLanguage = state['features/subtitles']._language?.replace('translation-languages:', '');
return {
..._abstractMapStateToProps(state),
isVpaas: isVpaasMeeting(state),
_availableLanguages: getAvailableSubtitlesLanguages(state, subtitlesLanguage)
.map((lang: string) => `translation-languages:${lang}`),
_canManageRecordingOrTranscription: canManageRecordingOrTranscription,
_canStartTranscribing: canAddTranscriber(state),
_followMeActive: isFollowMeActive(state),
_followMeRecorderActive: isFollowMeRecorderActive(state),
_followMeRecorderEnabled: Boolean(followMeRecorderEnabled),
_isLiveStreamRunning: isLiveStreamingRunning(state),
_hideStorageWarning: Boolean(recordingService?.hideStorageWarning),
_isModerator: isLocalParticipantModerator(state),
_renderRecording: isJwtFeatureEnabled(state, MEET_FEATURES.RECORDING, false),
_transcriptionRunning: canManageRecordingOrTranscription ? isRecorderTranscriptionsRunning(state) : false,
_localRecordingAvailable,
_localRecordingEnabled: !localRecording?.disable,
_localRecordingRunning: Boolean(state['features/recording'].localRecordingRunning),
_localRecordingSelfEnabled: !localRecording?.disableSelfRecording,
_localRecordingNoNotification: !localRecording?.notifyAllParticipants,
_styles: ColorSchemeRegistry.get(state, 'StartRecordingDialogContent')
};
}
export default AbstractStartRecordingDialogContent;

View File

@@ -0,0 +1,195 @@
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 { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { setStopRecordingIntent, 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;
/**
* Whether a transcription session is running.
*/
_transcriptionRunning: boolean;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The user trying to stop the video while local recording is running.
*/
localRecordingVideoStop?: boolean;
/**
* Controls which service is stopped: 'recording' stops the file recording
* session, 'transcription' stops only transcription and leaves recording
* running.
*/
stopMode: 'recording' | 'transcription';
}
/**
* 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,
_transcriptionRunning,
dispatch,
localRecordingVideoStop,
stopMode
} = this.props;
const stoppingRecording = stopMode === 'recording';
const stoppingTranscription = stopMode === 'transcription';
// Pre-seed stopRecordingIntent so the off-sound/notification coordinator
// (maybeNotifyRecordingStop) knows what to wait for. Each button only
// signals the service it is responsible for stopping.
if (!_localRecording) {
const recordingRunning = stoppingRecording && Boolean(_fileRecordingSession);
const transcriptionRunning = stoppingTranscription && _transcriptionRunning;
if (recordingRunning || transcriptionRunning) {
dispatch(setStopRecordingIntent({
recording: recordingRunning,
transcription: transcriptionRunning
}));
}
}
if (stoppingRecording) {
if (_localRecording) {
dispatch(stopLocalVideoRecording());
if (localRecordingVideoStop) {
dispatch(setVideoMuted(true));
}
} else if (_fileRecordingSession) {
_conference?.stopRecording(_fileRecordingSession.id);
this._toggleScreenshotCapture();
}
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isRecordingRequested: false
});
}
if (stoppingTranscription) {
// TODO: this should be an action in transcribing. -saghul
dispatch(
setRequestingSubtitles(Boolean(_displaySubtitles), _displaySubtitles, _subtitlesLanguage, true));
_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,
_transcriptionRunning: isRecorderTranscriptionsRunning(state)
};
}

View File

@@ -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;

View File

@@ -0,0 +1,395 @@
// @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.clone());
videoTrack && localTracks.push(videoTrack.clone());
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];
if (supportsCaptureHandle) {
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
});
// Serialize all chunk writes so the stop handler can await them before closing the
// stream. Without this, the stop event fires concurrently with the last dataavailable
// callback's async fixDuration call, and close() races the write — leaving an empty
// file for recordings shorter than the 5-second timeslice.
let writeQueue: Promise<void> = Promise.resolve();
this.recorder.addEventListener('dataavailable', e => {
if (e.data && e.data.size > 0) {
const chunk = e.data;
writeQueue = writeQueue.then(async () => {
let data = chunk;
if (!this.firstChunk) {
this.firstChunk = data = await fixDuration(chunk, 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());
this.recorder = undefined;
this.audioContext?.close();
this.audioContext = undefined;
this.audioDestination = undefined;
this.startTime = undefined;
this.stream = undefined;
this.selfRecording.on = false;
this.selfRecording.withVideo = false;
// Wait for all pending dataavailable writes before closing the stream.
await writeQueue;
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;

View File

@@ -0,0 +1,2 @@
export { default as RecordingTranscriptionDialog } from './native/RecordingTranscriptionDialog';
export { default as RecordingConsentDialog } from './native/RecordingConsentDialog';

View File

@@ -0,0 +1,2 @@
export { default as RecordingTranscriptionDialog } from './web/RecordingTranscriptionDialog';
export { default as RecordingConsentDialog } from './web/RecordingConsentDialog';

View File

@@ -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));

View File

@@ -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;

View File

@@ -0,0 +1,60 @@
import { Platform } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
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';
type Props = IProps & AbstractProps;
/**
* Button for opening the recording/transcription management screen.
* The screen handles both starting a new session and managing a running one.
*/
class RecordButton extends AbstractRecordButton<Props> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
_onHandleClick() {
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));

View File

@@ -0,0 +1,67 @@
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 { IRecordingConsentDialogProps } from '../../../reducer';
import styles from '../styles.native';
/**
* Component that renders the dialog for explicit consent for recordings.
*
* @param {IRecordingConsentDialogProps} props - The component props.
* @returns {JSX.Element}
*/
export default function RecordingConsentDialog({ audioWasMuted = false, videoWasMuted = false }: IRecordingConsentDialogProps) {
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));
// Restore to the mute state before consent was requested.
dispatch(setAudioMuted(audioWasMuted, false));
dispatch(setVideoMuted(videoWasMuted, VIDEO_MUTISM_AUTHORITY.USER, false));
return true;
}, [ audioWasMuted, videoWasMuted ]);
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.recordingInProgressDescription')}
{consentLearnMoreLink && (
<Link
style = { styles.learnMoreLink }
url = { consentLearnMoreLink }>
{` (${t('dialog.learnMore')})`}
</Link>
)}
{'.'}
</Dialog.Description>
</ConfirmDialog>
);
}

View File

@@ -0,0 +1,91 @@
import React, { useCallback } from 'react';
import { TouchableOpacity, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import Icon from '../../../../base/icons/components/Icon';
import { IconCheck } from '../../../../base/icons/svg';
import BaseTheme from '../../../../base/ui/components/BaseTheme.native';
interface IProps {
/**
* Whether the row can be selected.
*/
disabled?: boolean;
/**
* The already translated label of the row.
*/
label: string;
/**
* Callback invoked with the row value on selection.
*/
onSelect: (value: string) => void;
/**
* Whether the row is the selected one.
*/
selected: boolean;
/**
* The value the row represents.
*/
value: string;
}
const styles = {
row: {
alignItems: 'center',
flexDirection: 'row',
paddingVertical: BaseTheme.spacing[2]
} as ViewStyle,
iconWrapper: {
alignItems: 'center',
width: BaseTheme.spacing[5]
} as ViewStyle,
label: {
color: BaseTheme.palette.text01
},
labelSelected: {
fontWeight: 'bold' as const
},
disabled: {
opacity: 0.5
}
};
/**
* A selectable row of the inline pickers (storage service, transcription
* language) used by the native recording dialog.
*
* @returns {JSX.Element}
*/
const RecordingOptionRow = ({ disabled, label, onSelect, selected, value }: IProps) => {
const onPress = useCallback(() => onSelect(value), [ onSelect, value ]);
return (
<TouchableOpacity
accessibilityLabel = { label }
accessibilityRole = 'button'
accessibilityState = {{ disabled: Boolean(disabled), selected }}
disabled = { disabled }
onPress = { onPress }
style = { [ styles.row, disabled && styles.disabled ] }>
<View style = { styles.iconWrapper }>
{ selected && <Icon
size = { 20 }
src = { IconCheck } /> }
</View>
<Text style = { [ styles.label, selected && styles.labelSelected ] }>
{ label }
</Text>
</TouchableOpacity>
);
};
export default RecordingOptionRow;

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { ScrollView } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { translate } from '../../../../base/i18n/functions';
import JitsiScreen from '../../../../base/modal/components/JitsiScreen';
import { goBack } from
'../../../../mobile/navigation/components/conference/ConferenceNavigationContainerRef';
import AbstractStartRecordingDialog, {
mapStateToProps as abstractMapStateToProps
} from '../AbstractStartRecordingDialog';
import styles from '../styles.native';
import StartRecordingDialogContent from './StartRecordingDialogContent';
/**
* React Component for managing a recording/transcription session on native.
* Each section (audio & video recording, transcription) has its own
* start/stop button which applies the action immediately and navigates back.
*
* @augments Component
*/
class RecordingTranscriptionDialog extends AbstractStartRecordingDialog {
/**
* Dismisses the screen by navigating back.
*
* @inheritdoc
* @returns {void}
*/
override _dismiss() {
goBack();
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const {
_fileRecordingsServiceEnabled,
_fileRecordingsServiceSharingEnabled,
_recordingRunning,
_transcriptionRunning,
recordAudioAndVideo
} = this.props;
const {
isTokenValid,
isValidating,
localRecordingOnlySelf,
selectedLanguage,
selectedRecordingService,
sharingEnabled,
spaceLeft,
userName
} = this.state;
return (
<JitsiScreen style = { styles.startRecodingContainer }>
<ScrollView>
<StartRecordingDialogContent
fileRecordingsServiceEnabled = { _fileRecordingsServiceEnabled }
fileRecordingsServiceSharingEnabled = { _fileRecordingsServiceSharingEnabled }
integrationsEnabled = { this._areIntegrationsEnabled() }
isTokenValid = { isTokenValid }
isValidating = { isValidating }
localRecordingOnlySelf = { localRecordingOnlySelf }
onLocalRecordingSelfChange = { this._onLocalRecordingSelfChange }
onRecordingServiceChange = { this._onSelectedRecordingServiceChanged }
onSharingSettingChanged = { this._onSharingSettingChanged }
onStartBoth = { this._onStartBothPress }
onStartRecording = { this._onStartRecordingPress }
onStartTranscription = { this._onStartTranscriptionPress }
onStopBoth = { this._onStopBothPress }
onStopRecording = { this._onStopRecordingPress }
onStopTranscription = { this._onStopTranscriptionPress }
onSubtitlesLanguageChange = { this._onSubtitlesLanguageChanged }
recordAudioAndVideo = { recordAudioAndVideo }
recordingRunning = { Boolean(_recordingRunning) }
selectedLanguage = { selectedLanguage }
selectedRecordingService = { selectedRecordingService }
sharingSetting = { sharingEnabled }
spaceLeft = { spaceLeft }
startRecordingDisabled = { this._isStartRecordingDisabled() }
transcriptionRunning = { Boolean(_transcriptionRunning) }
userName = { userName } />
</ScrollView>
</JitsiScreen>
);
}
}
/**
* Maps redux state to component props, bridging the navigation route params
* (native-only) into the shared {@code recordAudioAndVideo} prop.
*
* @param {Object} state - Redux state.
* @param {any} ownProps - Component's own props. Kept as `any`: this component is registered
* directly as a react-navigation Screen (see ConferenceNavigationContainer.tsx), whose generic
* `route`/`navigation` prop types don't compose with a narrower own-props type here without
* modeling the whole navigation ParamList, which AbstractStartRecordingDialog's own
* mapStateToProps(_ownProps: any) already opts out of for the same reason.
* @returns {Object}
*/
function mapStateToProps(state: IReduxState, ownProps: any) {
return {
...abstractMapStateToProps(state, {
...ownProps,
recordAudioAndVideo: ownProps.recordAudioAndVideo ?? ownProps.route?.params?.recordAudioAndVideo
})
};
}
export default translate(connect(mapStateToProps)(RecordingTranscriptionDialog));

View File

@@ -0,0 +1,532 @@
import React from 'react';
import { Image, TouchableOpacity, 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 LanguageListItem from '../../../../subtitles/components/native/LanguageListItem';
import { RECORDING_TYPES } from '../../../constants';
import { getRecordingDurationEstimation } from '../../../functions';
import AbstractStartRecordingDialogContent, { IProps, mapStateToProps } from '../AbstractStartRecordingDialogContent';
import {
ICON_INFO,
ICON_USERS
} from '../styles.native';
import RecordingOptionRow from './RecordingOptionRow';
/**
* The recording & transcription dialog content for the mobile application.
*/
class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
/**
* Initializes a new {@code StartRecordingDialogContent} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onLanguageSelected = this._onLanguageSelected.bind(this);
}
/**
* Handles a language selection from the inline language list: applies it
* and collapses the list.
*
* @param {string} language - The selected ("translation-languages:"
* prefixed) language.
* @returns {void}
*/
_onLanguageSelected(language: string) {
this.props.onSubtitlesLanguageChange(language);
this.setState({ showLanguageList: false });
}
/**
* Renders the component.
*
* @protected
* @returns {React$Component}
*/
override render() {
const { _styles: styles } = this.props;
return (
<View style = { styles.container }>
{ this._renderRecordingSection() }
{ this._renderTranscriptionSection() }
{ this._renderFooter() }
</View>
);
}
/**
* Renders a section header: title and the start/stop button of the
* section's service.
*
* @param {Object} options - Rendering options.
* @returns {React$Component}
*/
_renderSectionHeader({ onButtonPress, running, startDisabled, startLabelKey, stopLabelKey, titleKey }: {
onButtonPress: () => void;
running: boolean;
startDisabled?: boolean;
startLabelKey: string;
stopLabelKey: string;
titleKey: string;
}) {
const { _dialogStyles, _styles: styles, t } = this.props;
return (
<View style = { styles.header }>
<Text
style = {{
..._dialogStyles.text,
...styles.title
}}>
{ t(titleKey) }
</Text>
<Button
accessibilityLabel = { running ? stopLabelKey : startLabelKey }
disabled = { !running && startDisabled }
labelKey = { running ? 'dialog.stop' : 'dialog.start' }
onClick = { onButtonPress }
type = { running ? BUTTON_TYPES.DESTRUCTIVE : BUTTON_TYPES.PRIMARY } />
</View>
);
}
/**
* Renders the accordion wrapping a section's options: a header with the
* options title, a one line summary of the current choices and a chevron,
* followed by the options themselves when expanded.
*
* @param {Object} options - Rendering options.
* @returns {React$Component}
*/
_renderAccordion({ body, expanded, onToggle, summary, titleKey }: {
body: React.ReactNode;
expanded: boolean;
onToggle: () => void;
summary: string;
titleKey: string;
}) {
const { _styles: styles, t } = this.props;
return (
<View style = { styles.accordion }>
<TouchableOpacity
accessibilityLabel = { t(titleKey) }
accessibilityRole = 'button'
accessibilityState = {{ expanded }}
onPress = { onToggle }
style = { styles.accordionHeader }>
<View style = { styles.accordionText }>
<Text style = { styles.accordionTitle }>
{ t(titleKey) }
</Text>
{ !expanded && (
<Text
numberOfLines = { 1 }
style = { styles.accordionSummary }>
{ summary }
</Text>
) }
</View>
<Icon
size = { 18 }
src = { IconArrowDown }
style = { expanded ? styles.accordionChevronOpen : undefined } />
</TouchableOpacity>
{ expanded && (
<View style = { styles.accordionBody }>
{ body }
</View>
) }
</View>
);
}
/**
* Renders the audio & video recording section.
*
* @returns {React$Component}
*/
_renderRecordingSection() {
const recordingServiceOptions = this._getRecordingServiceOptions();
if (!this._shouldRenderRecordingSection(recordingServiceOptions)) {
return null;
}
const {
_dialogStyles,
_styles: styles,
isValidating,
onStartRecording,
onStopRecording,
recordingRunning,
selectedRecordingService,
startRecordingDisabled,
t
} = this.props;
const { showRecordingOptions } = this.state;
return (
<View style = { styles.section }>
{ this._renderSectionHeader({
onButtonPress: recordingRunning ? onStopRecording : onStartRecording,
running: recordingRunning,
startDisabled: startRecordingDisabled || isValidating,
startLabelKey: 'dialog.startRecording',
stopLabelKey: 'dialog.stopRecording',
titleKey: 'recording.recordAudioAndVideo'
}) }
{ this._renderAccordion({
body: (
<>
<Text
style = {{
..._dialogStyles.text,
...styles.pickerLabel
}}>
{ t('recording.storageLocation') }
</Text>
{ recordingServiceOptions.map(service => (
<RecordingOptionRow
disabled = { recordingRunning || isValidating }
key = { service }
label = { t(this._getServiceLabelKey(service)) }
onSelect = { this._onRecordingServiceChange }
selected = { selectedRecordingService === service }
value = { service } />
)) }
{ selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
&& this._renderJitsiServiceOptions() }
{ selectedRecordingService === RECORDING_TYPES.DROPBOX
&& this._renderDropboxOptions() }
{ this._renderFollowMeRecorder() }
</>
),
expanded: showRecordingOptions,
onToggle: this._onToggleRecordingOptions,
// Only rendered by _renderAccordion while collapsed — skip computing it
// (and its nested t() calls) while the section is expanded.
summary: showRecordingOptions ? '' : this._getRecordingSummary(),
titleKey: 'recording.recordingOptions'
}) }
</View>
);
}
/**
* Renders the options of the Jitsi recording service: the recording link
* sharing switch and the vpaas storage warning.
*
* @returns {React$Component}
*/
_renderJitsiServiceOptions() {
const {
_dialogStyles,
_styles: styles,
isValidating,
onSharingSettingChanged,
recordingRunning,
sharingSetting,
t
} = this.props;
return (
<>
{ this._shouldRenderFileSharingContent() && (
<View
key = 'fileSharingSetting'
style = { styles.headerNested }>
<Image
source = { ICON_USERS }
style = { styles.recordingIcon } />
<Text
style = {{
..._dialogStyles.text,
...styles.optionLabel
}}>
{ t('recording.fileSharingdescription') }
</Text>
<Switch
checked = { sharingSetting }
disabled = { isValidating || recordingRunning }
onChange = { onSharingSettingChanged }
style = { styles.switch } />
</View>
) }
{ this._renderUploadToTheCloudInfo() }
</>
);
}
/**
* Renders the info in case recording is uploaded to the cloud.
*
* @returns {React$Component}
*/
_renderUploadToTheCloudInfo() {
const {
_dialogStyles,
_hideStorageWarning,
_styles: styles,
isVpaas,
t
} = this.props;
if (!isVpaas || _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 the Dropbox account area: sign in button, or account info plus
* sign out button once authorized.
*
* @returns {React$Component}
*/
_renderDropboxOptions() {
const { _dialogStyles, _styles: styles, isTokenValid, isValidating, spaceLeft, t, userName } = this.props;
if (isValidating) {
return (
<View style = { styles.loggedIn }>
<LoadingIndicator size = 'small' />
</View>
);
}
if (!isTokenValid) {
return (
<View style = { styles.headerNested }>
<Text
style = {{
..._dialogStyles.text,
...styles.optionLabel
}}>
{ t('recording.dropboxSignInPrompt') }
</Text>
<Button
accessibilityLabel = 'recording.signIn'
labelKey = 'recording.signIn'
onClick = { this._onSignIn }
type = { BUTTON_TYPES.PRIMARY } />
</View>
);
}
const duration = getRecordingDurationEstimation(spaceLeft);
return (
<View style = { styles.headerNested }>
<View style = { styles.loggedInInfo }>
<Text
style = { [
styles.text,
styles.recordingText
] }>
{ t('recording.loggedIn', { userName }) }
</Text>
<Text
style = { [
styles.text,
styles.recordingText
] }>
{
t('recording.availableSpace', {
spaceLeft,
duration
})
}
</Text>
</View>
<Button
accessibilityLabel = 'recording.signOut'
labelKey = 'recording.signOut'
onClick = { this._onSignOut }
type = { BUTTON_TYPES.SECONDARY } />
</View>
);
}
/**
* Renders the recorder follow me switch for cloud based recordings.
*
* @returns {React$Component}
*/
_renderFollowMeRecorder() {
if (!this._shouldRenderFollowMeRecorder()) {
return null;
}
const { _dialogStyles, _styles: styles, t } = this.props;
return (
<View style = { styles.headerNested }>
<Text
style = {{
..._dialogStyles.text,
...styles.optionLabel
}}>
{ t('settings.followMeRecorder') }
</Text>
<Switch
checked = { this._isFollowMeRecorderChecked() }
disabled = { this._isFollowMeRecorderDisabled() }
onChange = { this._onFollowMeRecorderChange }
style = { styles.switch } />
</View>
);
}
/**
* Renders the transcription section with the inline language picker.
*
* @returns {React$Component}
*/
_renderTranscriptionSection() {
if (!this._shouldRenderTranscriptionSection()) {
return null;
}
const {
_availableLanguages,
_dialogStyles,
_styles: styles,
onStartTranscription,
onStopTranscription,
selectedLanguage,
t,
transcriptionRunning
} = this.props;
const { showLanguageList, showTranscriptionOptions } = this.state;
return (
<View style = { styles.section }>
{ this._renderSectionHeader({
onButtonPress: transcriptionRunning ? onStopTranscription : onStartTranscription,
running: transcriptionRunning,
startLabelKey: 'dialog.startTranscription',
stopLabelKey: 'dialog.stopTranscription',
titleKey: 'recording.recordTranscription'
}) }
{ this._renderAccordion({
body: (
<>
<View style = { styles.headerNested }>
<Text
style = {{
..._dialogStyles.text,
...styles.pickerLabel
}}>
{ `${t('recording.transcriptionLanguage')}: ${t(selectedLanguage ?? '')}` }
</Text>
<Icon
ariaPressed = { showLanguageList }
onClick = { transcriptionRunning ? undefined : this._onToggleLanguageList }
role = 'button'
size = { 24 }
src = { showLanguageList ? IconArrowDown : IconArrowRight } />
</View>
{ showLanguageList && !transcriptionRunning
&& _availableLanguages.map((language: string) => (
<LanguageListItem
key = { language }
lang = { language }
onLanguageSelected = { this._onLanguageSelected }
selected = { selectedLanguage === language } />
)) }
</>
),
expanded: showTranscriptionOptions,
onToggle: this._onToggleTranscriptionOptions,
// Only rendered by _renderAccordion while collapsed — skip computing it while
// the section is expanded.
summary: showTranscriptionOptions ? '' : this._getTranscriptionSummary(),
titleKey: 'recording.transcriptionOptions'
}) }
</View>
);
}
/**
* Renders the footer buttons acting on both services at once. When only
* one service is running both buttons are shown: stop both stops the
* running one, start both starts the missing one.
*
* @returns {React$Component}
*/
_renderFooter() {
if (!this._shouldRenderFooter()) {
return null;
}
const {
_styles: styles,
isValidating,
onStartBoth,
onStopBoth,
recordingRunning,
startRecordingDisabled,
transcriptionRunning
} = this.props;
const showStopBoth = recordingRunning || transcriptionRunning;
const showStartBoth = !recordingRunning || !transcriptionRunning;
const startBothDisabled = !recordingRunning && (startRecordingDisabled || isValidating);
return (
<View style = { styles.footer }>
{ showStopBoth && (
<Button
accessibilityLabel = 'dialog.stopBoth'
labelKey = 'dialog.stopBoth'
onClick = { onStopBoth }
style = { styles.footerButton }
type = { BUTTON_TYPES.DESTRUCTIVE } />
) }
{ showStartBoth && (
<Button
accessibilityLabel = 'dialog.startBoth'
disabled = { startBothDisabled }
labelKey = 'dialog.startBoth'
onClick = { onStartBoth }
style = { styles.footerButton }
type = { BUTTON_TYPES.PRIMARY } />
) }
</View>
);
}
}
export default translate(connect(mapStateToProps)(StartRecordingDialogContent));

View File

@@ -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));

View File

@@ -0,0 +1,229 @@
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 ICON_INFO = require('../../../../../images/icon-info.png');
export const ICON_USERS = require('../../../../../images/icon-users.png');
/* 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]
},
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'
},
footer: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginHorizontal: BaseTheme.spacing[3],
paddingVertical: _PADDING
},
footerButton: {
marginLeft: BaseTheme.spacing[3]
},
header: {
...header,
marginHorizontal: BaseTheme.spacing[3]
},
headerNested: {
...header,
paddingBottom: 0
},
headerInfo: {
...header,
backgroundColor: BaseTheme.palette.warning02,
marginBottom: BaseTheme.spacing[4],
paddingHorizontal: BaseTheme.spacing[3]
},
loggedIn: {
paddingHorizontal: _PADDING,
paddingVertical: BaseTheme.spacing[2]
},
loggedInInfo: {
flex: 1,
marginRight: BaseTheme.spacing[2]
},
optionLabel: {
flex: 1,
fontSize: 16,
marginRight: BaseTheme.spacing[2],
textAlign: 'left'
},
pickerLabel: {
color: BaseTheme.palette.text02,
flex: 1,
textAlign: 'left'
},
section: {
flex: 0,
flexDirection: 'column',
paddingVertical: BaseTheme.spacing[2]
},
accordion: {
backgroundColor: BaseTheme.palette.ui02,
borderColor: BaseTheme.palette.ui03,
borderRadius: 11,
borderWidth: 1,
marginHorizontal: BaseTheme.spacing[3],
marginTop: BaseTheme.spacing[2],
overflow: 'hidden'
},
accordionHeader: {
alignItems: 'center',
flexDirection: 'row',
padding: BaseTheme.spacing[2]
},
accordionText: {
flex: 1,
flexDirection: 'column',
marginRight: BaseTheme.spacing[2]
},
accordionTitle: {
color: BaseTheme.palette.text01,
fontWeight: '600'
},
accordionSummary: {
color: BaseTheme.palette.text02,
fontSize: 12
},
accordionChevronOpen: {
transform: [ { rotate: '180deg' } ]
},
accordionBody: {
paddingBottom: BaseTheme.spacing[2],
paddingHorizontal: BaseTheme.spacing[2]
},
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')
}
});

View File

@@ -0,0 +1,220 @@
import { Theme } from '@mui/material';
export const ICON_INFO = 'images/icon-info.png';
export const ICON_USERS = 'images/icon-users.png';
/**
* Creates the theme based styles of the recording & transcription dialog
* content.
*
* @param {Theme} theme - The current theme.
* @returns {Object}
*/
export const startRecordingDialogStyles = (theme: Theme) => {
return {
container: {
flex: 0,
flexDirection: 'column' as const
},
section: {
padding: `${theme.spacing(2)} 0`
},
// No overflow: hidden here — the dropdown menus must be able to
// overflow the accordion; the header carries its own border radius.
accordion: {
background: theme.palette.ui02,
border: `1px solid ${theme.palette.ui03}`,
borderRadius: '4px',
boxSizing: 'border-box' as const,
marginTop: theme.spacing(2),
width: '100%'
},
// Also resets the native button appearance — the header is a <button>
// so it is keyboard operable out of the box.
accordionHeader: {
alignItems: 'center',
appearance: 'none' as const,
background: 'none',
border: 0,
borderRadius: '4px',
boxSizing: 'border-box' as const,
color: theme.palette.text01,
cursor: 'pointer',
display: 'flex',
font: 'inherit',
gap: theme.spacing(2),
padding: '10px 12px',
textAlign: 'left' as const,
width: '100%',
'&:hover': {
background: theme.palette.ui03
},
'&:focus-visible': {
boxShadow: `0px 0px 0px 2px ${theme.palette.selectFocus} inset`,
outline: 0
}
},
accordionText: {
display: 'flex',
flex: 1,
flexDirection: 'column' as const,
gap: '2px',
minWidth: 0
},
accordionTitle: {
fontSize: '0.8125rem',
fontWeight: 600,
lineHeight: 1.2
},
accordionSummary: {
color: theme.palette.text02,
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap' as const
},
accordionChevron: {
color: theme.palette.text02,
display: 'flex',
marginLeft: 'auto',
transition: 'transform .16s'
},
accordionChevronOpen: {
transform: 'rotate(180deg)'
},
accordionBody: {
padding: '12px'
},
authorizationPanel: {
display: 'flex',
flexDirection: 'column' as const,
margin: `${theme.spacing(2)} 0 0`
},
loggedInPanel: {
padding: `${theme.spacing(2)} 0`
},
footer: {
display: 'flex',
flexDirection: 'row' as const,
justifyContent: 'flex-end',
padding: '12px 0 4px',
'& button:not(:first-child)': {
marginLeft: theme.spacing(2)
}
},
header: {
alignItems: 'center',
display: 'flex',
flex: 0,
flexDirection: 'row' as const,
justifyContent: 'space-between'
},
headerSpaceTop: {
marginTop: '10px'
},
title: {
alignItems: 'center',
display: 'inline-flex',
fontSize: '0.875rem',
marginLeft: theme.spacing(3),
maxWidth: '70%'
},
titleNoMargin: {
alignItems: 'center',
display: 'inline-flex',
fontSize: '0.875rem',
fontWeight: 600,
maxWidth: '70%'
},
optionLabel: {
alignItems: 'center',
display: 'inline-flex',
fontSize: '0.875rem',
maxWidth: '70%'
},
switch: {
marginLeft: 'auto'
},
iconContainer: {
alignItems: 'center',
display: 'inline-flex'
},
fileSharingIconContainer: {
backgroundColor: theme.palette.ui04,
borderRadius: '4px',
height: '40px',
justifyContent: 'center',
width: '42px'
},
fileSharingIcon: {
height: '18px',
objectFit: 'contain' as const,
width: '18px'
},
info: {
backgroundColor: theme.palette.warning02,
color: theme.palette.uiBackground,
display: 'inline-flex',
margin: '16px 0 8px',
width: '100%'
},
infoIcon: {
alignSelf: 'center',
height: '14px',
margin: '0 24px 0 16px',
objectFit: 'contain' as const,
width: '14px'
},
infoTitle: {
display: 'inline-flex',
fontSize: '0.875rem',
width: '290px'
},
localRecordingWarning: {
display: 'block',
fontSize: '0.875rem',
lineHeight: '1.25rem',
marginTop: theme.spacing(2),
padding: '8px 16px'
},
localRecordingWarningText: {
backgroundColor: theme.palette.ui03,
color: theme.palette.text01
},
localRecordingWarningNotification: {
backgroundColor: theme.palette.warning02,
color: theme.palette.uiBackground
}
};
};

View File

@@ -0,0 +1,374 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../../base/icons/components/Icon';
import { IconArrowDown } from '../../../../base/icons/svg';
import ContextMenu from '../../../../base/ui/components/web/ContextMenu';
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
import ContextMenuItemGroup from '../../../../base/ui/components/web/ContextMenuItemGroup';
interface IOption {
label: string;
value: string;
}
interface IProps {
/**
* Whether or not the dropdown is disabled.
*/
disabled?: boolean;
/**
* Id of the trigger element.
*/
id: string;
/**
* Label to be displayed above the dropdown.
*/
label?: string;
/**
* Change handler, invoked with the value of the picked option.
*/
onChange: (value: string) => void;
/**
* The options of the dropdown.
*/
options: Array<IOption>;
/**
* The value of the selected option.
*/
value: string | null;
}
interface IItemProps {
label: string;
onSelect: (value: string) => void;
selected: boolean;
value: string;
}
const useStyles = makeStyles()(theme => {
return {
container: {
display: 'flex',
flexDirection: 'column'
},
label: {
color: theme.palette.selectLabel,
...theme.typography.bodyShortRegular,
marginBottom: theme.spacing(2)
},
dropdownContainer: {
position: 'relative'
},
trigger: {
alignItems: 'center',
backgroundColor: theme.palette.selectBackground,
border: 0,
borderRadius: `${theme.shape.borderRadius}px`,
color: theme.palette.selectText,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
...theme.typography.bodyShortRegular,
padding: '10px 16px',
textAlign: 'left',
width: '100%',
'&:focus': {
outline: 0,
boxShadow: `0px 0px 0px 2px ${theme.palette.selectFocus}`
},
'&:disabled': {
color: theme.palette.selectDisabled,
cursor: 'default'
}
},
triggerText: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
},
chevron: {
marginLeft: theme.spacing(2),
transition: 'transform .16s'
},
chevronOpen: {
transform: 'rotate(180deg)'
},
// The z-index must beat the Switch knob (zIndex 10) of the option rows
// rendered under the dropdown.
menuWrapper: {
left: 0,
position: 'absolute',
right: 0,
top: `calc(100% + ${theme.spacing(1)})`,
zIndex: 50
},
menuWrapperUp: {
bottom: `calc(100% + ${theme.spacing(1)})`,
top: 'auto'
},
// Neutralizes the ContextMenu default positioning (marginTop/right/top
// target the participants pane) so the menu sits in the wrapper. The
// :focus highlight makes the arrow key navigation visible — the items
// only style the .focus-visible polyfill class, which programmatic
// focus() calls never set.
menu: {
boxSizing: 'border-box',
margin: 0,
maxHeight: '250px',
overflowY: 'auto',
position: 'static',
right: 'auto',
width: '100%',
'& [role="menuitem"]:focus': {
backgroundColor: theme.palette.overflowMenuItemHover,
outline: 0
}
}
};
});
/**
* An entry of the dropdown options list.
*
* @returns {JSX.Element}
*/
const DropdownSelectItem = ({ label, onSelect, selected, value }: IItemProps) => {
const onClick = useCallback(() => onSelect(value), [ onSelect, value ]);
return (
<ContextMenuItem
accessibilityLabel = { label }
onClick = { onClick }
role = 'menuitem'
selected = { selected }
text = { label } />
);
};
/**
* A themed single choice dropdown: a select look-alike built out of
* ContextMenu so the options list matches the app theme (the native <select>
* popup cannot be styled). The menu is rendered right under the trigger,
* matching its width.
*
* @returns {JSX.Element}
*/
// eslint-disable-next-line react/no-multi-comp
const DropdownSelect = ({ disabled, id, label, onChange, options, value }: IProps) => {
const { classes, cx } = useStyles();
const [ isOpen, setIsOpen ] = useState(false);
const [ openUp, setOpenUp ] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const onToggle = useCallback(() => {
if (disabled) {
return;
}
// When opening, check whether the menu fits below the trigger within
// the closest scroll container (the dialog content) and open upwards
// otherwise — the menu would be clipped/scrolled out of view below.
if (!isOpen && containerRef.current) {
let clipBottom = window.innerHeight;
let parent = containerRef.current.parentElement;
while (parent) {
const { overflowY } = window.getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll') {
clipBottom = parent.getBoundingClientRect().bottom;
break;
}
parent = parent.parentElement;
}
const { bottom } = containerRef.current.getBoundingClientRect();
const menuHeight = Math.min(250, (options.length * 40) + 16) + 8;
setOpenUp(clipBottom - bottom < menuHeight);
}
setIsOpen(!isOpen);
}, [ disabled, isOpen, options.length ]);
const onSelect = useCallback((selectedValue: string) => {
onChange(selectedValue);
setIsOpen(false);
triggerRef.current?.focus();
}, [ onChange ]);
const getMenuItems = useCallback(
() => Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []), []);
// Opens the menu with the arrow keys per the menu button pattern; when it
// is already open, moves the focus into the item list.
const onTriggerKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
return;
}
e.preventDefault();
if (isOpen) {
const items = getMenuItems();
(e.key === 'ArrowDown' ? items[0] : items[items.length - 1])?.focus();
} else {
onToggle();
}
}, [ getMenuItems, isOpen, onToggle ]);
// Arrow key navigation between the menu items, wrapping around the ends.
const onMenuKeyDown = useCallback((e: React.KeyboardEvent) => {
const items = getMenuItems();
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
items[(currentIndex + 1) % items.length]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
items[currentIndex <= 0 ? items.length - 1 : currentIndex - 1]?.focus();
break;
case 'Home':
e.preventDefault();
items[0]?.focus();
break;
case 'End':
e.preventDefault();
items[items.length - 1]?.focus();
break;
case 'Tab':
setIsOpen(false);
break;
}
}, [ getMenuItems ]);
// Move focus to the selected item when the menu opens.
useEffect(() => {
if (isOpen) {
const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));
const items = menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]');
items?.[Math.min(selectedIndex, items.length - 1)]?.focus();
}
}, [ isOpen ]);
useEffect(() => {
if (!isOpen) {
return;
}
const onOutsideClick = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) {
setIsOpen(false);
}
};
// Capture phase so closing the menu does not also close the dialog.
const onEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
setIsOpen(false);
triggerRef.current?.focus();
}
};
window.addEventListener('click', onOutsideClick);
window.addEventListener('keydown', onEscKey, true);
return () => {
window.removeEventListener('click', onOutsideClick);
window.removeEventListener('keydown', onEscKey, true);
};
}, [ isOpen ]);
// Fall back to the first option's label when the current value isn't among the options
// (e.g. it just became unavailable while the dialog stayed open) — otherwise the trigger
// would render blank instead of a usable service name.
const selectedLabel = options.find(option => option.value === value)?.label ?? options[0]?.label ?? '';
return (
<div className = { classes.container }>
{ label && (
<label
className = { classes.label }
htmlFor = { id }>
{ label }
</label>
) }
<div
className = { classes.dropdownContainer }
ref = { containerRef }>
<button
aria-controls = { `${id}-menu` }
aria-expanded = { isOpen }
aria-haspopup = 'menu'
className = { classes.trigger }
disabled = { disabled }
id = { id }
onClick = { onToggle }
onKeyDown = { onTriggerKeyDown }
ref = { triggerRef }
type = 'button'>
<span className = { classes.triggerText }>
{ selectedLabel }
</span>
<Icon
className = { cx(classes.chevron, isOpen && classes.chevronOpen) }
size = { 18 }
src = { IconArrowDown } />
</button>
{ isOpen && (
// The arrow key interactions of the menu are handled on the
// wrapper; the items themselves take care of Enter/Space.
<div
className = { cx(classes.menuWrapper, openUp && classes.menuWrapperUp) }
onKeyDown = { onMenuKeyDown }
ref = { menuRef }>
<ContextMenu
accessibilityLabel = { label }
className = { classes.menu }
hidden = { false }
id = { `${id}-menu` }
role = 'menu'>
<ContextMenuItemGroup>
{ options.map(option => (
<DropdownSelectItem
key = { option.value }
label = { option.label }
onSelect = { onSelect }
selected = { option.value === value }
value = { option.value } />
)) }
</ContextMenuItemGroup>
</ContextMenu>
</div>
) }
</div>
</div>
);
};
export default DropdownSelect;

View File

@@ -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 RecordingTranscriptionDialog from '../../Recording/web/RecordingTranscriptionDialog';
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.recordingHighlightButtonDisabled
},
regular: {
background: theme.palette.recordingHighlightButton
},
highlightNotification: {
backgroundColor: theme.palette.recordingHighlightButton,
borderRadius: '6px',
boxShadow: '0px 6px 20px rgba(0, 0, 0, 0.25)',
boxSizing: 'border-box' as const,
color: theme.palette.recordingNotificationText,
fontSize: '0.875rem',
fontWeight: 400,
left: '4px',
padding: '16px',
position: 'absolute' as const,
top: '32px',
width: 320
},
highlightNotificationButton: {
color: theme.palette.recordingNotificationAction,
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('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
}
}
/**
* 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.recordingHighlightButtonIconDisabled : BaseTheme.palette.recordingHighlightButtonIcon }
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);

View File

@@ -0,0 +1,59 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { openDialog } from '../../../../base/dialog/actions';
import { translate } from '../../../../base/i18n/functions';
import { isRecordingRunning } from '../../../functions';
import AbstractRecordButton, {
IProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractRecordButton';
import RecordingTranscriptionDialog from './RecordingTranscriptionDialog';
/**
* Button for opening the unified recording & transcription management dialog.
*/
class RecordingButton extends AbstractRecordButton<IProps> {
/**
* Handles clicking / pressing the button.
*
* @override
* @protected
* @returns {void}
*/
override _onHandleClick() {
const { dispatch } = this.props;
dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
}
}
/**
* 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,
_isRecordingRunning: isRecordingRunning(state),
visible
};
}
export default translate(connect(_mapStateToProps)(RecordingButton));

View File

@@ -0,0 +1,67 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import Dialog from '../../../../base/ui/components/web/Dialog';
import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../../actions.web';
/**
* Component that renders the dialog for explicit consent for recordings.
* The prejoin mute state is read from Redux by the action creator.
*
* @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 ?? {};
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'>
{ t('dialog.recordingInProgressDescription') }
{ consentLearnMoreLink && (
<>
{' '}
<a
href = { consentLearnMoreLink }
rel = 'noopener noreferrer'
target = '_blank'>
{ t('dialog.learnMore') }
</a>
{'.'}
</>
) }
</Dialog>
);
}

View File

@@ -0,0 +1,125 @@
import React from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { hideDialog } from '../../../../base/dialog/actions';
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 AbstractStartRecordingDialog, {
mapStateToProps as abstractMapStateToProps
} from '../AbstractStartRecordingDialog';
import StartRecordingDialogContent from './StartRecordingDialogContent';
/**
* React Component for the recording & transcription dialog. Each section
* (audio & video recording, transcription) has its own start/stop button
* which applies the action immediately and closes the dialog.
*
* @augments Component
*/
class RecordingTranscriptionDialog extends AbstractStartRecordingDialog {
/**
* Dismisses the dialog.
*
* @inheritdoc
* @returns {void}
*/
override _dismiss() {
this.props.dispatch(hideDialog());
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
*/
override render() {
const {
isTokenValid,
isValidating,
localRecordingOnlySelf,
selectedLanguage,
selectedRecordingService,
sharingEnabled,
spaceLeft,
userName
} = this.state;
const {
_canTranscribe,
_fileRecordingsServiceEnabled,
_fileRecordingsServiceSharingEnabled,
_recordingRunning,
_transcriptionRunning,
recordAudioAndVideo
} = this.props;
return (
<Dialog
cancel = {{ hidden: true }}
disableEnter = { true }
ok = {{ hidden: true }}
titleKey = { _canTranscribe ? 'dialog.recordAndTranscribe' : 'toolbar.record' }>
<StartRecordingDialogContent
fileRecordingsServiceEnabled = { _fileRecordingsServiceEnabled }
fileRecordingsServiceSharingEnabled = { _fileRecordingsServiceSharingEnabled }
integrationsEnabled = { this._areIntegrationsEnabled() }
isTokenValid = { isTokenValid }
isValidating = { isValidating }
localRecordingOnlySelf = { localRecordingOnlySelf }
onLocalRecordingSelfChange = { this._onLocalRecordingSelfChange }
onRecordingServiceChange = { this._onSelectedRecordingServiceChanged }
onSharingSettingChanged = { this._onSharingSettingChanged }
onStartBoth = { this._onStartBothPress }
onStartRecording = { this._onStartRecordingPress }
onStartTranscription = { this._onStartTranscriptionPress }
onStopBoth = { this._onStopBothPress }
onStopRecording = { this._onStopRecordingPress }
onStopTranscription = { this._onStopTranscriptionPress }
onSubtitlesLanguageChange = { this._onSubtitlesLanguageChanged }
recordAudioAndVideo = { recordAudioAndVideo }
recordingRunning = { Boolean(_recordingRunning) }
selectedLanguage = { selectedLanguage }
selectedRecordingService = { selectedRecordingService }
sharingSetting = { sharingEnabled }
spaceLeft = { spaceLeft }
startRecordingDisabled = { this._isStartRecordingDisabled() }
transcriptionRunning = { Boolean(_transcriptionRunning) }
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)(RecordingTranscriptionDialog));

View File

@@ -0,0 +1,548 @@
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { translate } from '../../../../base/i18n/functions';
import Icon from '../../../../base/icons/components/Icon';
import { IconArrowDown } 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 {
ICON_INFO,
ICON_USERS,
startRecordingDialogStyles
} from '../styles.web';
import DropdownSelect from './DropdownSelect';
const EMPTY_FUNCTION = () => {
// empty
};
/**
* The recording & transcription dialog content for the web application.
*/
class StartRecordingDialogContent extends AbstractStartRecordingDialogContent {
override render() {
const classes = this.props.classes ?? {};
return (
<Container className = { classes.container }>
{ this._renderRecordingSection() }
{ this._renderTranscriptionSection() }
{ this._renderFooter() }
</Container>
);
}
/**
* Renders the accordion wrapping a section's options: a header with the
* options title, a one line summary of the current choices and a chevron,
* followed by the options themselves when expanded.
*
* @param {Object} options - Rendering options.
* @returns {React$Component}
*/
_renderAccordion({ body, expanded, id, onToggle, summary, titleKey }: {
body: React.ReactNode;
expanded: boolean;
id: string;
onToggle: () => void;
summary: string;
titleKey: string;
}) {
const { t } = this.props;
const classes = this.props.classes ?? {};
return (
<Container className = { classes.accordion }>
<button
aria-controls = { `${id}-content` }
aria-expanded = { expanded }
className = { classes.accordionHeader }
id = { id }
onClick = { onToggle }
type = 'button'>
<Container className = { classes.accordionText }>
<span className = { classes.accordionTitle }>
{ t(titleKey) }
</span>
{ !expanded && (
<span className = { classes.accordionSummary }>
{ summary }
</span>
) }
</Container>
<Icon
className = { `${classes.accordionChevron} ${expanded ? classes.accordionChevronOpen : ''}` }
size = { 18 }
src = { IconArrowDown } />
</button>
{ expanded && (
<div
className = { classes.accordionBody }
id = { `${id}-content` }>
{ body }
</div>
) }
</Container>
);
}
/**
* Renders the audio & video recording section: the action row with the
* start/stop button and the options accordion holding the storage service
* combobox and the selected service's options.
*
* @returns {React$Component}
*/
_renderRecordingSection() {
const recordingServiceOptions = this._getRecordingServiceOptions();
if (!this._shouldRenderRecordingSection(recordingServiceOptions)) {
return null;
}
const {
isValidating,
onStartRecording,
onStopRecording,
recordingRunning,
selectedRecordingService,
startRecordingDisabled,
t
} = this.props;
const { showRecordingOptions } = this.state;
const classes = this.props.classes ?? {};
const serviceOptions = recordingServiceOptions.map(service => {
return {
label: t(this._getServiceLabelKey(service)),
value: service
};
});
return (
<Container className = { classes.section }>
<Container className = { classes.header }>
<label className = { classes.titleNoMargin }>
{ t('recording.recordAudioAndVideo') }
</label>
<Container className = { classes.switch }>
{ recordingRunning ? (
<Button
accessibilityLabel = { t('dialog.stopRecording') }
labelKey = 'dialog.stop'
onClick = { onStopRecording }
testId = 'recordingDialog.stopRecording'
type = { BUTTON_TYPES.DESTRUCTIVE } />
) : (
<Button
accessibilityLabel = { t('dialog.startRecording') }
disabled = { startRecordingDisabled || isValidating }
labelKey = 'dialog.start'
onClick = { onStartRecording }
testId = 'recordingDialog.startRecording'
type = { BUTTON_TYPES.PRIMARY } />
) }
</Container>
</Container>
{ this._renderAccordion({
body: (
<>
<DropdownSelect
disabled = { recordingRunning || isValidating }
id = 'recording-service-select'
label = { t('recording.storageLocation') }
onChange = { this._onRecordingServiceChange }
options = { serviceOptions }
value = { selectedRecordingService ?? '' } />
{ selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
&& this._renderJitsiServiceOptions() }
{ selectedRecordingService === RECORDING_TYPES.DROPBOX
&& this._renderDropboxOptions() }
{ selectedRecordingService === RECORDING_TYPES.LOCAL
&& this._renderLocalRecordingOptions() }
{ this._renderFollowMeRecorder() }
</>
),
expanded: showRecordingOptions,
id: 'recording-options',
onToggle: this._onToggleRecordingOptions,
// Only rendered by _renderAccordion while collapsed — skip computing it
// (and its nested t() calls) while the section is expanded.
summary: showRecordingOptions ? '' : this._getRecordingSummary(),
titleKey: 'recording.recordingOptions'
}) }
</Container>
);
}
/**
* Renders the options of the Jitsi recording service: the recording link
* sharing switch and the vpaas storage warning.
*
* @returns {React$Component}
*/
_renderJitsiServiceOptions() {
const {
isValidating,
onSharingSettingChanged,
recordingRunning,
sharingSetting,
t
} = this.props;
const classes = this.props.classes ?? {};
return (
<>
{ this._shouldRenderFileSharingContent() && (
<Container
className = { `${classes.header} ${classes.headerSpaceTop}` }
key = 'fileSharingSetting'>
<Container className = { `${classes.iconContainer} ${classes.fileSharingIconContainer}` }>
<Image
alt = ''
className = { classes.fileSharingIcon }
src = { ICON_USERS } />
</Container>
<label
className = { classes.title }
htmlFor = 'recording-switch-share'>
{ t('recording.fileSharingdescription') }
</label>
<Switch
checked = { sharingSetting }
className = { classes.switch }
disabled = { isValidating || recordingRunning }
id = 'recording-switch-share'
onChange = { onSharingSettingChanged } />
</Container>
) }
{ this._renderUploadToTheCloudInfo() }
</>
);
}
/**
* Renders the info in case recording is uploaded to the cloud.
*
* @returns {React$Component}
*/
_renderUploadToTheCloudInfo() {
const {
_hideStorageWarning,
isVpaas,
t
} = this.props;
if (!isVpaas || _hideStorageWarning) {
return null;
}
const classes = this.props.classes ?? {};
return (
<Container
className = { classes.info }
key = 'cloudUploadInfo'>
<Image
alt = ''
className = { classes.infoIcon }
src = { ICON_INFO } />
<Text className = { classes.infoTitle }>
{ t('recording.serviceDescriptionCloudInfo') }
</Text>
</Container>
);
}
/**
* Renders the Dropbox account area: sign in button, or account info plus
* sign out button once authorized.
*
* @returns {React$Component}
*/
_renderDropboxOptions() {
const {
isTokenValid,
isValidating,
spaceLeft,
t,
userName
} = this.props;
const classes = this.props.classes ?? {};
if (isValidating) {
return (
<Container className = { classes.authorizationPanel }>
<LoadingIndicator size = 'small' />
</Container>
);
}
if (!isTokenValid) {
return (
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
<span className = { classes.optionLabel }>
{ t('recording.dropboxSignInPrompt') }
</span>
<Container className = { classes.switch }>
<Button
accessibilityLabel = { t('recording.signIn') }
labelKey = 'recording.signIn'
onClick = { this._onSignIn }
type = { BUTTON_TYPES.PRIMARY } />
</Container>
</Container>
);
}
const duration = getRecordingDurationEstimation(spaceLeft);
return (
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
<Container className = { classes.loggedInPanel }>
<Container>
<Text>
{ t('recording.loggedIn', { userName }) }
</Text>
</Container>
<Container>
<Text>
{
t('recording.availableSpace', {
spaceLeft,
duration
})
}
</Text>
</Container>
</Container>
<Container className = { classes.switch }>
<Button
accessibilityLabel = { t('recording.signOut') }
labelKey = 'recording.signOut'
onClick = { this._onSignOut }
type = { BUTTON_TYPES.SECONDARY } />
</Container>
</Container>
);
}
/**
* Renders the options for local recordings.
*
* @returns {React$Component}
*/
_renderLocalRecordingOptions() {
const {
_localRecordingNoNotification,
_localRecordingSelfEnabled,
isValidating,
localRecordingOnlySelf,
onLocalRecordingSelfChange,
recordingRunning,
t
} = this.props;
const classes = this.props.classes ?? {};
return (
<>
{ _localRecordingSelfEnabled && (
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
<Container className = { `${classes.iconContainer} ${classes.fileSharingIconContainer}` }>
<Image
alt = ''
className = { classes.fileSharingIcon }
src = { ICON_USERS } />
</Container>
<label
className = { classes.title }
htmlFor = 'recording-switch-myself'>
{ t('recording.onlyRecordSelf') }
</label>
<Switch
checked = { Boolean(localRecordingOnlySelf) }
className = { classes.switch }
disabled = { isValidating || recordingRunning }
id = 'recording-switch-myself'
onChange = { onLocalRecordingSelfChange ?? EMPTY_FUNCTION } />
</Container>
) }
<Text className = { `${classes.localRecordingWarning} ${classes.localRecordingWarningText}` }>
{ t('recording.localRecordingWarning') }
</Text>
{ _localRecordingNoNotification && !localRecordingOnlySelf
&& <Text className = { `${classes.localRecordingWarning} ${classes.localRecordingWarningNotification}` }>
{ t('recording.localRecordingNoNotificationWarning') }
</Text>
}
</>
);
}
/**
* Renders the recorder follow me switch for cloud based recordings.
*
* @returns {React$Component}
*/
_renderFollowMeRecorder() {
if (!this._shouldRenderFollowMeRecorder()) {
return null;
}
const { t } = this.props;
const classes = this.props.classes ?? {};
return (
<Container className = { `${classes.header} ${classes.headerSpaceTop}` }>
<label
className = { classes.optionLabel }
htmlFor = 'recording-switch-follow-me'>
{ t('settings.followMeRecorder') }
</label>
<Switch
checked = { this._isFollowMeRecorderChecked() }
className = { classes.switch }
disabled = { this._isFollowMeRecorderDisabled() }
id = 'recording-switch-follow-me'
onChange = { this._onFollowMeRecorderChange } />
</Container>
);
}
/**
* Renders the transcription section: header with the start/stop button
* and a collapsible area holding the language combobox.
*
* @returns {React$Component}
*/
_renderTranscriptionSection() {
if (!this._shouldRenderTranscriptionSection()) {
return null;
}
const {
_availableLanguages,
onStartTranscription,
onStopTranscription,
selectedLanguage,
t,
transcriptionRunning
} = this.props;
const { showTranscriptionOptions } = this.state;
const classes = this.props.classes ?? {};
const languageOptions = _availableLanguages.map((lang: string) => {
return {
label: t(lang),
value: lang
};
});
return (
<Container className = { classes.section }>
<Container className = { classes.header }>
<label className = { classes.titleNoMargin }>
{ t('recording.recordTranscription') }
</label>
<Container className = { classes.switch }>
{ transcriptionRunning ? (
<Button
accessibilityLabel = { t('dialog.stopTranscription') }
labelKey = 'dialog.stop'
onClick = { onStopTranscription }
testId = 'recordingDialog.stopTranscription'
type = { BUTTON_TYPES.DESTRUCTIVE } />
) : (
<Button
accessibilityLabel = { t('dialog.startTranscription') }
labelKey = 'dialog.start'
onClick = { onStartTranscription }
testId = 'recordingDialog.startTranscription'
type = { BUTTON_TYPES.PRIMARY } />
) }
</Container>
</Container>
{ this._renderAccordion({
body: (
<DropdownSelect
disabled = { transcriptionRunning }
id = 'transcription-language-select'
label = { t('recording.transcriptionLanguage') }
onChange = { this.props.onSubtitlesLanguageChange }
options = { languageOptions }
value = { selectedLanguage ?? '' } />
),
expanded: showTranscriptionOptions,
id: 'transcription-options',
onToggle: this._onToggleTranscriptionOptions,
// Only rendered by _renderAccordion while collapsed — skip computing it while
// the section is expanded.
summary: showTranscriptionOptions ? '' : this._getTranscriptionSummary(),
titleKey: 'recording.transcriptionOptions'
}) }
</Container>
);
}
/**
* Renders the footer buttons acting on both services at once. When only
* one service is running both buttons are shown: stop both stops the
* running one, start both starts the missing one.
*
* @returns {React$Component}
*/
_renderFooter() {
if (!this._shouldRenderFooter()) {
return null;
}
const {
isValidating,
onStartBoth,
onStopBoth,
recordingRunning,
startRecordingDisabled,
t,
transcriptionRunning
} = this.props;
const showStopBoth = recordingRunning || transcriptionRunning;
const showStartBoth = !recordingRunning || !transcriptionRunning;
const startBothDisabled = !recordingRunning && (startRecordingDisabled || isValidating);
const classes = this.props.classes ?? {};
return (
<Container className = { classes.footer }>
{ showStopBoth && (
<Button
accessibilityLabel = { t('dialog.stopBoth') }
labelKey = 'dialog.stopBoth'
onClick = { onStopBoth }
testId = 'recordingDialog.stopBoth'
type = { BUTTON_TYPES.DESTRUCTIVE } />
) }
{ showStartBoth && (
<Button
accessibilityLabel = { t('dialog.startBoth') }
disabled = { startBothDisabled }
labelKey = 'dialog.startBoth'
onClick = { onStartBoth }
testId = 'recordingDialog.startBoth'
type = { BUTTON_TYPES.PRIMARY } />
) }
</Container>
);
}
}
export default withStyles(translate(connect(mapStateToProps)(StartRecordingDialogContent)), startRecordingDialogStyles);

View File

@@ -0,0 +1,53 @@
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 { localRecordingVideoStop, stopMode, t } = this.props;
const titleKey = stopMode === 'transcription' ? 'dialog.stopTranscription' : 'dialog.recording';
const bodyKey = stopMode === 'transcription'
? 'dialog.stopTranscriptionWarning'
: (localRecordingVideoStop ? 'recording.localRecordingVideoStop' : 'dialog.stopRecordingWarning');
return (
<Dialog
ok = {{ translationKey: 'dialog.confirm' }}
onSubmit = { this._onSubmit }
titleKey = { titleKey }>
{ t(bodyKey) }
</Dialog>
);
}
/**
* Toggles screenshot capture.
*
* @returns {void}
*/
override _toggleScreenshotCapture() {
this.props.dispatch(toggleScreenshotCaptureSummary(false));
}
}
export default translate(connect(_mapStateToProps)(StopRecordingDialog));

View File

@@ -0,0 +1,103 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { getSessionStatusToShow } from '../../functions';
interface IProps extends AbstractProps {
/**
* Whether this meeting is being transcribed.
*/
_isTranscribing: boolean;
/**
* The status of the highermost priority session.
*/
_status?: string;
/**
* The recording mode this indicator should display.
*/
mode: string;
/**
* Function to be used to translate i18n labels.
*/
t: Function;
}
/**
* A react {@code Component} that implements an expanded label as tooltip-like
* component to explain the meaning of the {@code RecordingLabel}.
*/
class RecordingExpandedLabel extends ExpandedLabel<IProps> {
/**
* Returns the label specific text of this {@code ExpandedLabel}.
*
* @returns {string}
*/
_getLabel() {
const { _status, mode, t } = this.props;
let postfix = 'expandedOn', prefix = 'recording'; // Default values.
switch (mode) {
case JitsiRecordingConstants.mode.STREAM:
prefix = 'liveStreaming';
break;
case JitsiRecordingConstants.mode.FILE:
prefix = 'recording';
break;
}
switch (_status) {
case JitsiRecordingConstants.status.OFF:
postfix = 'expandedOff';
break;
case JitsiRecordingConstants.status.PENDING:
postfix = 'expandedPending';
break;
case JitsiRecordingConstants.status.ON:
postfix = 'expandedOn';
break;
}
let content = t(`${prefix}.${postfix}`);
if (this.props._isTranscribing) {
if (_status === JitsiRecordingConstants.status.ON) {
content += ` ${t('transcribing.labelTooltipExtra')}`;
} else {
content = t('transcribing.labelTooltip');
}
}
return content;
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code RecordingExpandedLabel}'s props.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The component's own props.
* @private
* @returns {{
* _status: ?string
* }}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { mode } = ownProps;
return {
_isTranscribing: isRecorderTranscriptionsRunning(state),
_status: getSessionStatusToShow(state, mode)
};
}
export default translate(connect(_mapStateToProps)(RecordingExpandedLabel));

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { IconRecord, IconSites } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { StyleType } from '../../../base/styles/functions.any';
import AbstractRecordingLabel, {
_mapStateToProps
} from '../AbstractRecordingLabel';
import styles from './styles';
/**
* Implements a React {@link Component} which displays the current state of
* conference recording.
*
* @augments {Component}
*/
class RecordingLabel extends AbstractRecordingLabel {
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
_renderLabel() {
let status: 'on' | 'in_progress' | 'off' = 'on';
const isRecording = this.props.mode === JitsiRecordingConstants.mode.FILE;
const icon = isRecording ? IconRecord : IconSites;
switch (this.props._status) {
case JitsiRecordingConstants.status.PENDING:
status = 'in_progress';
break;
case JitsiRecordingConstants.status.OFF:
status = 'off';
break;
}
return (
<Label
icon = { icon }
status = { status }
style = { styles.indicatorStyle as StyleType } />
);
}
}
export default translate(connect(_mapStateToProps)(RecordingLabel));

View File

@@ -0,0 +1,55 @@
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
interface IProps extends AbstractProps, WithTranslation {
/**
* Whether this meeting is being transcribed.
*/
_isTranscribing?: boolean;
}
/**
* A react {@code Component} that implements an expanded label as tooltip-like
* component to explain the meaning of the {@code TranscribingLabel}.
*/
class TranscribingExpandedLabel extends ExpandedLabel<IProps> {
/**
* Returns the label specific text of this {@code ExpandedLabel}.
*
* @returns {string}
*/
_getLabel() {
const { _isTranscribing, t } = this.props;
if (_isTranscribing) {
return t('transcribing.labelTooltip');
}
return t('transcribing.expandedOff');
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code TranscribingExpandedLabel}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _isTranscribing: boolean
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
_isTranscribing: isRecorderTranscriptionsRunning(state)
};
}
export default translate(connect(_mapStateToProps)(TranscribingExpandedLabel));

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { IconTranscription } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { StyleType } from '../../../base/styles/functions.any';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import AbstractRecordingLabel, {
IProps as AbstractProps
} from '../AbstractRecordingLabel';
import styles from './styles';
/**
* Implements a React {@link Component} which displays the current state of
* transcription.
*
* @augments {Component}
*/
class TranscribingLabel extends AbstractRecordingLabel<AbstractProps> {
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
_renderLabel() {
const { _isTranscribing } = this.props;
if (!_isTranscribing) {
return null;
}
return (
<Label
icon = { IconTranscription }
status = { 'on' }
style = { styles.transcribingIndicatorStyle as StyleType } />
);
}
}
/**
* Maps (parts of) the Redux state to the associated props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Object}
*/
function _mapStateToProps(state: any) {
const _isTranscribing = isRecorderTranscriptionsRunning(state);
return {
_isVisible: _isTranscribing,
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
_isTranscribing,
mode: 'transcribing', // Custom mode for transcription
_status: _isTranscribing ? JitsiRecordingConstants.status.ON : JitsiRecordingConstants.status.OFF
};
}
export default translate(connect(_mapStateToProps)(TranscribingLabel));

View File

@@ -0,0 +1,28 @@
import { createStyleSheet } from '../../../base/styles/functions.native';
import BaseTheme from '../../../base/ui/components/BaseTheme';
/**
* The styles of the React {@code Components} of the feature recording.
*/
export default createStyleSheet({
/**
* Style for the recording indicator.
*/
indicatorStyle: {
marginRight: 4,
marginLeft: 0,
marginBottom: 0,
backgroundColor: BaseTheme.palette.iconError
},
/**
* Style for the transcription indicator.
*/
transcribingIndicatorStyle: {
marginRight: 4,
marginLeft: 0,
marginBottom: 0,
backgroundColor: BaseTheme.palette.transcriptionIndicator
}
});

View File

@@ -0,0 +1,131 @@
import { Theme } from '@mui/material';
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { IconRecord, IconSites } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { hasRecordingOrTranscriptionFeature } from '../../functions';
import AbstractRecordingLabel, {
IProps as AbstractProps,
_mapStateToProps as _abstractMapStateToProps
} from '../AbstractRecordingLabel';
import RecordingTranscriptionDialog from '../Recording/web/RecordingTranscriptionDialog';
interface IProps extends AbstractProps {
/**
* Whether the local participant can control recording/transcription (has either feature enabled).
*/
_canControlRecording: boolean;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current UI theme.
*
* @returns {Object}
*/
const styles = (theme: Theme) => {
return {
record: {
background: theme.palette.actionDanger
}
};
};
/**
* Implements a React {@link Component} which displays the current state of
* conference recording.
*
* @augments {Component}
*/
class RecordingLabel extends AbstractRecordingLabel<IProps> {
/**
* Initializes a new {@code RecordingLabel} instance.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onClick = this._onClick.bind(this);
}
/**
* Handles clicking on the label.
*
* @returns {void}
*/
_onClick() {
if (this.props._canControlRecording) {
this.props.dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
}
}
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
override _renderLabel() {
const { _status, mode, t } = this.props;
const classes = withStyles.getClasses(this.props);
const isRecording = mode === JitsiRecordingConstants.mode.FILE;
const icon = isRecording ? IconRecord : IconSites;
let content;
if (_status === JitsiRecordingConstants.status.ON) {
content = t(isRecording ? 'videoStatus.recording' : 'videoStatus.streaming');
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
return null;
} else {
return null;
}
return (
<Tooltip
content = { content }
position = { 'bottom' }>
<Label
className = { classes.record }
icon = { icon }
onClick = { this._onClick } />
</Tooltip>
);
}
}
/**
* Maps (parts of) the Redux state to the associated props.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The component's own props.
* @private
* @returns {Object}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
return {
..._abstractMapStateToProps(state, ownProps),
_canControlRecording: hasRecordingOrTranscriptionFeature(state)
};
}
export default withStyles(translate(connect(_mapStateToProps)(RecordingLabel)), styles);

View File

@@ -0,0 +1,129 @@
import { Theme } from '@mui/material';
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { IconTranscription } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import { hasRecordingOrTranscriptionFeature } from '../../functions';
import AbstractRecordingLabel, {
IProps as AbstractProps
} from '../AbstractRecordingLabel';
import RecordingTranscriptionDialog from '../Recording/web/RecordingTranscriptionDialog';
interface IProps extends AbstractProps {
/**
* Whether the local participant can control recording/transcription (has either feature enabled).
*/
_canControlRecording: boolean;
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current UI theme.
*
* @returns {Object}
*/
const styles = (theme: Theme) => {
return {
transcribing: {
background: theme.palette.transcriptionIndicator
}
};
};
/**
* Implements a React {@link Component} which displays the current state of
* transcription.
*
* @augments {Component}
*/
class TranscribingLabel extends AbstractRecordingLabel<IProps> {
/**
* Initializes a new {@code TranscribingLabel} instance.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onClick = this._onClick.bind(this);
}
/**
* Handles clicking on the label.
*
* @returns {void}
*/
_onClick() {
if (this.props._canControlRecording) {
this.props.dispatch(openDialog('RecordingTranscriptionDialog', RecordingTranscriptionDialog));
}
}
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
override _renderLabel() {
const { _isTranscribing, t } = this.props;
const classes = withStyles.getClasses(this.props);
if (!_isTranscribing) {
return null;
}
const content = t('transcribing.labelTooltip');
return (
<Tooltip
content = { content }
position = { 'bottom' }>
<Label
className = { classes.transcribing }
icon = { IconTranscription }
onClick = { this._onClick } />
</Tooltip>
);
}
}
/**
* Maps (parts of) the Redux state to the associated props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Object}
*/
function _mapStateToProps(state: IReduxState) {
const _isTranscribing = isRecorderTranscriptionsRunning(state);
return {
_isVisible: _isTranscribing,
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
_canControlRecording: hasRecordingOrTranscriptionFeature(state),
_isTranscribing,
mode: 'transcribing' // Custom mode for transcription
};
}
export default withStyles(translate(connect(_mapStateToProps)(TranscribingLabel)), styles);