This commit is contained in:
307
react/features/settings/components/web/CalendarTab.tsx
Normal file
307
react/features/settings/components/web/CalendarTab.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React, { Component } from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Spinner from '../../../base/ui/components/web/Spinner';
|
||||
import { bootstrapCalendarIntegration, clearCalendarIntegration, signIn } from '../../../calendar-sync/actions';
|
||||
import MicrosoftSignInButton from '../../../calendar-sync/components/MicrosoftSignInButton';
|
||||
import { CALENDAR_TYPE } from '../../../calendar-sync/constants';
|
||||
import { isCalendarEnabled } from '../../../calendar-sync/functions';
|
||||
import GoogleSignInButton from '../../../google-api/components/GoogleSignInButton';
|
||||
import logger from '../../logger';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link CalendarTab}.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* The name given to this Jitsi Application.
|
||||
*/
|
||||
_appName: string;
|
||||
|
||||
/**
|
||||
* Whether or not to display a button to sign in to Google.
|
||||
*/
|
||||
_enableGoogleIntegration: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to display a button to sign in to Microsoft.
|
||||
*/
|
||||
_enableMicrosoftIntegration: boolean;
|
||||
|
||||
/**
|
||||
* The current calendar integration in use, if any.
|
||||
*/
|
||||
_isConnectedToCalendar: boolean;
|
||||
|
||||
/**
|
||||
* The email address associated with the calendar integration in use.
|
||||
*/
|
||||
_profileEmail?: string;
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* Invoked to change the configured calendar integration.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link CalendarTab}.
|
||||
*/
|
||||
interface IState {
|
||||
|
||||
/**
|
||||
* Whether or not any third party APIs are being loaded.
|
||||
*/
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center' as const,
|
||||
minHeight: '100px',
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.bodyShortRegular
|
||||
},
|
||||
|
||||
button: {
|
||||
marginTop: theme.spacing(4)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying calendar integration.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class CalendarTab extends Component<IProps, IState> {
|
||||
/**
|
||||
* Initializes a new {@code CalendarTab} instance.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
loading: true
|
||||
};
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onClickDisconnect = this._onClickDisconnect.bind(this);
|
||||
this._onClickGoogle = this._onClickGoogle.bind(this);
|
||||
this._onClickMicrosoft = this._onClickMicrosoft.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads third party APIs as needed and bootstraps the initial calendar
|
||||
* state if not already set.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
override componentDidMount() {
|
||||
this.props.dispatch(bootstrapCalendarIntegration())
|
||||
.catch((err: any) => logger.error('CalendarTab bootstrap failed', err))
|
||||
.then(() => this.setState({ loading: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
let view;
|
||||
|
||||
if (this.state.loading) {
|
||||
view = this._renderLoadingState();
|
||||
} else if (this.props._isConnectedToCalendar) {
|
||||
view = this._renderSignOutState();
|
||||
} else {
|
||||
view = this._renderSignInState();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
{ view }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches the action to start the sign in flow for a given calendar
|
||||
* integration type.
|
||||
*
|
||||
* @param {string} type - The calendar type to try integrating with.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_attemptSignIn(type: string) {
|
||||
this.props.dispatch(signIn(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an action to sign out of the currently connected third party
|
||||
* used for calendar integration.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClickDisconnect() {
|
||||
// We clear the integration state instead of actually signing out. This
|
||||
// is for two primary reasons. Microsoft does not support a sign out and
|
||||
// instead relies on clearing of local auth data. Google signout can
|
||||
// also sign the user out of YouTube. So for now we've decided not to
|
||||
// do an actual sign out.
|
||||
this.props.dispatch(clearCalendarIntegration());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the sign in flow for Google calendar integration.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClickGoogle() {
|
||||
this._attemptSignIn(CALENDAR_TYPE.GOOGLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the sign in flow for Microsoft calendar integration.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onClickMicrosoft() {
|
||||
this._attemptSignIn(CALENDAR_TYPE.MICROSOFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a React Element to indicate third party APIs are being loaded.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderLoadingState() {
|
||||
return (
|
||||
<Spinner />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a React Element to sign into a third party for calendar
|
||||
* integration.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderSignInState() {
|
||||
const {
|
||||
_appName,
|
||||
_enableGoogleIntegration,
|
||||
_enableMicrosoftIntegration,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>
|
||||
{ t('settings.calendar.about',
|
||||
{ appName: _appName || '' }) }
|
||||
</p>
|
||||
{ _enableGoogleIntegration
|
||||
&& <div className = { classes.button }>
|
||||
<GoogleSignInButton
|
||||
onClick = { this._onClickGoogle }
|
||||
text = { t('liveStreaming.signIn') } />
|
||||
</div> }
|
||||
{ _enableMicrosoftIntegration
|
||||
&& <div className = { classes.button }>
|
||||
<MicrosoftSignInButton
|
||||
onClick = { this._onClickMicrosoft }
|
||||
text = { t('settings.calendar.microsoftSignIn') } />
|
||||
</div> }
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a React Element to sign out of the currently connected third
|
||||
* party used for calendar integration.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderSignOutState() {
|
||||
const { _profileEmail, t } = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<>
|
||||
{ t('settings.calendar.signedIn',
|
||||
{ email: _profileEmail }) }
|
||||
<Button
|
||||
className = { classes.button }
|
||||
id = 'calendar_logout'
|
||||
label = { t('settings.calendar.disconnect') }
|
||||
onClick = { this._onClickDisconnect } />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code CalendarTab} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @private
|
||||
* @returns {{
|
||||
* _appName: string,
|
||||
* _enableGoogleIntegration: boolean,
|
||||
* _enableMicrosoftIntegration: boolean,
|
||||
* _isConnectedToCalendar: boolean,
|
||||
* _profileEmail: string
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState) {
|
||||
const calendarState = state['features/calendar-sync'] || {};
|
||||
const {
|
||||
googleApiApplicationClientID,
|
||||
microsoftApiApplicationClientID
|
||||
} = state['features/base/config'];
|
||||
const calendarEnabled = isCalendarEnabled(state);
|
||||
|
||||
return {
|
||||
_appName: interfaceConfig.APP_NAME,
|
||||
_enableGoogleIntegration: Boolean(
|
||||
calendarEnabled && googleApiApplicationClientID),
|
||||
_enableMicrosoftIntegration: Boolean(
|
||||
calendarEnabled && microsoftApiApplicationClientID),
|
||||
_isConnectedToCalendar: calendarState.integrationReady,
|
||||
_profileEmail: calendarState.profileEmail
|
||||
};
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect(_mapStateToProps)(CalendarTab)), styles);
|
||||
38
react/features/settings/components/web/LogoutDialog.tsx
Normal file
38
react/features/settings/components/web/LogoutDialog.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Dialog from '../../../base/ui/components/web/Dialog';
|
||||
|
||||
/**
|
||||
* The type of {@link LogoutDialog}'s React {@code Component} props.
|
||||
*/
|
||||
interface IProps extends WithTranslation {
|
||||
|
||||
/**
|
||||
* Logout handler.
|
||||
*/
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the Logout dialog.
|
||||
*
|
||||
* @param {Object} props - The props of the component.
|
||||
* @returns {React$Element}
|
||||
*/
|
||||
function LogoutDialog({ onLogout, t }: IProps) {
|
||||
return (
|
||||
<Dialog
|
||||
ok = {{ translationKey: 'dialog.Yes' }}
|
||||
onSubmit = { onLogout }
|
||||
titleKey = { t('dialog.logoutTitle') }>
|
||||
<div>
|
||||
{ t('dialog.logoutQuestion') }
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default translate(connect()(LogoutDialog));
|
||||
281
react/features/settings/components/web/ModeratorTab.tsx
Normal file
281
react/features/settings/components/web/ModeratorTab.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import AbstractDialogTab, {
|
||||
IProps as AbstractDialogTabProps } from '../../../base/dialog/components/web/AbstractDialogTab';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Checkbox from '../../../base/ui/components/web/Checkbox';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link ModeratorTab}.
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
/**
|
||||
* Whether the user has selected the audio moderation feature to be enabled.
|
||||
*/
|
||||
audioModerationEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether the user has selected the chat with permissions feature to be enabled.
|
||||
*/
|
||||
chatWithPermissionsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* Whether to hide chat with permissions.
|
||||
*/
|
||||
disableChatWithPermissions: boolean;
|
||||
|
||||
/**
|
||||
* If set hides the reactions moderation setting.
|
||||
*/
|
||||
disableReactionsModeration: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not follow me is currently active (enabled by some other participant).
|
||||
*/
|
||||
followMeActive: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the user has selected the Follow Me feature to be enabled.
|
||||
*/
|
||||
followMeEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether follow me for recorder is currently active (enabled by some other participant).
|
||||
*/
|
||||
followMeRecorderActive: boolean;
|
||||
|
||||
/**
|
||||
* Whether the user has selected the Follow Me Recorder feature to be enabled.
|
||||
*/
|
||||
followMeRecorderEnabled: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the user has selected the Start Audio Muted feature to be
|
||||
* enabled.
|
||||
*/
|
||||
startAudioMuted: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the user has selected the Start Reactions Muted feature to be
|
||||
* enabled.
|
||||
*/
|
||||
startReactionsMuted: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the user has selected the Start Video Muted feature to be
|
||||
* enabled.
|
||||
*/
|
||||
startVideoMuted: boolean;
|
||||
|
||||
/**
|
||||
* Whether the user has selected the video moderation feature to be enabled.
|
||||
*/
|
||||
videoModerationEnabled: boolean;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const
|
||||
},
|
||||
|
||||
title: {
|
||||
...theme.typography.heading6,
|
||||
color: `${theme.palette.text01} !important`,
|
||||
marginBottom: theme.spacing(3)
|
||||
},
|
||||
|
||||
checkbox: {
|
||||
marginBottom: theme.spacing(3)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying language and moderator settings.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class ModeratorTab extends AbstractDialogTab<IProps, any> {
|
||||
/**
|
||||
* Initializes a new {@code ModeratorTab} 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._onStartAudioMutedChanged = this._onStartAudioMutedChanged.bind(this);
|
||||
this._onStartVideoMutedChanged = this._onStartVideoMutedChanged.bind(this);
|
||||
this._onStartReactionsMutedChanged = this._onStartReactionsMutedChanged.bind(this);
|
||||
this._onFollowMeEnabledChanged = this._onFollowMeEnabledChanged.bind(this);
|
||||
this._onFollowMeRecorderEnabledChanged = this._onFollowMeRecorderEnabledChanged.bind(this);
|
||||
this._onChatWithPermissionsChanged = this._onChatWithPermissionsChanged.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if conferences should start
|
||||
* with audio muted.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartAudioMutedChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ startAudioMuted: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if conferences should start
|
||||
* with video disabled.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartVideoMutedChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ startVideoMuted: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if conferences should start
|
||||
* with reactions muted.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartReactionsMutedChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ startReactionsMuted: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if follow-me mode
|
||||
* should be activated.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onFollowMeEnabledChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({
|
||||
followMeEnabled: checked,
|
||||
followMeRecorderEnabled: checked ? false : undefined
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if follow-me for recorder mode should be activated.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onFollowMeRecorderEnabledChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({
|
||||
followMeEnabled: checked ? false : undefined,
|
||||
followMeRecorderEnabled: checked
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if chat with permissions should be activated.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onChatWithPermissionsChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ chatWithPermissionsEnabled: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
audioModerationEnabled,
|
||||
chatWithPermissionsEnabled,
|
||||
disableChatWithPermissions,
|
||||
disableReactionsModeration,
|
||||
followMeActive,
|
||||
followMeEnabled,
|
||||
followMeRecorderActive,
|
||||
followMeRecorderEnabled,
|
||||
startAudioMuted,
|
||||
startVideoMuted,
|
||||
startReactionsMuted,
|
||||
t,
|
||||
videoModerationEnabled
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
const followMeRecorderChecked = followMeRecorderEnabled && !followMeRecorderActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { `moderator-tab ${classes.container}` }
|
||||
key = 'moderator'>
|
||||
<h2 className = { classes.title }>
|
||||
{t('settings.moderatorOptions')}
|
||||
</h2>
|
||||
{ !audioModerationEnabled && <Checkbox
|
||||
checked = { startAudioMuted }
|
||||
className = { classes.checkbox }
|
||||
label = { t('settings.startAudioMuted') }
|
||||
name = 'start-audio-muted'
|
||||
onChange = { this._onStartAudioMutedChanged } /> }
|
||||
{ !videoModerationEnabled && <Checkbox
|
||||
checked = { startVideoMuted }
|
||||
className = { classes.checkbox }
|
||||
label = { t('settings.startVideoMuted') }
|
||||
name = 'start-video-muted'
|
||||
onChange = { this._onStartVideoMutedChanged } /> }
|
||||
<Checkbox
|
||||
checked = { followMeEnabled && !followMeActive && !followMeRecorderChecked }
|
||||
className = { classes.checkbox }
|
||||
disabled = { followMeActive || followMeRecorderActive }
|
||||
label = { t('settings.followMe') }
|
||||
name = 'follow-me'
|
||||
onChange = { this._onFollowMeEnabledChanged } />
|
||||
<Checkbox
|
||||
checked = { followMeRecorderChecked }
|
||||
className = { classes.checkbox }
|
||||
disabled = { followMeRecorderActive || followMeActive }
|
||||
label = { t('settings.followMeRecorder') }
|
||||
name = 'follow-me-recorder'
|
||||
onChange = { this._onFollowMeRecorderEnabledChanged } />
|
||||
{ !disableReactionsModeration
|
||||
&& <Checkbox
|
||||
checked = { startReactionsMuted }
|
||||
className = { classes.checkbox }
|
||||
label = { t('settings.startReactionsMuted') }
|
||||
name = 'start-reactions-muted'
|
||||
onChange = { this._onStartReactionsMutedChanged } /> }
|
||||
{ !disableChatWithPermissions
|
||||
&& <Checkbox
|
||||
checked = { chatWithPermissionsEnabled }
|
||||
className = { classes.checkbox }
|
||||
label = { t('settings.chatWithPermissions') }
|
||||
name = 'chat-with-permissions'
|
||||
onChange = { this._onChatWithPermissionsChanged } /> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(ModeratorTab), styles);
|
||||
283
react/features/settings/components/web/MoreTab.tsx
Normal file
283
react/features/settings/components/web/MoreTab.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import AbstractDialogTab, {
|
||||
IProps as AbstractDialogTabProps
|
||||
} from '../../../base/dialog/components/web/AbstractDialogTab';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Checkbox from '../../../base/ui/components/web/Checkbox';
|
||||
import Select from '../../../base/ui/components/web/Select';
|
||||
import { MAX_ACTIVE_PARTICIPANTS } from '../../../filmstrip/constants';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link MoreTab}.
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* Indicates if closed captions are enabled.
|
||||
*/
|
||||
areClosedCaptionsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* The currently selected language to display in the language select
|
||||
* dropdown.
|
||||
*/
|
||||
currentLanguage: string;
|
||||
|
||||
/**
|
||||
* Whether to show hide self view setting.
|
||||
*/
|
||||
disableHideSelfView: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not follow me is currently active (enabled by some other participant).
|
||||
*/
|
||||
followMeActive: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to hide self-view screen.
|
||||
*/
|
||||
hideSelfView: boolean;
|
||||
|
||||
/**
|
||||
* Whether we are in visitors mode.
|
||||
*/
|
||||
iAmVisitor: boolean;
|
||||
|
||||
/**
|
||||
* All available languages to display in the language select dropdown.
|
||||
*/
|
||||
languages: Array<string>;
|
||||
|
||||
/**
|
||||
* The number of max participants to display on stage.
|
||||
*/
|
||||
maxStageParticipants: number;
|
||||
|
||||
/**
|
||||
* Whether or not to display the language select dropdown.
|
||||
*/
|
||||
showLanguageSettings: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to display moderator-only settings.
|
||||
*/
|
||||
showModeratorSettings: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to show subtitles on stage.
|
||||
*/
|
||||
showSubtitlesOnStage: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the stage filmstrip is enabled.
|
||||
*/
|
||||
stageFilmstripEnabled: boolean;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
padding: '0 2px'
|
||||
},
|
||||
|
||||
divider: {
|
||||
margin: `${theme.spacing(4)} 0`,
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
border: 0,
|
||||
backgroundColor: theme.palette.ui03
|
||||
},
|
||||
|
||||
checkbox: {
|
||||
margin: `${theme.spacing(3)} 0`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying language and moderator settings.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class MoreTab extends AbstractDialogTab<IProps, any> {
|
||||
/**
|
||||
* Initializes a new {@code MoreTab} 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._renderMaxStageParticipantsSelect = this._renderMaxStageParticipantsSelect.bind(this);
|
||||
this._onMaxStageParticipantsSelect = this._onMaxStageParticipantsSelect.bind(this);
|
||||
this._onHideSelfViewChanged = this._onHideSelfViewChanged.bind(this);
|
||||
this._onShowSubtitlesOnStageChanged = this._onShowSubtitlesOnStageChanged.bind(this);
|
||||
this._onLanguageItemSelect = this._onLanguageItemSelect.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
areClosedCaptionsEnabled,
|
||||
disableHideSelfView,
|
||||
iAmVisitor,
|
||||
hideSelfView,
|
||||
showLanguageSettings,
|
||||
showSubtitlesOnStage,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { clsx('more-tab', classes.container) }
|
||||
key = 'more'>
|
||||
{this._renderMaxStageParticipantsSelect()}
|
||||
{!disableHideSelfView && !iAmVisitor && (
|
||||
<Checkbox
|
||||
checked = { hideSelfView }
|
||||
className = { classes.checkbox }
|
||||
label = { t('videothumbnail.hideSelfView') }
|
||||
name = 'hide-self-view'
|
||||
onChange = { this._onHideSelfViewChanged } />
|
||||
)}
|
||||
{areClosedCaptionsEnabled && <Checkbox
|
||||
checked = { showSubtitlesOnStage }
|
||||
className = { classes.checkbox }
|
||||
label = { t('settings.showSubtitlesOnStage') }
|
||||
name = 'show-subtitles-button'
|
||||
onChange = { this._onShowSubtitlesOnStageChanged } /> }
|
||||
{showLanguageSettings && this._renderLanguageSelect()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select a max number of stage participants from the select dropdown.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onMaxStageParticipantsSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const maxParticipants = Number(e.target.value);
|
||||
|
||||
super._onChange({ maxStageParticipants: maxParticipants });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if hide self view should be enabled.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onHideSelfViewChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ hideSelfView: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if show subtitles button should be enabled.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onShowSubtitlesOnStageChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ showSubtitlesOnStage: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select a language from select dropdown.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onLanguageItemSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const language = e.target.value;
|
||||
|
||||
super._onChange({ currentLanguage: language });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the React Element for the max stage participants dropdown.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderMaxStageParticipantsSelect() {
|
||||
const { maxStageParticipants, t, stageFilmstripEnabled } = this.props;
|
||||
|
||||
if (!stageFilmstripEnabled) {
|
||||
return null;
|
||||
}
|
||||
const maxParticipantsItems = Array(MAX_ACTIVE_PARTICIPANTS).fill(0)
|
||||
.map((no, index) => {
|
||||
return {
|
||||
value: index + 1,
|
||||
label: `${index + 1}`
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
id = 'more-maxStageParticipants-select'
|
||||
label = { t('settings.maxStageParticipants') }
|
||||
onChange = { this._onMaxStageParticipantsSelect }
|
||||
options = { maxParticipantsItems }
|
||||
value = { maxStageParticipants } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the menu item for changing displayed language.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderLanguageSelect() {
|
||||
const {
|
||||
currentLanguage,
|
||||
languages,
|
||||
t
|
||||
} = this.props;
|
||||
|
||||
const languageItems
|
||||
= languages.map((language: string) => {
|
||||
return {
|
||||
value: language,
|
||||
label: t(`languages:${language}`)
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
id = 'more-language-select'
|
||||
label = { t('settings.language') }
|
||||
onChange = { this._onLanguageItemSelect }
|
||||
options = { languageItems }
|
||||
value = { currentLanguage } />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(MoreTab), styles);
|
||||
273
react/features/settings/components/web/NotificationsTab.tsx
Normal file
273
react/features/settings/components/web/NotificationsTab.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import AbstractDialogTab, {
|
||||
IProps as AbstractDialogTabProps } from '../../../base/dialog/components/web/AbstractDialogTab';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Checkbox from '../../../base/ui/components/web/Checkbox';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link NotificationsTab}.
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* Array of disabled sounds ids.
|
||||
*/
|
||||
disabledSounds: string[];
|
||||
|
||||
/**
|
||||
* Whether or not the reactions feature is enabled.
|
||||
*/
|
||||
enableReactions: Boolean;
|
||||
|
||||
/**
|
||||
* The types of enabled notifications that can be configured and their specific visibility.
|
||||
*/
|
||||
enabledNotifications: Object;
|
||||
|
||||
/**
|
||||
* Whether or not moderator muted the sounds.
|
||||
*/
|
||||
moderatorMutedSoundsReactions: Boolean;
|
||||
|
||||
/**
|
||||
* Whether or not to display notifications settings.
|
||||
*/
|
||||
showNotificationsSettings: boolean;
|
||||
|
||||
/**
|
||||
* Whether sound settings should be displayed or not.
|
||||
*/
|
||||
showSoundsSettings: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the sound for the incoming message should play.
|
||||
*/
|
||||
soundsIncomingMessage: Boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the sound for the participant joined should play.
|
||||
*/
|
||||
soundsParticipantJoined: Boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the sound for the participant entering the lobby should play.
|
||||
*/
|
||||
soundsParticipantKnocking: Boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the sound for the participant left should play.
|
||||
*/
|
||||
soundsParticipantLeft: Boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the sound for reactions should play.
|
||||
*/
|
||||
soundsReactions: Boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the sound for the talk while muted notification should play.
|
||||
*/
|
||||
soundsTalkWhileMuted: Boolean;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
|
||||
'@media (max-width: 607px)': {
|
||||
flexDirection: 'column' as const
|
||||
}
|
||||
},
|
||||
|
||||
column: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
flex: 1,
|
||||
|
||||
'&:first-child:not(:last-child)': {
|
||||
marginRight: theme.spacing(3),
|
||||
|
||||
'@media (max-width: 607px)': {
|
||||
marginRight: 0,
|
||||
marginBottom: theme.spacing(3)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
title: {
|
||||
...theme.typography.heading6,
|
||||
color: `${theme.palette.text01} !important`,
|
||||
marginBottom: theme.spacing(3)
|
||||
},
|
||||
|
||||
checkbox: {
|
||||
marginBottom: theme.spacing(3)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying the local user's sound settings.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class NotificationsTab extends AbstractDialogTab<IProps, any> {
|
||||
/**
|
||||
* Initializes a new {@code SoundsTab} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code SoundsTab} instance with.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this._onEnabledNotificationsChanged = this._onEnabledNotificationsChanged.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a sound setting state.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
override _onChange({ target }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ [target.name]: target.checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if the given type of
|
||||
* notifications should be shown.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
* @param {string} type - The type of the notification.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onEnabledNotificationsChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>, type: any) {
|
||||
super._onChange({
|
||||
enabledNotifications: {
|
||||
...this.props.enabledNotifications,
|
||||
[type]: checked
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
disabledSounds,
|
||||
enabledNotifications,
|
||||
showNotificationsSettings,
|
||||
showSoundsSettings,
|
||||
soundsIncomingMessage,
|
||||
soundsParticipantJoined,
|
||||
soundsParticipantKnocking,
|
||||
soundsParticipantLeft,
|
||||
soundsTalkWhileMuted,
|
||||
soundsReactions,
|
||||
enableReactions,
|
||||
moderatorMutedSoundsReactions,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<form
|
||||
className = { classes.container }
|
||||
key = 'sounds'>
|
||||
{showSoundsSettings && (
|
||||
<fieldset className = { classes.column }>
|
||||
<legend className = { classes.title }>
|
||||
{t('settings.playSounds')}
|
||||
</legend>
|
||||
{enableReactions && <Checkbox
|
||||
checked = { soundsReactions && !disabledSounds.includes('REACTION_SOUND') }
|
||||
className = { classes.checkbox }
|
||||
disabled = { Boolean(moderatorMutedSoundsReactions
|
||||
|| disabledSounds.includes('REACTION_SOUND')) }
|
||||
label = { t('settings.reactions') }
|
||||
name = 'soundsReactions'
|
||||
onChange = { this._onChange } />
|
||||
}
|
||||
<Checkbox
|
||||
checked = { soundsIncomingMessage && !disabledSounds.includes('INCOMING_MSG_SOUND') }
|
||||
className = { classes.checkbox }
|
||||
disabled = { disabledSounds.includes('INCOMING_MSG_SOUND') }
|
||||
label = { t('settings.incomingMessage') }
|
||||
name = 'soundsIncomingMessage'
|
||||
onChange = { this._onChange } />
|
||||
<Checkbox
|
||||
checked = { soundsParticipantJoined
|
||||
&& !disabledSounds.includes('PARTICIPANT_JOINED_SOUND') }
|
||||
className = { classes.checkbox }
|
||||
disabled = { disabledSounds.includes('PARTICIPANT_JOINED_SOUND') }
|
||||
label = { t('settings.participantJoined') }
|
||||
name = 'soundsParticipantJoined'
|
||||
onChange = { this._onChange } />
|
||||
<Checkbox
|
||||
checked = { soundsParticipantLeft && !disabledSounds.includes('PARTICIPANT_LEFT_SOUND') }
|
||||
className = { classes.checkbox }
|
||||
disabled = { disabledSounds.includes('PARTICIPANT_LEFT_SOUND') }
|
||||
label = { t('settings.participantLeft') }
|
||||
name = 'soundsParticipantLeft'
|
||||
onChange = { this._onChange } />
|
||||
<Checkbox
|
||||
checked = { soundsTalkWhileMuted && !disabledSounds.includes('TALK_WHILE_MUTED_SOUND') }
|
||||
className = { classes.checkbox }
|
||||
disabled = { disabledSounds.includes('TALK_WHILE_MUTED_SOUND') }
|
||||
label = { t('settings.talkWhileMuted') }
|
||||
name = 'soundsTalkWhileMuted'
|
||||
onChange = { this._onChange } />
|
||||
<Checkbox
|
||||
checked = { soundsParticipantKnocking
|
||||
&& !disabledSounds.includes('KNOCKING_PARTICIPANT_SOUND') }
|
||||
className = { classes.checkbox }
|
||||
disabled = { disabledSounds.includes('KNOCKING_PARTICIPANT_SOUND') }
|
||||
label = { t('settings.participantKnocking') }
|
||||
name = 'soundsParticipantKnocking'
|
||||
onChange = { this._onChange } />
|
||||
</fieldset>
|
||||
)}
|
||||
{showNotificationsSettings && (
|
||||
<fieldset className = { classes.column }>
|
||||
<legend className = { classes.title }>
|
||||
{t('notify.displayNotifications')}
|
||||
</legend>
|
||||
{
|
||||
Object.keys(enabledNotifications).map(key => (
|
||||
<Checkbox
|
||||
checked = { Boolean(enabledNotifications[key as
|
||||
keyof typeof enabledNotifications]) }
|
||||
className = { classes.checkbox }
|
||||
key = { key }
|
||||
label = { t(key) }
|
||||
name = { `show-${key}` }
|
||||
/* eslint-disable-next-line react/jsx-no-bind */
|
||||
onChange = { e => this._onEnabledNotificationsChanged(e, key) } />
|
||||
))
|
||||
}
|
||||
</fieldset>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(NotificationsTab), styles);
|
||||
251
react/features/settings/components/web/ProfileTab.tsx
Normal file
251
react/features/settings/components/web/ProfileTab.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import { createProfilePanelButtonEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { IStore } from '../../../app/types';
|
||||
import { login, logout } from '../../../authentication/actions.web';
|
||||
import Avatar from '../../../base/avatar/components/Avatar';
|
||||
import AbstractDialogTab, {
|
||||
IProps as AbstractDialogTabProps } from '../../../base/dialog/components/web/AbstractDialogTab';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Button from '../../../base/ui/components/web/Button';
|
||||
import Input from '../../../base/ui/components/web/Input';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link ProfileTab}.
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* Whether server-side authentication is available.
|
||||
*/
|
||||
authEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The name of the currently (server-side) authenticated user.
|
||||
*/
|
||||
authLogin: string;
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* Invoked to change the configured calendar integration.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* The display name to display for the local participant.
|
||||
*/
|
||||
displayName: string;
|
||||
|
||||
/**
|
||||
* The email to display for the local participant.
|
||||
*/
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* Whether to hide the email input in the profile settings.
|
||||
*/
|
||||
hideEmailInSettings?: boolean;
|
||||
|
||||
/**
|
||||
* The id of the local participant.
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* If the display name is read only.
|
||||
*/
|
||||
readOnlyName: boolean;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
width: '100%',
|
||||
padding: '0 2px'
|
||||
},
|
||||
|
||||
avatarContainer: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
marginBottom: theme.spacing(4)
|
||||
},
|
||||
|
||||
bottomMargin: {
|
||||
marginBottom: theme.spacing(4)
|
||||
},
|
||||
|
||||
label: {
|
||||
color: `${theme.palette.text01} !important`,
|
||||
...theme.typography.bodyShortRegular,
|
||||
marginBottom: theme.spacing(2)
|
||||
},
|
||||
|
||||
name: {
|
||||
marginBottom: theme.spacing(1)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying the local user's profile.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class ProfileTab extends AbstractDialogTab<IProps, any> {
|
||||
static defaultProps = {
|
||||
displayName: '',
|
||||
email: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes a new {@code ConnectedSettingsDialog} instance.
|
||||
*
|
||||
* @param {IProps} props - The React {@code Component} props to initialize
|
||||
* the new {@code ConnectedSettingsDialog} instance with.
|
||||
*/
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
// Bind event handlers so they are only bound once for every instance.
|
||||
this._onAuthToggle = this._onAuthToggle.bind(this);
|
||||
this._onDisplayNameChange = this._onDisplayNameChange.bind(this);
|
||||
this._onEmailChange = this._onEmailChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes display name of the user.
|
||||
*
|
||||
* @param {string} value - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onDisplayNameChange(value: string) {
|
||||
super._onChange({ displayName: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes email of the user.
|
||||
*
|
||||
* @param {string} value - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onEmailChange(value: string) {
|
||||
super._onChange({ email: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
authEnabled,
|
||||
displayName,
|
||||
email,
|
||||
hideEmailInSettings,
|
||||
id,
|
||||
readOnlyName,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<div className = { classes.container } >
|
||||
<div className = { classes.avatarContainer }>
|
||||
<Avatar
|
||||
participantId = { id }
|
||||
size = { 60 } />
|
||||
</div>
|
||||
<Input
|
||||
className = { classes.bottomMargin }
|
||||
disabled = { readOnlyName }
|
||||
id = 'setDisplayName'
|
||||
label = { t('profile.setDisplayNameLabel') }
|
||||
name = 'name'
|
||||
onChange = { this._onDisplayNameChange }
|
||||
placeholder = { t('settings.name') }
|
||||
type = 'text'
|
||||
value = { displayName } />
|
||||
{!hideEmailInSettings && <div className = 'profile-edit-field'>
|
||||
<Input
|
||||
className = { classes.bottomMargin }
|
||||
id = 'setEmail'
|
||||
label = { t('profile.setEmailLabel') }
|
||||
name = 'email'
|
||||
onChange = { this._onEmailChange }
|
||||
placeholder = { t('profile.setEmailInput') }
|
||||
type = 'text'
|
||||
value = { email } />
|
||||
</div>}
|
||||
{ authEnabled && this._renderAuth() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the dialog for logging in or out of a server and closes this
|
||||
* dialog.
|
||||
*
|
||||
* @private
|
||||
* @returns {void}
|
||||
*/
|
||||
_onAuthToggle() {
|
||||
if (this.props.authLogin) {
|
||||
sendAnalytics(createProfilePanelButtonEvent('logout.button'));
|
||||
|
||||
this.props.dispatch(logout());
|
||||
} else {
|
||||
sendAnalytics(createProfilePanelButtonEvent('login.button'));
|
||||
|
||||
this.props.dispatch(login());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a React Element for interacting with server-side authentication.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_renderAuth() {
|
||||
const {
|
||||
authLogin,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className = { classes.label }>
|
||||
{ t('toolbar.authenticate') }
|
||||
</h2>
|
||||
{ authLogin
|
||||
&& <div className = { classes.name }>
|
||||
{ t('settings.loggedIn', { name: authLogin }) }
|
||||
</div> }
|
||||
<Button
|
||||
accessibilityLabel = { authLogin ? t('toolbar.logout') : t('toolbar.login') }
|
||||
id = 'login_button'
|
||||
label = { authLogin ? t('toolbar.logout') : t('toolbar.login') }
|
||||
onClick = { this._onAuthToggle } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(connect()(ProfileTab)), styles);
|
||||
50
react/features/settings/components/web/SettingsButton.ts
Normal file
50
react/features/settings/components/web/SettingsButton.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
|
||||
import { sendAnalytics } from '../../../analytics/functions';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import { IconGear } from '../../../base/icons/svg';
|
||||
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
|
||||
import { openSettingsDialog } from '../../actions';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SettingsButton}.
|
||||
*/
|
||||
interface IProps extends AbstractButtonProps {
|
||||
|
||||
/**
|
||||
* The default tab at which the settings dialog will be opened.
|
||||
*/
|
||||
defaultTab: string;
|
||||
|
||||
/**
|
||||
* Indicates whether the device selection dialog is displayed on the
|
||||
* welcome page or not.
|
||||
*/
|
||||
isDisplayedOnWelcomePage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstract implementation of a button for accessing settings.
|
||||
*/
|
||||
class SettingsButton extends AbstractButton<IProps> {
|
||||
override accessibilityLabel = 'toolbar.accessibilityLabel.Settings';
|
||||
override icon = IconGear;
|
||||
override label = 'toolbar.Settings';
|
||||
override tooltip = 'toolbar.Settings';
|
||||
|
||||
/**
|
||||
* Handles clicking / pressing the button, and opens the appropriate dialog.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
override _handleClick() {
|
||||
const { dispatch, isDisplayedOnWelcomePage = false } = this.props;
|
||||
|
||||
sendAnalytics(createToolbarEvent('settings'));
|
||||
dispatch(openSettingsDialog(undefined, isDisplayedOnWelcomePage));
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(SettingsButton));
|
||||
330
react/features/settings/components/web/SettingsDialog.tsx
Normal file
330
react/features/settings/components/web/SettingsDialog.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../app/types';
|
||||
import {
|
||||
IconBell,
|
||||
IconCalendar,
|
||||
IconGear,
|
||||
IconImage,
|
||||
IconModerator,
|
||||
IconShortcuts,
|
||||
IconUser,
|
||||
IconVideo,
|
||||
IconVolumeUp
|
||||
} from '../../../base/icons/svg';
|
||||
import DialogWithTabs, { IDialogTab } from '../../../base/ui/components/web/DialogWithTabs';
|
||||
import { isCalendarEnabled } from '../../../calendar-sync/functions.web';
|
||||
import { submitAudioDeviceSelectionTab, submitVideoDeviceSelectionTab } from '../../../device-selection/actions.web';
|
||||
import AudioDevicesSelection from '../../../device-selection/components/AudioDevicesSelection';
|
||||
import VideoDeviceSelection from '../../../device-selection/components/VideoDeviceSelection';
|
||||
import {
|
||||
getAudioDeviceSelectionDialogProps,
|
||||
getVideoDeviceSelectionDialogProps
|
||||
} from '../../../device-selection/functions.web';
|
||||
import { checkBlurSupport, checkVirtualBackgroundEnabled } from '../../../virtual-background/functions';
|
||||
import { iAmVisitor } from '../../../visitors/functions';
|
||||
import {
|
||||
submitModeratorTab,
|
||||
submitMoreTab,
|
||||
submitNotificationsTab,
|
||||
submitProfileTab,
|
||||
submitShortcutsTab,
|
||||
submitVirtualBackgroundTab
|
||||
} from '../../actions';
|
||||
import { SETTINGS_TABS } from '../../constants';
|
||||
import {
|
||||
getModeratorTabProps,
|
||||
getMoreTabProps,
|
||||
getNotificationsMap,
|
||||
getNotificationsTabProps,
|
||||
getProfileTabProps,
|
||||
getShortcutsTabProps,
|
||||
getVirtualBackgroundTabProps
|
||||
} from '../../functions';
|
||||
|
||||
import CalendarTab from './CalendarTab';
|
||||
import ModeratorTab from './ModeratorTab';
|
||||
import MoreTab from './MoreTab';
|
||||
import NotificationsTab from './NotificationsTab';
|
||||
import ProfileTab from './ProfileTab';
|
||||
import ShortcutsTab from './ShortcutsTab';
|
||||
import VirtualBackgroundTab from './VirtualBackgroundTab';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of
|
||||
* {@link ConnectedSettingsDialog}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Information about the tabs to be rendered.
|
||||
*/
|
||||
_tabs: IDialogTab<any>[];
|
||||
|
||||
/**
|
||||
* Which settings tab should be initially displayed. If not defined then
|
||||
* the first tab will be displayed.
|
||||
*/
|
||||
defaultTab: string;
|
||||
|
||||
/**
|
||||
* Invoked to save changed settings.
|
||||
*/
|
||||
dispatch: IStore['dispatch'];
|
||||
|
||||
/**
|
||||
* Indicates whether the device selection dialog is displayed on the
|
||||
* welcome page or not.
|
||||
*/
|
||||
isDisplayedOnWelcomePage: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
settingsDialog: {
|
||||
display: 'flex',
|
||||
width: '100%'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const SettingsDialog = ({ _tabs, defaultTab, dispatch }: IProps) => {
|
||||
const { classes } = useStyles();
|
||||
|
||||
const correctDefaultTab = _tabs.find(tab => tab.name === defaultTab)?.name;
|
||||
const tabs = _tabs.map(tab => {
|
||||
return {
|
||||
...tab,
|
||||
className: `settings-pane ${classes.settingsDialog}`,
|
||||
submit: (...args: any) => tab.submit
|
||||
&& dispatch(tab.submit(...args))
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogWithTabs
|
||||
className = 'settings-dialog'
|
||||
defaultTab = { correctDefaultTab }
|
||||
tabs = { tabs }
|
||||
titleKey = 'settings.title' />
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps (parts of) the Redux state to the associated props for the
|
||||
* {@code ConnectedSettingsDialog} component.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @param {Object} ownProps - The props passed to the component.
|
||||
* @private
|
||||
* @returns {{
|
||||
* tabs: Array<Object>
|
||||
* }}
|
||||
*/
|
||||
function _mapStateToProps(state: IReduxState, ownProps: any) {
|
||||
const { isDisplayedOnWelcomePage } = ownProps;
|
||||
const configuredTabs = interfaceConfig.SETTINGS_SECTIONS || [];
|
||||
|
||||
// The settings sections to display.
|
||||
const showDeviceSettings = configuredTabs.includes('devices');
|
||||
const moreTabProps = getMoreTabProps(state);
|
||||
const moderatorTabProps = getModeratorTabProps(state);
|
||||
const { showModeratorSettings } = moderatorTabProps;
|
||||
const showMoreTab = configuredTabs.includes('more');
|
||||
const showProfileSettings
|
||||
= configuredTabs.includes('profile') && !state['features/base/config'].disableProfile;
|
||||
const showCalendarSettings
|
||||
= configuredTabs.includes('calendar') && isCalendarEnabled(state);
|
||||
const showSoundsSettings = configuredTabs.includes('sounds');
|
||||
const enabledNotifications = getNotificationsMap(state);
|
||||
const showNotificationsSettings = Object.keys(enabledNotifications).length > 0;
|
||||
const virtualBackgroundSupported = checkBlurSupport();
|
||||
const enableVirtualBackground = checkVirtualBackgroundEnabled(state);
|
||||
const tabs: IDialogTab<any>[] = [];
|
||||
const _iAmVisitor = iAmVisitor(state);
|
||||
|
||||
if (showDeviceSettings) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.AUDIO,
|
||||
component: AudioDevicesSelection,
|
||||
labelKey: 'settings.audio',
|
||||
props: getAudioDeviceSelectionDialogProps(state, isDisplayedOnWelcomePage),
|
||||
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getAudioDeviceSelectionDialogProps>) => {
|
||||
// Ensure the device selection tab gets updated when new devices
|
||||
// are found by taking the new props and only preserving the
|
||||
// current user selected devices. If this were not done, the
|
||||
// tab would keep using a copy of the initial props it received,
|
||||
// leaving the device list to become stale.
|
||||
|
||||
return {
|
||||
...newProps,
|
||||
noiseSuppressionEnabled: tabState.noiseSuppressionEnabled,
|
||||
selectedAudioInputId: tabState.selectedAudioInputId,
|
||||
selectedAudioOutputId: tabState.selectedAudioOutputId
|
||||
};
|
||||
},
|
||||
submit: (newState: any) => submitAudioDeviceSelectionTab(newState, isDisplayedOnWelcomePage),
|
||||
icon: IconVolumeUp
|
||||
});
|
||||
!_iAmVisitor && tabs.push({
|
||||
name: SETTINGS_TABS.VIDEO,
|
||||
component: VideoDeviceSelection,
|
||||
labelKey: 'settings.video',
|
||||
props: getVideoDeviceSelectionDialogProps(state, isDisplayedOnWelcomePage),
|
||||
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getVideoDeviceSelectionDialogProps>) => {
|
||||
// Ensure the device selection tab gets updated when new devices
|
||||
// are found by taking the new props and only preserving the
|
||||
// current user selected devices. If this were not done, the
|
||||
// tab would keep using a copy of the initial props it received,
|
||||
// leaving the device list to become stale.
|
||||
|
||||
return {
|
||||
...newProps,
|
||||
currentFramerate: tabState?.currentFramerate,
|
||||
localFlipX: tabState.localFlipX,
|
||||
selectedVideoInputId: tabState.selectedVideoInputId
|
||||
};
|
||||
},
|
||||
submit: (newState: any) => submitVideoDeviceSelectionTab(newState, isDisplayedOnWelcomePage),
|
||||
icon: IconVideo
|
||||
});
|
||||
}
|
||||
|
||||
if (virtualBackgroundSupported && !_iAmVisitor && enableVirtualBackground) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.VIRTUAL_BACKGROUND,
|
||||
component: VirtualBackgroundTab,
|
||||
labelKey: 'virtualBackground.title',
|
||||
props: getVirtualBackgroundTabProps(state, isDisplayedOnWelcomePage),
|
||||
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getVirtualBackgroundTabProps>,
|
||||
tabStates: any) => {
|
||||
const videoTabState = tabStates[tabs.findIndex(tab => tab.name === SETTINGS_TABS.VIDEO)];
|
||||
|
||||
return {
|
||||
...newProps,
|
||||
selectedVideoInputId: videoTabState?.selectedVideoInputId || newProps.selectedVideoInputId,
|
||||
options: tabState.options
|
||||
};
|
||||
},
|
||||
submit: (newState: any) => submitVirtualBackgroundTab(newState),
|
||||
cancel: () => {
|
||||
const { options } = getVirtualBackgroundTabProps(state, isDisplayedOnWelcomePage);
|
||||
|
||||
return submitVirtualBackgroundTab({ options }, true);
|
||||
},
|
||||
icon: IconImage
|
||||
});
|
||||
}
|
||||
|
||||
if ((showSoundsSettings || showNotificationsSettings) && !_iAmVisitor) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.NOTIFICATIONS,
|
||||
component: NotificationsTab,
|
||||
labelKey: 'settings.notifications',
|
||||
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getNotificationsTabProps>) => {
|
||||
return {
|
||||
...newProps,
|
||||
enabledNotifications: tabState?.enabledNotifications || {},
|
||||
soundsIncomingMessage: tabState?.soundsIncomingMessage,
|
||||
soundsParticipantJoined: tabState?.soundsParticipantJoined,
|
||||
soundsParticipantKnocking: tabState?.soundsParticipantKnocking,
|
||||
soundsParticipantLeft: tabState?.soundsParticipantLeft,
|
||||
soundsReactions: tabState?.soundsReactions,
|
||||
soundsTalkWhileMuted: tabState?.soundsTalkWhileMuted
|
||||
};
|
||||
},
|
||||
props: getNotificationsTabProps(state, showSoundsSettings),
|
||||
submit: submitNotificationsTab,
|
||||
icon: IconBell
|
||||
});
|
||||
}
|
||||
|
||||
if (showModeratorSettings && !_iAmVisitor) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.MODERATOR,
|
||||
component: ModeratorTab,
|
||||
labelKey: 'settings.moderator',
|
||||
props: moderatorTabProps,
|
||||
propsUpdateFunction: (tabState: any, newProps: typeof moderatorTabProps) => {
|
||||
// Updates tab props, keeping users selection
|
||||
|
||||
return {
|
||||
...newProps,
|
||||
chatWithPermissionsEnabled: tabState?.chatWithPermissionsEnabled,
|
||||
followMeEnabled: tabState?.followMeEnabled,
|
||||
followMeRecorderEnabled: tabState?.followMeRecorderEnabled,
|
||||
startAudioMuted: tabState?.startAudioMuted,
|
||||
startVideoMuted: tabState?.startVideoMuted,
|
||||
startReactionsMuted: tabState?.startReactionsMuted
|
||||
};
|
||||
},
|
||||
submit: submitModeratorTab,
|
||||
icon: IconModerator
|
||||
});
|
||||
}
|
||||
|
||||
if (showProfileSettings) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.PROFILE,
|
||||
component: ProfileTab,
|
||||
labelKey: 'profile.title',
|
||||
props: getProfileTabProps(state),
|
||||
submit: submitProfileTab,
|
||||
icon: IconUser
|
||||
});
|
||||
}
|
||||
|
||||
if (showCalendarSettings && !_iAmVisitor) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.CALENDAR,
|
||||
component: CalendarTab,
|
||||
labelKey: 'settings.calendar.title',
|
||||
icon: IconCalendar
|
||||
});
|
||||
}
|
||||
|
||||
!_iAmVisitor && tabs.push({
|
||||
name: SETTINGS_TABS.SHORTCUTS,
|
||||
component: ShortcutsTab,
|
||||
labelKey: 'settings.shortcuts',
|
||||
props: getShortcutsTabProps(state, isDisplayedOnWelcomePage),
|
||||
propsUpdateFunction: (tabState: any, newProps: ReturnType<typeof getShortcutsTabProps>) => {
|
||||
// Updates tab props, keeping users selection
|
||||
|
||||
return {
|
||||
...newProps,
|
||||
keyboardShortcutsEnabled: tabState?.keyboardShortcutsEnabled
|
||||
};
|
||||
},
|
||||
submit: submitShortcutsTab,
|
||||
icon: IconShortcuts
|
||||
});
|
||||
|
||||
if (showMoreTab && !_iAmVisitor) {
|
||||
tabs.push({
|
||||
name: SETTINGS_TABS.MORE,
|
||||
component: MoreTab,
|
||||
labelKey: 'settings.more',
|
||||
props: moreTabProps,
|
||||
propsUpdateFunction: (tabState: any, newProps: typeof moreTabProps) => {
|
||||
// Updates tab props, keeping users selection
|
||||
|
||||
return {
|
||||
...newProps,
|
||||
currentLanguage: tabState?.currentLanguage,
|
||||
hideSelfView: tabState?.hideSelfView,
|
||||
showSubtitlesOnStage: tabState?.showSubtitlesOnStage,
|
||||
maxStageParticipants: tabState?.maxStageParticipants
|
||||
};
|
||||
},
|
||||
submit: submitMoreTab,
|
||||
icon: IconGear
|
||||
});
|
||||
}
|
||||
|
||||
return { _tabs: tabs };
|
||||
}
|
||||
|
||||
export default connect(_mapStateToProps)(SettingsDialog);
|
||||
178
react/features/settings/components/web/ShortcutsTab.tsx
Normal file
178
react/features/settings/components/web/ShortcutsTab.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Theme } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import AbstractDialogTab, {
|
||||
IProps as AbstractDialogTabProps } from '../../../base/dialog/components/web/AbstractDialogTab';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import Checkbox from '../../../base/ui/components/web/Checkbox';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link ShortcutsTab}.
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* Whether to display the shortcuts or not.
|
||||
*/
|
||||
displayShortcuts: boolean;
|
||||
|
||||
/**
|
||||
* Whether the keyboard shortcuts are enabled or not.
|
||||
*/
|
||||
keyboardShortcutsEnabled: boolean;
|
||||
|
||||
/**
|
||||
* The keyboard shortcuts descriptions.
|
||||
*/
|
||||
keyboardShortcutsHelpDescriptions: Map<string, string>;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
width: '100%',
|
||||
paddingBottom: theme.spacing(3)
|
||||
},
|
||||
|
||||
checkbox: {
|
||||
marginBottom: theme.spacing(3)
|
||||
},
|
||||
|
||||
listContainer: {
|
||||
listStyleType: 'none',
|
||||
padding: 0,
|
||||
margin: 0
|
||||
},
|
||||
|
||||
listItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: `${theme.spacing(1)} 0`,
|
||||
...theme.typography.bodyShortRegular,
|
||||
color: theme.palette.text01
|
||||
},
|
||||
|
||||
listItemKey: {
|
||||
backgroundColor: theme.palette.ui04,
|
||||
...theme.typography.labelBold,
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
|
||||
borderRadius: `${Number(theme.shape.borderRadius) / 2}px`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying the local user's profile.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class ShortcutsTab extends AbstractDialogTab<IProps, any> {
|
||||
/**
|
||||
* Initializes a new {@code MoreTab} 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._onKeyboardShortcutEnableChanged = this._onKeyboardShortcutEnableChanged.bind(this);
|
||||
this._renderShortcutsListItem = this._renderShortcutsListItem.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if global keyboard shortcuts
|
||||
* should be enabled.
|
||||
*
|
||||
* @param {Object} e - The key event to handle.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onKeyboardShortcutEnableChanged({ target: { checked } }: React.ChangeEvent<HTMLInputElement>) {
|
||||
super._onChange({ keyboardShortcutsEnabled: checked });
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a keyboard shortcut with key and description.
|
||||
*
|
||||
* @param {string} keyboardKey - The keyboard key for the shortcut.
|
||||
* @param {string} translationKey - The translation key for the shortcut description.
|
||||
* @returns {JSX}
|
||||
*/
|
||||
_renderShortcutsListItem(keyboardKey: string, translationKey: string) {
|
||||
const { t } = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
let modifierKey = 'Alt';
|
||||
|
||||
if (window.navigator?.platform) {
|
||||
if (window.navigator.platform.indexOf('Mac') !== -1) {
|
||||
modifierKey = '⌥';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className = { classes.listItem }
|
||||
key = { keyboardKey }>
|
||||
<span
|
||||
aria-label = { t(translationKey) }>
|
||||
{t(translationKey)}
|
||||
</span>
|
||||
<span className = { classes.listItemKey }>
|
||||
{keyboardKey.startsWith(':')
|
||||
? `${modifierKey} + ${keyboardKey.slice(1)}`
|
||||
: keyboardKey}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
displayShortcuts,
|
||||
keyboardShortcutsHelpDescriptions,
|
||||
keyboardShortcutsEnabled,
|
||||
t
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
const shortcutDescriptions: Map<string, string> = displayShortcuts
|
||||
? keyboardShortcutsHelpDescriptions
|
||||
: new Map();
|
||||
|
||||
return (
|
||||
<div className = { classes.container }>
|
||||
<Checkbox
|
||||
checked = { keyboardShortcutsEnabled }
|
||||
className = { classes.checkbox }
|
||||
label = { t('prejoin.keyboardShortcuts') }
|
||||
name = 'enable-keyboard-shortcuts'
|
||||
onChange = { this._onKeyboardShortcutEnableChanged } />
|
||||
{displayShortcuts && (
|
||||
<ul className = { classes.listContainer }>
|
||||
{Array.from(shortcutDescriptions)
|
||||
.map(description => this._renderShortcutsListItem(...description))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(ShortcutsTab), styles);
|
||||
102
react/features/settings/components/web/VirtualBackgroundTab.tsx
Normal file
102
react/features/settings/components/web/VirtualBackgroundTab.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { WithTranslation } from 'react-i18next';
|
||||
import { withStyles } from 'tss-react/mui';
|
||||
|
||||
import AbstractDialogTab, {
|
||||
IProps as AbstractDialogTabProps
|
||||
} from '../../../base/dialog/components/web/AbstractDialogTab';
|
||||
import { translate } from '../../../base/i18n/functions';
|
||||
import VirtualBackgrounds from '../../../virtual-background/components/VirtualBackgrounds';
|
||||
import { IVirtualBackground } from '../../../virtual-background/reducer';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link VirtualBackgroundTab}.
|
||||
*/
|
||||
export interface IProps extends AbstractDialogTabProps, WithTranslation {
|
||||
|
||||
/**
|
||||
* CSS classes object.
|
||||
*/
|
||||
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
|
||||
|
||||
/**
|
||||
* Virtual background options.
|
||||
*/
|
||||
options: IVirtualBackground;
|
||||
|
||||
/**
|
||||
* The id of the selected video device.
|
||||
*/
|
||||
selectedVideoInputId: string;
|
||||
}
|
||||
|
||||
const styles = () => {
|
||||
return {
|
||||
container: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* React {@code Component} for modifying language and moderator settings.
|
||||
*
|
||||
* @augments Component
|
||||
*/
|
||||
class VirtualBackgroundTab extends AbstractDialogTab<IProps, any> {
|
||||
/**
|
||||
* Initializes a new {@code VirtualBackgroundTab} 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._onOptionsChanged = this._onOptionsChanged.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback invoked to select if follow-me mode
|
||||
* should be activated.
|
||||
*
|
||||
* @param {Object} options - The new background options.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onOptionsChanged(options: any) {
|
||||
super._onChange({ options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
override render() {
|
||||
const {
|
||||
options,
|
||||
selectedVideoInputId
|
||||
} = this.props;
|
||||
const classes = withStyles.getClasses(this.props);
|
||||
|
||||
return (
|
||||
<div
|
||||
className = { classes.container }
|
||||
id = 'virtual-background-dialog'
|
||||
key = 'virtual-background'>
|
||||
<VirtualBackgrounds
|
||||
onOptionsChange = { this._onOptionsChanged }
|
||||
options = { options }
|
||||
selectedThumbnail = { options.selectedThumbnail ?? '' }
|
||||
selectedVideoInputId = { selectedVideoInputId } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(translate(VirtualBackgroundTab), styles);
|
||||
@@ -0,0 +1,357 @@
|
||||
/* eslint-disable react/no-multi-comp */
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../../app/types';
|
||||
import { IconMic, IconVolumeUp } from '../../../../base/icons/svg';
|
||||
import JitsiMeetJS from '../../../../base/lib-jitsi-meet';
|
||||
import { equals } from '../../../../base/redux/functions';
|
||||
import Checkbox from '../../../../base/ui/components/web/Checkbox';
|
||||
import ContextMenu from '../../../../base/ui/components/web/ContextMenu';
|
||||
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
|
||||
import ContextMenuItemGroup from '../../../../base/ui/components/web/ContextMenuItemGroup';
|
||||
import { toggleNoiseSuppression } from '../../../../noise-suppression/actions';
|
||||
import { isNoiseSuppressionEnabled } from '../../../../noise-suppression/functions';
|
||||
import { isPrejoinPageVisible } from '../../../../prejoin/functions';
|
||||
import { createLocalAudioTracks } from '../../../functions.web';
|
||||
|
||||
import MicrophoneEntry from './MicrophoneEntry';
|
||||
import SpeakerEntry from './SpeakerEntry';
|
||||
|
||||
const browser = JitsiMeetJS.util.browser;
|
||||
|
||||
/**
|
||||
* Translates the default device label into a more user friendly one.
|
||||
*
|
||||
* @param {string} deviceId - The device Id.
|
||||
* @param {string} label - The device label.
|
||||
* @param {Function} t - The translation function.
|
||||
* @returns {string}
|
||||
*/
|
||||
function transformDefaultDeviceLabel(deviceId: string, label: string, t: Function) {
|
||||
return deviceId === 'default'
|
||||
? t('settings.sameAsSystem', { label: label.replace('Default - ', '') })
|
||||
: label;
|
||||
}
|
||||
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* The deviceId of the microphone in use.
|
||||
*/
|
||||
currentMicDeviceId: string;
|
||||
|
||||
/**
|
||||
* The deviceId of the output device in use.
|
||||
*/
|
||||
currentOutputDeviceId?: string;
|
||||
|
||||
/**
|
||||
* Used to decide whether to measure audio levels for microphone devices.
|
||||
*/
|
||||
measureAudioLevels: boolean;
|
||||
|
||||
/**
|
||||
* A list with objects containing the labels and deviceIds
|
||||
* of all the input devices.
|
||||
*/
|
||||
microphoneDevices: Array<{ deviceId: string; label: string; }>;
|
||||
|
||||
/**
|
||||
* Whether noise suppression is enabled or not.
|
||||
*/
|
||||
noiseSuppressionEnabled: boolean;
|
||||
|
||||
/**
|
||||
* A list of objects containing the labels and deviceIds
|
||||
* of all the output devices.
|
||||
*/
|
||||
outputDevices: Array<{ deviceId: string; label: string; }>;
|
||||
|
||||
/**
|
||||
* Whether the prejoin page is visible or not.
|
||||
*/
|
||||
prejoinVisible: boolean;
|
||||
|
||||
/**
|
||||
* Used to set a new microphone as the current one.
|
||||
*/
|
||||
setAudioInputDevice: Function;
|
||||
|
||||
/**
|
||||
* Used to set a new output device as the current one.
|
||||
*/
|
||||
setAudioOutputDevice: Function;
|
||||
|
||||
/**
|
||||
* Function to toggle noise suppression.
|
||||
*/
|
||||
toggleSuppression: () => void;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
contextMenu: {
|
||||
position: 'relative',
|
||||
right: 'auto',
|
||||
margin: 0,
|
||||
marginBottom: theme.spacing(1),
|
||||
maxHeight: 'calc(100dvh - 100px)',
|
||||
overflow: 'auto',
|
||||
width: '300px'
|
||||
},
|
||||
|
||||
header: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'initial',
|
||||
cursor: 'initial'
|
||||
}
|
||||
},
|
||||
|
||||
list: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
listStyleType: 'none'
|
||||
},
|
||||
|
||||
checkboxContainer: {
|
||||
padding: '10px 16px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const AudioSettingsContent = ({
|
||||
currentMicDeviceId,
|
||||
currentOutputDeviceId,
|
||||
measureAudioLevels,
|
||||
microphoneDevices,
|
||||
noiseSuppressionEnabled,
|
||||
outputDevices,
|
||||
prejoinVisible,
|
||||
setAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
toggleSuppression
|
||||
}: IProps) => {
|
||||
const _componentWasUnmounted = useRef(false);
|
||||
const microphoneHeaderId = 'microphone_settings_header';
|
||||
const speakerHeaderId = 'speaker_settings_header';
|
||||
const { classes } = useStyles();
|
||||
const [ audioTracks, setAudioTracks ] = useState(microphoneDevices.map(({ deviceId, label }) => {
|
||||
return {
|
||||
deviceId,
|
||||
hasError: false,
|
||||
jitsiTrack: null,
|
||||
label
|
||||
};
|
||||
}));
|
||||
const microphoneDevicesRef = useRef(microphoneDevices);
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Click handler for the microphone entries.
|
||||
*
|
||||
* @param {string} deviceId - The deviceId for the clicked microphone.
|
||||
* @returns {void}
|
||||
*/
|
||||
const _onMicrophoneEntryClick = useCallback((deviceId: string) => {
|
||||
setAudioInputDevice(deviceId);
|
||||
}, [ setAudioInputDevice ]);
|
||||
|
||||
/**
|
||||
* Click handler for the speaker entries.
|
||||
*
|
||||
* @param {string} deviceId - The deviceId for the clicked speaker.
|
||||
* @returns {void}
|
||||
*/
|
||||
const _onSpeakerEntryClick = useCallback((deviceId: string) => {
|
||||
setAudioOutputDevice(deviceId);
|
||||
}, [ setAudioOutputDevice ]);
|
||||
|
||||
/**
|
||||
* Renders a single microphone entry.
|
||||
*
|
||||
* @param {Object} data - An object with the deviceId, jitsiTrack & label of the microphone.
|
||||
* @param {number} index - The index of the element, used for creating a key.
|
||||
* @param {length} length - The length of the microphone list.
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
const _renderMicrophoneEntry = (data: { deviceId: string; hasError: boolean; jitsiTrack: any; label: string; },
|
||||
index: number, length: number) => {
|
||||
const { deviceId, jitsiTrack, hasError } = data;
|
||||
const label = transformDefaultDeviceLabel(deviceId, data.label, t);
|
||||
const isSelected = deviceId === currentMicDeviceId;
|
||||
|
||||
return (
|
||||
<MicrophoneEntry
|
||||
deviceId = { deviceId }
|
||||
hasError = { hasError }
|
||||
index = { index }
|
||||
isSelected = { isSelected }
|
||||
jitsiTrack = { jitsiTrack }
|
||||
key = { `me-${index}` }
|
||||
length = { length }
|
||||
measureAudioLevels = { measureAudioLevels }
|
||||
onClick = { _onMicrophoneEntryClick }>
|
||||
{label}
|
||||
</MicrophoneEntry>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a single speaker entry.
|
||||
*
|
||||
* @param {Object} data - An object with the deviceId and label of the speaker.
|
||||
* @param {number} index - The index of the element, used for creating a key.
|
||||
* @param {length} length - The length of the speaker list.
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
const _renderSpeakerEntry = (data: { deviceId: string; label: string; }, index: number, length: number) => {
|
||||
const { deviceId } = data;
|
||||
const label = transformDefaultDeviceLabel(deviceId, data.label, t);
|
||||
const key = `se-${index}`;
|
||||
const isSelected = deviceId === currentOutputDeviceId;
|
||||
|
||||
return (
|
||||
<SpeakerEntry
|
||||
deviceId = { deviceId }
|
||||
index = { index }
|
||||
isSelected = { isSelected }
|
||||
key = { key }
|
||||
length = { length }
|
||||
onClick = { _onSpeakerEntryClick }>
|
||||
{label}
|
||||
</SpeakerEntry>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Disposes the audio tracks.
|
||||
*
|
||||
* @param {Object} tracks - The object holding the audio tracks.
|
||||
* @returns {void}
|
||||
*/
|
||||
const _disposeTracks = (tracks: Array<{ jitsiTrack: any; }>) => {
|
||||
tracks.forEach(({ jitsiTrack }) => {
|
||||
jitsiTrack?.dispose();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and updates the audio tracks.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const _setTracks = async () => {
|
||||
if (browser.isWebKitBased()) {
|
||||
|
||||
// It appears that at the time of this writing, creating audio tracks blocks the browser's main thread for
|
||||
// long time on safari. Wasn't able to confirm which part of track creation does the blocking exactly, but
|
||||
// not creating the tracks seems to help and makes the UI much more responsive.
|
||||
return;
|
||||
}
|
||||
|
||||
_disposeTracks(audioTracks);
|
||||
|
||||
const newAudioTracks = await createLocalAudioTracks(microphoneDevices, 5000);
|
||||
|
||||
if (_componentWasUnmounted.current) {
|
||||
_disposeTracks(newAudioTracks);
|
||||
} else {
|
||||
setAudioTracks(newAudioTracks);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
_setTracks();
|
||||
|
||||
return () => {
|
||||
_componentWasUnmounted.current = true;
|
||||
_disposeTracks(audioTracks);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!equals(microphoneDevices, microphoneDevicesRef.current)) {
|
||||
_setTracks();
|
||||
microphoneDevicesRef.current = microphoneDevices;
|
||||
}
|
||||
}, [ microphoneDevices ]);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
activateFocusTrap = { true }
|
||||
aria-labelledby = 'audio-settings-button'
|
||||
className = { classes.contextMenu }
|
||||
hidden = { false }
|
||||
id = 'audio-settings-dialog'
|
||||
role = 'menu'
|
||||
tabIndex = { -1 }>
|
||||
<ContextMenuItemGroup
|
||||
aria-labelledby = { microphoneHeaderId }
|
||||
role = 'group'>
|
||||
<ContextMenuItem
|
||||
className = { classes.header }
|
||||
icon = { IconMic }
|
||||
id = { microphoneHeaderId }
|
||||
text = { t('settings.microphones') } />
|
||||
<ul
|
||||
className = { classes.list }
|
||||
role = 'presentation'>
|
||||
{audioTracks.map((data, i) =>
|
||||
_renderMicrophoneEntry(data, i, audioTracks.length)
|
||||
)}
|
||||
</ul>
|
||||
</ContextMenuItemGroup>
|
||||
{outputDevices.length > 0 && (
|
||||
<ContextMenuItemGroup
|
||||
aria-labelledby = { speakerHeaderId }
|
||||
role = 'group'>
|
||||
<ContextMenuItem
|
||||
className = { classes.header }
|
||||
icon = { IconVolumeUp }
|
||||
id = { speakerHeaderId }
|
||||
text = { t('settings.speakers') } />
|
||||
<ul
|
||||
className = { classes.list }
|
||||
role = 'presentation'>
|
||||
{outputDevices.map((data: any, i: number) =>
|
||||
_renderSpeakerEntry(data, i, outputDevices.length)
|
||||
)}
|
||||
</ul>
|
||||
</ContextMenuItemGroup>)
|
||||
}
|
||||
{!prejoinVisible && (
|
||||
<ContextMenuItemGroup>
|
||||
<div
|
||||
className = { classes.checkboxContainer }
|
||||
// eslint-disable-next-line react/jsx-no-bind
|
||||
onClick = { e => e.stopPropagation() }>
|
||||
<Checkbox
|
||||
checked = { noiseSuppressionEnabled }
|
||||
label = { t('toolbar.noiseSuppression') }
|
||||
onChange = { toggleSuppression } />
|
||||
</div>
|
||||
</ContextMenuItemGroup>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: IReduxState) => {
|
||||
return {
|
||||
noiseSuppressionEnabled: isNoiseSuppressionEnabled(state),
|
||||
prejoinVisible: isPrejoinPageVisible(state)
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch: IStore['dispatch']) => {
|
||||
return {
|
||||
toggleSuppression() {
|
||||
dispatch(toggleNoiseSuppression());
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AudioSettingsContent);
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import { areAudioLevelsEnabled } from '../../../../base/config/functions.web';
|
||||
import {
|
||||
setAudioInputDeviceAndUpdateSettings,
|
||||
setAudioOutputDevice as setAudioOutputDeviceAction
|
||||
} from '../../../../base/devices/actions.web';
|
||||
import {
|
||||
getAudioInputDeviceData,
|
||||
getAudioOutputDeviceData
|
||||
} from '../../../../base/devices/functions.web';
|
||||
import Popover from '../../../../base/popover/components/Popover.web';
|
||||
import { SMALL_MOBILE_WIDTH } from '../../../../base/responsive-ui/constants';
|
||||
import {
|
||||
getCurrentMicDeviceId,
|
||||
getCurrentOutputDeviceId
|
||||
} from '../../../../base/settings/functions.web';
|
||||
import { toggleAudioSettings } from '../../../actions';
|
||||
import { getAudioSettingsVisibility } from '../../../functions.web';
|
||||
|
||||
import AudioSettingsContent from './AudioSettingsContent';
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Component's children (the audio button).
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* The deviceId of the microphone in use.
|
||||
*/
|
||||
currentMicDeviceId: string;
|
||||
|
||||
/**
|
||||
* The deviceId of the output device in use.
|
||||
*/
|
||||
currentOutputDeviceId?: string;
|
||||
|
||||
/**
|
||||
* Flag controlling the visibility of the popup.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Used to decide whether to measure audio levels for microphone devices.
|
||||
*/
|
||||
measureAudioLevels: boolean;
|
||||
|
||||
/**
|
||||
* A list with objects containing the labels and deviceIds
|
||||
* of all the input devices.
|
||||
*/
|
||||
microphoneDevices: Array<{ deviceId: string; label: string; }>;
|
||||
|
||||
/**
|
||||
* Callback executed when the popup closes.
|
||||
*/
|
||||
onClose: Function;
|
||||
|
||||
/**
|
||||
* A list of objects containing the labels and deviceIds
|
||||
* of all the output devices.
|
||||
*/
|
||||
outputDevices: Array<{ deviceId: string; label: string; }>;
|
||||
|
||||
/**
|
||||
* The popup placement enum value.
|
||||
*/
|
||||
popupPlacement: string;
|
||||
|
||||
/**
|
||||
* Used to set a new microphone as the current one.
|
||||
*/
|
||||
setAudioInputDevice: Function;
|
||||
|
||||
/**
|
||||
* Used to set a new output device as the current one.
|
||||
*/
|
||||
setAudioOutputDevice: Function;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
display: 'inline-block'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Popup with audio settings.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function AudioSettingsPopup({
|
||||
children,
|
||||
currentMicDeviceId,
|
||||
currentOutputDeviceId,
|
||||
isOpen,
|
||||
microphoneDevices,
|
||||
setAudioInputDevice,
|
||||
setAudioOutputDevice,
|
||||
onClose,
|
||||
outputDevices,
|
||||
popupPlacement,
|
||||
measureAudioLevels
|
||||
}: IProps) {
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { cx(classes.container, 'audio-preview') }>
|
||||
<Popover
|
||||
allowClick = { true }
|
||||
content = { <AudioSettingsContent
|
||||
currentMicDeviceId = { currentMicDeviceId }
|
||||
currentOutputDeviceId = { currentOutputDeviceId }
|
||||
measureAudioLevels = { measureAudioLevels }
|
||||
microphoneDevices = { microphoneDevices }
|
||||
outputDevices = { outputDevices }
|
||||
setAudioInputDevice = { setAudioInputDevice }
|
||||
setAudioOutputDevice = { setAudioOutputDevice } /> }
|
||||
headingId = 'audio-settings-button'
|
||||
onPopoverClose = { onClose }
|
||||
position = { popupPlacement }
|
||||
trigger = 'click'
|
||||
visible = { isOpen }>
|
||||
{children}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function that maps parts of Redux state tree into component props.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { videoSpaceWidth } = state['features/base/responsive-ui'];
|
||||
|
||||
return {
|
||||
popupPlacement: videoSpaceWidth <= Number(SMALL_MOBILE_WIDTH) ? 'auto' : 'top-end',
|
||||
currentMicDeviceId: getCurrentMicDeviceId(state),
|
||||
currentOutputDeviceId: getCurrentOutputDeviceId(state),
|
||||
isOpen: Boolean(getAudioSettingsVisibility(state)),
|
||||
microphoneDevices: getAudioInputDeviceData(state) ?? [],
|
||||
outputDevices: getAudioOutputDeviceData(state) ?? [],
|
||||
measureAudioLevels: areAudioLevelsEnabled(state)
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onClose: toggleAudioSettings,
|
||||
setAudioInputDevice: setAudioInputDeviceAndUpdateSettings,
|
||||
setAudioOutputDevice: setAudioOutputDeviceAction
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AudioSettingsPopup);
|
||||
45
react/features/settings/components/web/audio/Meter.tsx
Normal file
45
react/features/settings/components/web/audio/Meter.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconMeter } from '../../../../base/icons/svg';
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Own class name for the component.
|
||||
*/
|
||||
className: string;
|
||||
|
||||
/**
|
||||
* Flag indicating whether the component is greyed out/disabled.
|
||||
*/
|
||||
isDisabled?: boolean;
|
||||
|
||||
/**
|
||||
* The level of the meter.
|
||||
* Should be between 0 and 7 as per the used SVG.
|
||||
*/
|
||||
level: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React {@code Component} representing an audio level meter.
|
||||
*
|
||||
* @returns { ReactElement}
|
||||
*/
|
||||
export default function({ className, isDisabled, level }: IProps) {
|
||||
let ownClassName;
|
||||
|
||||
if (level > -1) {
|
||||
ownClassName = `metr metr-l-${level}`;
|
||||
} else {
|
||||
ownClassName = `metr ${isDisabled ? 'metr--disabled' : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon
|
||||
className = { `${ownClassName} ${className}` }
|
||||
size = { 12 }
|
||||
src = { IconMeter } />
|
||||
);
|
||||
}
|
||||
223
react/features/settings/components/web/audio/MicrophoneEntry.tsx
Normal file
223
react/features/settings/components/web/audio/MicrophoneEntry.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import Icon from '../../../../base/icons/components/Icon';
|
||||
import { IconCheck, IconExclamationSolid } from '../../../../base/icons/svg';
|
||||
import JitsiMeetJS from '../../../../base/lib-jitsi-meet/_';
|
||||
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
|
||||
import { TEXT_OVERFLOW_TYPES } from '../../../../base/ui/constants.any';
|
||||
|
||||
import Meter from './Meter';
|
||||
|
||||
const JitsiTrackEvents = JitsiMeetJS.events.track;
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The text for this component.
|
||||
*/
|
||||
children: string;
|
||||
|
||||
/**
|
||||
* The deviceId of the microphone.
|
||||
*/
|
||||
deviceId: string;
|
||||
|
||||
/**
|
||||
* Flag indicating if there is a problem with the device.
|
||||
*/
|
||||
hasError?: boolean;
|
||||
|
||||
/**
|
||||
* Index of the device item used to generate this entry.
|
||||
* Indexes are 0 based.
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* Flag indicating the selection state.
|
||||
*/
|
||||
isSelected: boolean;
|
||||
|
||||
/**
|
||||
* The audio track for the current entry.
|
||||
*/
|
||||
jitsiTrack: any;
|
||||
|
||||
/**
|
||||
* The id for the label, that contains the item text.
|
||||
*/
|
||||
labelId?: string;
|
||||
|
||||
/**
|
||||
* The length of the microphone list.
|
||||
*/
|
||||
length: number;
|
||||
|
||||
|
||||
/**
|
||||
* Used to decide whether to listen to audio level changes.
|
||||
*/
|
||||
measureAudioLevels: boolean;
|
||||
|
||||
/**
|
||||
* Click handler for component.
|
||||
*/
|
||||
onClick: Function;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative'
|
||||
},
|
||||
|
||||
entryText: {
|
||||
maxWidth: '238px',
|
||||
|
||||
'&.withMeter': {
|
||||
maxWidth: '178px'
|
||||
},
|
||||
|
||||
'&.left-margin': {
|
||||
marginLeft: '36px'
|
||||
}
|
||||
},
|
||||
|
||||
icon: {
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
width: '14px',
|
||||
marginLeft: '6px',
|
||||
|
||||
'& svg': {
|
||||
fill: theme.palette.iconError
|
||||
}
|
||||
},
|
||||
|
||||
meter: {
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
top: '14px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const MicrophoneEntry = ({
|
||||
deviceId,
|
||||
children,
|
||||
hasError,
|
||||
index,
|
||||
isSelected,
|
||||
length,
|
||||
jitsiTrack,
|
||||
measureAudioLevels,
|
||||
onClick: propsClick
|
||||
}: IProps) => {
|
||||
const [ level, setLevel ] = useState(-1);
|
||||
const activeTrackRef = useRef(jitsiTrack);
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
/**
|
||||
* Click handler for the entry.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const onClick = useCallback(() => {
|
||||
propsClick(deviceId);
|
||||
}, [ propsClick, deviceId ]);
|
||||
|
||||
/**
|
||||
* Key pressed handler for the entry.
|
||||
*
|
||||
* @param {Object} e - The event.
|
||||
* @private
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const onKeyPress = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
propsClick(deviceId);
|
||||
}
|
||||
}, [ propsClick, deviceId ]);
|
||||
|
||||
/**
|
||||
* Updates the level of the meter.
|
||||
*
|
||||
* @param {number} num - The audio level provided by the jitsiTrack.
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateLevel = useCallback((num: number) => {
|
||||
setLevel(Math.floor(num / 0.125));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Subscribes to audio level changes coming from the jitsiTrack.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const startListening = () => {
|
||||
jitsiTrack && measureAudioLevels && jitsiTrack.on(
|
||||
JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED,
|
||||
updateLevel);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unsubscribes from changes coming from the jitsiTrack.
|
||||
*
|
||||
* @param {Object} track - The jitsiTrack to unsubscribe from.
|
||||
* @returns {void}
|
||||
*/
|
||||
const stopListening = (track?: any) => {
|
||||
track?.off(JitsiTrackEvents.TRACK_AUDIO_LEVEL_CHANGED, updateLevel);
|
||||
setLevel(-1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
startListening();
|
||||
|
||||
return () => {
|
||||
stopListening(jitsiTrack);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
stopListening(activeTrackRef.current);
|
||||
startListening();
|
||||
activeTrackRef.current = jitsiTrack;
|
||||
}, [ jitsiTrack ]);
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-checked = { isSelected }
|
||||
aria-posinset = { index + 1 } // Add one to offset the 0 based index.
|
||||
aria-setsize = { length }
|
||||
className = { classes.container }
|
||||
onClick = { onClick }
|
||||
onKeyPress = { onKeyPress }
|
||||
role = 'menuitemradio'
|
||||
tabIndex = { 0 }>
|
||||
<ContextMenuItem
|
||||
icon = { isSelected ? IconCheck : undefined }
|
||||
overflowType = { TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER }
|
||||
selected = { isSelected }
|
||||
text = { children }
|
||||
textClassName = { cx(classes.entryText,
|
||||
measureAudioLevels && 'withMeter',
|
||||
!isSelected && 'left-margin') }>
|
||||
{hasError && <Icon
|
||||
className = { classes.icon }
|
||||
size = { 16 }
|
||||
src = { IconExclamationSolid } />}
|
||||
</ContextMenuItem>
|
||||
{Boolean(jitsiTrack) && measureAudioLevels && <Meter
|
||||
className = { classes.meter }
|
||||
isDisabled = { hasError }
|
||||
level = { level } />
|
||||
}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default MicrophoneEntry;
|
||||
176
react/features/settings/components/web/audio/SpeakerEntry.tsx
Normal file
176
react/features/settings/components/web/audio/SpeakerEntry.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IconCheck } from '../../../../base/icons/svg';
|
||||
import Button from '../../../../base/ui/components/web/Button';
|
||||
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
|
||||
import { BUTTON_TYPES, TEXT_OVERFLOW_TYPES } from '../../../../base/ui/constants.any';
|
||||
import logger from '../../../logger';
|
||||
|
||||
const TEST_SOUND_PATH = 'sounds/ring.mp3';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link SpeakerEntry}.
|
||||
*/
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* The text label for the entry.
|
||||
*/
|
||||
children: string;
|
||||
|
||||
/**
|
||||
* The deviceId of the speaker.
|
||||
*/
|
||||
deviceId: string;
|
||||
|
||||
/**
|
||||
* Index of the device item used to generate this entry.
|
||||
* Indexes are 0 based.
|
||||
*/
|
||||
index: number;
|
||||
|
||||
/**
|
||||
* Flag controlling the selection state of the entry.
|
||||
*/
|
||||
isSelected: boolean;
|
||||
|
||||
/**
|
||||
* Flag controlling the selection state of the entry.
|
||||
*/
|
||||
length: number;
|
||||
|
||||
/**
|
||||
* Click handler for the component.
|
||||
*/
|
||||
onClick: Function;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative',
|
||||
|
||||
[[ '&:hover', '&:focus', '&:focus-within' ] as any]: {
|
||||
'& .entryText': {
|
||||
maxWidth: '178px',
|
||||
marginRight: 0
|
||||
},
|
||||
|
||||
'& .testButton': {
|
||||
display: 'inline-block'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
entryText: {
|
||||
maxWidth: '238px',
|
||||
|
||||
'&.left-margin': {
|
||||
marginLeft: '36px'
|
||||
}
|
||||
},
|
||||
|
||||
testButton: {
|
||||
display: 'none',
|
||||
padding: '4px 10px',
|
||||
position: 'absolute',
|
||||
right: '16px',
|
||||
top: '6px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Implements a React {@link Component} which displays an audio
|
||||
* output settings entry. The user can click and play a test sound.
|
||||
*
|
||||
* @param {IProps} props - Component props.
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const SpeakerEntry = (props: IProps) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const { classes, cx } = useStyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Click handler for the entry.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onClick() {
|
||||
props.onClick(props.deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Key pressed handler for the entry.
|
||||
*
|
||||
* @param {Object} e - The event.
|
||||
* @private
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function _onKeyPress(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
props.onClick(props.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for Test button.
|
||||
* Sets the current audio output id and plays a sound.
|
||||
*
|
||||
* @param {Object} e - The synthetic event.
|
||||
* @returns {void}
|
||||
*/
|
||||
async function _onTestButtonClick(e: React.KeyboardEvent | React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
await audioRef.current?.setSinkId(props.deviceId);
|
||||
audioRef.current?.play();
|
||||
} catch (err) {
|
||||
logger.log('Could not set sink id', err);
|
||||
}
|
||||
}
|
||||
|
||||
const { children, isSelected, index, length } = props;
|
||||
const testLabel = t('deviceSelection.testAudio');
|
||||
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
return (
|
||||
<li
|
||||
aria-checked = { isSelected }
|
||||
aria-label = { children }
|
||||
aria-posinset = { index + 1 } // Add one to offset the 0 based index.
|
||||
aria-setsize = { length }
|
||||
className = { classes.container }
|
||||
onClick = { _onClick }
|
||||
onKeyPress = { _onKeyPress }
|
||||
role = 'menuitemradio'
|
||||
tabIndex = { 0 }>
|
||||
<ContextMenuItem
|
||||
icon = { isSelected ? IconCheck : undefined }
|
||||
overflowType = { TEXT_OVERFLOW_TYPES.SCROLL_ON_HOVER }
|
||||
selected = { isSelected }
|
||||
text = { children }
|
||||
textClassName = { cx(classes.entryText, 'entryText', !isSelected && 'left-margin') } />
|
||||
<audio
|
||||
preload = 'auto'
|
||||
ref = { audioRef }
|
||||
src = { TEST_SOUND_PATH } />
|
||||
<Button
|
||||
accessibilityLabel = { `${testLabel} ${children}` }
|
||||
className = { cx(classes.testButton, 'testButton') }
|
||||
label = { testLabel }
|
||||
onClick = { _onTestButtonClick }
|
||||
onKeyPress = { _onTestButtonClick }
|
||||
type = { BUTTON_TYPES.SECONDARY } />
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default SpeakerEntry;
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState, IStore } from '../../../../app/types';
|
||||
import { IconImage } from '../../../../base/icons/svg';
|
||||
import { Video } from '../../../../base/media/components/index';
|
||||
import { equals } from '../../../../base/redux/functions';
|
||||
import { updateSettings } from '../../../../base/settings/actions';
|
||||
import Checkbox from '../../../../base/ui/components/web/Checkbox';
|
||||
import ContextMenu from '../../../../base/ui/components/web/ContextMenu';
|
||||
import ContextMenuItem from '../../../../base/ui/components/web/ContextMenuItem';
|
||||
import ContextMenuItemGroup from '../../../../base/ui/components/web/ContextMenuItemGroup';
|
||||
import { checkBlurSupport, checkVirtualBackgroundEnabled } from '../../../../virtual-background/functions';
|
||||
import { openSettingsDialog } from '../../../actions';
|
||||
import { SETTINGS_TABS } from '../../../constants';
|
||||
import { createLocalVideoTracks } from '../../../functions.web';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link VideoSettingsContent}.
|
||||
*/
|
||||
export interface IProps {
|
||||
|
||||
/**
|
||||
* Callback to change the flip state.
|
||||
*/
|
||||
changeFlip: (flip: boolean) => void;
|
||||
|
||||
/**
|
||||
* The deviceId of the camera device currently being used.
|
||||
*/
|
||||
currentCameraDeviceId: string;
|
||||
|
||||
/**
|
||||
* Whether the local video flip is disabled.
|
||||
*/
|
||||
disableLocalVideoFlip: boolean | undefined;
|
||||
|
||||
/**
|
||||
* Whether or not the local video is flipped.
|
||||
*/
|
||||
localFlipX: boolean;
|
||||
|
||||
/**
|
||||
* Open virtual background dialog.
|
||||
*/
|
||||
selectBackground: () => void;
|
||||
|
||||
/**
|
||||
* Callback invoked to change current camera.
|
||||
*/
|
||||
setVideoInputDevice: Function;
|
||||
|
||||
/**
|
||||
* Callback invoked to toggle the settings popup visibility.
|
||||
*/
|
||||
toggleVideoSettings: Function;
|
||||
|
||||
/**
|
||||
* All the camera device ids currently connected.
|
||||
*/
|
||||
videoDeviceIds: string[];
|
||||
|
||||
/**
|
||||
* Whether or not the virtual background is visible.
|
||||
*/
|
||||
visibleVirtualBackground: boolean;
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(theme => {
|
||||
return {
|
||||
container: {
|
||||
maxHeight: 'calc(100dvh - 100px)',
|
||||
overflow: 'auto',
|
||||
margin: 0,
|
||||
marginBottom: theme.spacing(1),
|
||||
position: 'relative',
|
||||
right: 'auto'
|
||||
},
|
||||
|
||||
previewEntry: {
|
||||
cursor: 'pointer',
|
||||
height: '138px',
|
||||
width: '244px',
|
||||
position: 'relative',
|
||||
margin: '0 7px',
|
||||
marginBottom: theme.spacing(1),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
|
||||
'&:last-child': {
|
||||
marginBottom: 0
|
||||
}
|
||||
},
|
||||
|
||||
selectedEntry: {
|
||||
border: `2px solid ${theme.palette.action01Hover}`
|
||||
},
|
||||
|
||||
previewVideo: {
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
objectFit: 'cover'
|
||||
},
|
||||
|
||||
error: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'absolute'
|
||||
},
|
||||
|
||||
labelContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxWidth: '100%',
|
||||
zIndex: 2,
|
||||
padding: theme.spacing(2)
|
||||
},
|
||||
|
||||
label: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
borderRadius: '4px',
|
||||
padding: `${theme.spacing(1)} ${theme.spacing(2)}`,
|
||||
color: theme.palette.text01,
|
||||
...theme.typography.labelBold,
|
||||
width: 'fit-content',
|
||||
maxwidth: `calc(100% - ${theme.spacing(2)} - ${theme.spacing(2)})`,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
},
|
||||
|
||||
checkboxContainer: {
|
||||
padding: '10px 14px'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const VideoSettingsContent = ({
|
||||
changeFlip,
|
||||
currentCameraDeviceId,
|
||||
disableLocalVideoFlip,
|
||||
localFlipX,
|
||||
selectBackground,
|
||||
setVideoInputDevice,
|
||||
toggleVideoSettings,
|
||||
videoDeviceIds,
|
||||
visibleVirtualBackground
|
||||
}: IProps) => {
|
||||
const _componentWasUnmounted = useRef(false);
|
||||
const [ trackData, setTrackData ] = useState(new Array(videoDeviceIds.length).fill({
|
||||
jitsiTrack: null
|
||||
}));
|
||||
const { t } = useTranslation();
|
||||
const videoDevicesRef = useRef(videoDeviceIds);
|
||||
const trackDataRef = useRef(trackData);
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
/**
|
||||
* Toggles local video flip state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const _onToggleFlip = useCallback(() => {
|
||||
changeFlip(!localFlipX);
|
||||
}, [ localFlipX, changeFlip ]);
|
||||
|
||||
/**
|
||||
* Destroys all the tracks from trackData object.
|
||||
*
|
||||
* @param {Object[]} tracks - An array of tracks that are to be disposed.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const _disposeTracks = (tracks: { jitsiTrack: any; }[]) => {
|
||||
tracks.forEach(({ jitsiTrack }) => {
|
||||
jitsiTrack?.dispose();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates and updates the track data.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
const _setTracks = async () => {
|
||||
_disposeTracks(trackData);
|
||||
|
||||
const newTrackData = await createLocalVideoTracks(videoDeviceIds, 5000);
|
||||
|
||||
// In case the component gets unmounted before the tracks are created
|
||||
// avoid a leak by not setting the state
|
||||
if (_componentWasUnmounted.current) {
|
||||
_disposeTracks(newTrackData);
|
||||
} else {
|
||||
setTrackData(newTrackData);
|
||||
trackDataRef.current = newTrackData;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the click handler used when selecting the video preview.
|
||||
*
|
||||
* @param {string} deviceId - The id of the camera device.
|
||||
* @returns {Function}
|
||||
*/
|
||||
const _onEntryClick = (deviceId: string) => () => {
|
||||
setVideoInputDevice(deviceId);
|
||||
toggleVideoSettings();
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a preview entry.
|
||||
*
|
||||
* @param {Object} data - The track data.
|
||||
* @param {number} index - The index of the entry.
|
||||
* @returns {React$Node}
|
||||
*/
|
||||
// eslint-disable-next-line react/no-multi-comp
|
||||
const _renderPreviewEntry = (data: { deviceId: string; error?: string; jitsiTrack: any | null; },
|
||||
index: number) => {
|
||||
const { error, jitsiTrack, deviceId } = data;
|
||||
const isSelected = deviceId === currentCameraDeviceId;
|
||||
const key = `vp-${index}`;
|
||||
const tabIndex = '0';
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className = { classes.previewEntry }
|
||||
key = { key }
|
||||
tabIndex = { -1 } >
|
||||
<div className = { classes.error }>{t(error)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const previewProps: any = {
|
||||
className: classes.previewEntry,
|
||||
key,
|
||||
tabIndex
|
||||
};
|
||||
const label = jitsiTrack?.getTrackLabel();
|
||||
|
||||
if (isSelected) {
|
||||
previewProps['aria-checked'] = true;
|
||||
previewProps.className = cx(classes.previewEntry, classes.selectedEntry);
|
||||
} else {
|
||||
previewProps['aria-checked'] = false;
|
||||
previewProps.onClick = _onEntryClick(deviceId);
|
||||
previewProps.onKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
previewProps.onClick();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{ ...previewProps }
|
||||
role = 'menuitemradio'>
|
||||
<div className = { classes.labelContainer }>
|
||||
{label && <div className = { classes.label }>
|
||||
<span>{label}</span>
|
||||
</div>}
|
||||
</div>
|
||||
<Video
|
||||
className = { cx(classes.previewVideo, 'flipVideoX') }
|
||||
id = { `video_settings_preview-${index}` }
|
||||
playsinline = { true }
|
||||
videoTrack = {{ jitsiTrack }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
_setTracks();
|
||||
|
||||
return () => {
|
||||
_componentWasUnmounted.current = true;
|
||||
_disposeTracks(trackDataRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!equals(videoDeviceIds, videoDevicesRef.current)) {
|
||||
_setTracks();
|
||||
videoDevicesRef.current = videoDeviceIds;
|
||||
}
|
||||
}, [ videoDeviceIds ]);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
activateFocusTrap = { true }
|
||||
className = { classes.container }
|
||||
hidden = { false }
|
||||
id = 'video-settings-dialog'>
|
||||
<ContextMenuItemGroup role = 'group'>
|
||||
{trackData.map((data, i) => _renderPreviewEntry(data, i))}
|
||||
</ContextMenuItemGroup>
|
||||
<ContextMenuItemGroup role = 'group'>
|
||||
{ visibleVirtualBackground && <ContextMenuItem
|
||||
accessibilityLabel = { t('virtualBackground.title') }
|
||||
icon = { IconImage }
|
||||
onClick = { selectBackground }
|
||||
role = 'menuitem'
|
||||
text = { t('virtualBackground.title') } /> }
|
||||
{!disableLocalVideoFlip && (
|
||||
<div
|
||||
className = { classes.checkboxContainer }
|
||||
onClick = { stopPropagation }
|
||||
role = 'menuitem'>
|
||||
<Checkbox
|
||||
checked = { localFlipX }
|
||||
label = { t('videothumbnail.mirrorVideo') }
|
||||
onChange = { _onToggleFlip } />
|
||||
</div>
|
||||
)}
|
||||
</ContextMenuItemGroup>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: IReduxState) => {
|
||||
const { disableLocalVideoFlip } = state['features/base/config'];
|
||||
const { localFlipX } = state['features/base/settings'];
|
||||
|
||||
return {
|
||||
disableLocalVideoFlip,
|
||||
localFlipX: Boolean(localFlipX),
|
||||
visibleVirtualBackground: checkBlurSupport()
|
||||
&& checkVirtualBackgroundEnabled(state)
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch: IStore['dispatch']) => {
|
||||
return {
|
||||
selectBackground: () => dispatch(openSettingsDialog(SETTINGS_TABS.VIRTUAL_BACKGROUND)),
|
||||
changeFlip: (flip: boolean) => {
|
||||
dispatch(updateSettings({
|
||||
localFlipX: flip
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(VideoSettingsContent);
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { IReduxState } from '../../../../app/types';
|
||||
import {
|
||||
setVideoInputDeviceAndUpdateSettings
|
||||
} from '../../../../base/devices/actions.web';
|
||||
import {
|
||||
getVideoDeviceIds
|
||||
} from '../../../../base/devices/functions.web';
|
||||
import Popover from '../../../../base/popover/components/Popover.web';
|
||||
import { SMALL_MOBILE_WIDTH } from '../../../../base/responsive-ui/constants';
|
||||
import { getCurrentCameraDeviceId } from '../../../../base/settings/functions.web';
|
||||
import { toggleVideoSettings } from '../../../actions';
|
||||
import { getVideoSettingsVisibility } from '../../../functions.web';
|
||||
|
||||
import VideoSettingsContent from './VideoSettingsContent';
|
||||
|
||||
|
||||
interface IProps {
|
||||
|
||||
/**
|
||||
* Component children (the Video button).
|
||||
*/
|
||||
children: ReactNode;
|
||||
|
||||
/**
|
||||
* The deviceId of the camera device currently being used.
|
||||
*/
|
||||
currentCameraDeviceId: string;
|
||||
|
||||
/**
|
||||
* Flag controlling the visibility of the popup.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
|
||||
/**
|
||||
* Callback executed when the popup closes.
|
||||
*/
|
||||
onClose: Function;
|
||||
|
||||
/**
|
||||
* The popup placement enum value.
|
||||
*/
|
||||
popupPlacement: string;
|
||||
|
||||
/**
|
||||
* Callback invoked to change current camera.
|
||||
*/
|
||||
setVideoInputDevice: Function;
|
||||
|
||||
/**
|
||||
* All the camera device ids currently connected.
|
||||
*/
|
||||
videoDeviceIds: string[];
|
||||
}
|
||||
|
||||
const useStyles = makeStyles()(() => {
|
||||
return {
|
||||
container: {
|
||||
background: 'none',
|
||||
display: 'inline-block'
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Popup with a preview of all the video devices.
|
||||
*
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
function VideoSettingsPopup({
|
||||
currentCameraDeviceId,
|
||||
children,
|
||||
isOpen,
|
||||
onClose,
|
||||
popupPlacement,
|
||||
setVideoInputDevice,
|
||||
videoDeviceIds
|
||||
}: IProps) {
|
||||
const { classes, cx } = useStyles();
|
||||
|
||||
return (
|
||||
<div className = { cx('video-preview', classes.container) }>
|
||||
<Popover
|
||||
allowClick = { true }
|
||||
content = { <VideoSettingsContent
|
||||
currentCameraDeviceId = { currentCameraDeviceId }
|
||||
setVideoInputDevice = { setVideoInputDevice }
|
||||
toggleVideoSettings = { onClose }
|
||||
videoDeviceIds = { videoDeviceIds } /> }
|
||||
headingId = 'video-settings-button'
|
||||
onPopoverClose = { onClose }
|
||||
position = { popupPlacement }
|
||||
role = 'menu'
|
||||
trigger = 'click'
|
||||
visible = { isOpen }>
|
||||
{ children }
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the associated {@code VideoSettingsPopup}'s
|
||||
* props.
|
||||
*
|
||||
* @param {Object} state - Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function mapStateToProps(state: IReduxState) {
|
||||
const { videoSpaceWidth } = state['features/base/responsive-ui'];
|
||||
|
||||
return {
|
||||
currentCameraDeviceId: getCurrentCameraDeviceId(state),
|
||||
isOpen: Boolean(getVideoSettingsVisibility(state)),
|
||||
popupPlacement: videoSpaceWidth <= Number(SMALL_MOBILE_WIDTH) ? 'auto' : 'top-end',
|
||||
videoDeviceIds: getVideoDeviceIds(state) ?? []
|
||||
};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onClose: toggleVideoSettings,
|
||||
setVideoInputDevice: setVideoInputDeviceAndUpdateSettings
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(VideoSettingsPopup);
|
||||
Reference in New Issue
Block a user