init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, NativeModules, Platform, Text } from 'react-native';
import { Divider } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { updateSettings } from '../../../base/settings/actions';
import Switch from '../../../base/ui/components/native/Switch';
import FormRow from './FormRow';
import FormSection from './FormSection';
import styles from './styles';
const { AppInfo } = NativeModules;
const AdvancedSection = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const {
disableCrashReporting,
disableCallIntegration,
disableP2P
} = useSelector((state: IReduxState) => state['features/base/settings']);
const onSwitchToggled = useCallback((name: string) => (enabled?: boolean) => {
if (name === 'disableCrashReporting' && enabled === true) {
Alert.alert(
t('settingsView.alertTitle'),
t('settingsView.disableCrashReportingWarning'),
[
{
onPress: () => dispatch(updateSettings({ disableCrashReporting: true })),
text: t('settingsView.alertOk')
},
{
text: t('settingsView.alertCancel')
}
]
);
} else {
dispatch(updateSettings({ [name]: enabled }));
}
}, [ dispatch, updateSettings ]);
const switches = useMemo(() => {
const partialSwitches = [
{
label: 'settingsView.disableCallIntegration',
state: disableCallIntegration,
name: 'disableCallIntegration'
},
{
label: 'settingsView.disableP2P',
state: disableP2P,
name: 'disableP2P'
},
{
label: 'settingsView.disableCrashReporting',
state: disableCrashReporting,
name: 'disableCrashReporting'
}
];
if (Platform.OS !== 'android') {
partialSwitches.shift();
}
if (!AppInfo.GOOGLE_SERVICES_ENABLED) {
partialSwitches.pop();
}
return partialSwitches;
}, [ disableCallIntegration, disableP2P, disableCrashReporting ]);
return (
<>
<FormSection
label = 'settingsView.advanced'>
{
switches.map(({ label, state, name }) => (
<FormRow
key = { label }
label = { label }>
<Switch
checked = { Boolean(state) }
onChange = { onSwitchToggled(name) } />
</FormRow>
))
}
</FormSection>
{/* @ts-ignore */}
<Divider style = { styles.fieldSeparator } />
<FormSection
label = 'settingsView.buildInfoSection'>
<FormRow
label = 'settingsView.version'>
<Text style = { styles.text }>
{`${AppInfo.version} build ${AppInfo.buildNumber}`}
</Text>
</FormRow>
<FormRow
label = 'settingsView.sdkVersion'>
<Text style = { styles.text }>
{AppInfo.sdkVersion}
</Text>
</FormRow>
</FormSection>
</>
);
};
export default AdvancedSection;

View File

@@ -0,0 +1,101 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { getDefaultURL } from '../../../app/functions.native';
import { IReduxState } from '../../../app/types';
import { updateSettings } from '../../../base/settings/actions';
import Input from '../../../base/ui/components/native/Input';
import Switch from '../../../base/ui/components/native/Switch';
import { isServerURLChangeEnabled, normalizeUserInputURL } from '../../functions.native';
import FormRow from './FormRow';
import FormSection from './FormSection';
import styles from './styles';
const ConferenceSection = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const {
serverURL,
startCarMode,
startWithAudioMuted,
startWithVideoMuted
} = useSelector((state: IReduxState) => state['features/base/settings']);
const defaultServerURL = useSelector((state: IReduxState) => getDefaultURL(state));
const [ newServerURL, setNewServerURL ] = useState(serverURL ?? '');
const serverURLChangeEnabled = useSelector((state: IReduxState) => isServerURLChangeEnabled(state));
const switches = useMemo(() => [
{
label: 'settingsView.startCarModeInLowBandwidthMode',
state: startCarMode,
name: 'startCarMode'
},
{
label: 'settingsView.startWithAudioMuted',
state: startWithAudioMuted,
name: 'startWithAudioMuted'
},
{
label: 'settingsView.startWithVideoMuted',
state: startWithVideoMuted,
name: 'startWithVideoMuted'
}
], [ startCarMode, startWithAudioMuted, startWithVideoMuted ]);
const onChangeServerURL = useCallback(value => {
setNewServerURL(value);
dispatch(updateSettings({
serverURL: value
}));
}, [ dispatch, newServerURL ]);
const processServerURL = useCallback(() => {
const normalizedURL = normalizeUserInputURL(newServerURL);
onChangeServerURL(normalizedURL);
}, [ newServerURL ]);
useEffect(() => () => processServerURL(), []);
const onSwitchToggled = useCallback((name: string) => (enabled?: boolean) => {
// @ts-ignore
dispatch(updateSettings({ [name]: enabled }));
}, [ dispatch ]);
return (
<FormSection
label = 'settingsView.conferenceSection'>
<Input
autoCapitalize = 'none'
customStyles = {{ container: styles.customContainer }}
editable = { serverURLChangeEnabled }
keyboardType = { 'url' }
label = { t('settingsView.serverURL') }
onBlur = { processServerURL }
onChange = { onChangeServerURL }
placeholder = { defaultServerURL }
textContentType = { 'URL' } // iOS only
value = { newServerURL } />
{
switches.map(({ label, state, name }) => (
<FormRow
key = { label }
label = { label }>
<Switch
checked = { Boolean(state) }
onChange = { onSwitchToggled(name) } />
</FormRow>
))
}
</FormSection>
);
};
export default ConferenceSection;

View File

@@ -0,0 +1,146 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, View, ViewStyle } from 'react-native';
import { translate } from '../../../base/i18n/functions';
import styles, { ANDROID_UNDERLINE_COLOR, PLACEHOLDER_COLOR } from './styles';
/**
* The type of the React {@code Component} props of {@link FormRow}.
*/
interface IProps extends WithTranslation {
/**
* Component's children.
*/
children: React.ReactElement;
/**
* Prop to decide if a row separator is to be rendered.
*/
fieldSeparator?: boolean;
/**
* The i18n key of the text label of the form field.
*/
label: string;
/**
* One of 'row' (default) or 'column'.
*/
layout?: string;
}
/**
* Implements a React {@code Component} which renders a standardized row on a
* form. The component should have exactly one child component.
*/
class FormRow extends Component<IProps> {
/**
* Initializes a new {@code FormRow} instance.
*
* @param {Object} props - Component properties.
*/
constructor(props: IProps) {
super(props);
React.Children.only(this.props.children);
this._getDefaultFieldProps = this._getDefaultFieldProps.bind(this);
this._getRowStyle = this._getRowStyle.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @override
* @returns {ReactElement}
*/
override render() {
const { t } = this.props;
// Some field types need additional props to look good and standardized
// on a form.
const newChild
= React.cloneElement(
this.props.children,
this._getDefaultFieldProps(this.props.children));
return (
<View
style = { this._getRowStyle() } >
<View style = { styles.fieldLabelContainer as ViewStyle } >
<Text
style = { [
styles.text,
styles.fieldLabelText
] } >
{ t(this.props.label) }
</Text>
</View>
<View style = { styles.fieldValueContainer as ViewStyle } >
{ newChild }
</View>
</View>
);
}
/**
* Assembles the default props to the field child component of this form
* row.
*
* Currently tested/supported field types:
* - TextInput
* - Switch (needs no addition props ATM).
*
* @param {Object} field - The field (child) component.
* @private
* @returns {Object}
*/
_getDefaultFieldProps(field?: React.ReactElement) {
if (field?.type) { // @ts-ignore
switch (field.type.displayName) {
case 'TextInput':
return {
placeholderTextColor: PLACEHOLDER_COLOR,
style: [
styles.textInputField,
this.props.layout === 'column' ? styles.textInputFieldColumn : undefined
],
underlineColorAndroid: ANDROID_UNDERLINE_COLOR
};
}
}
return {};
}
/**
* Assembles the row style array based on the row's props.
*
* @private
* @returns {Array<Object>}
*/
_getRowStyle() {
const { fieldSeparator, layout } = this.props;
const rowStyle: ViewStyle[] = [
styles.fieldContainer as ViewStyle
];
if (fieldSeparator) {
rowStyle.push(styles.fieldSeparator);
}
if (layout === 'column') {
rowStyle.push(
styles.fieldContainerColumn as ViewStyle
);
}
return rowStyle;
}
}
export default translate(FormRow);

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, View } from 'react-native';
import { translate } from '../../../base/i18n/functions';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link FormSection}.
*/
interface IProps extends WithTranslation {
/**
* The children to be displayed within this Link.
*/
children: React.ReactNode;
/**
* The i18n key of the text label of the section.
*/
label?: string;
}
/**
* Section accordion on settings form.
*
* @returns {React$Element<any>}
*/
function FormSection({ children, label, t }: IProps) {
return (
<View>
{label && <Text style = { styles.formSectionTitleText }>
{ t(label) }
</Text>}
{ children }
</View>
);
}
export default translate(FormSection);

View File

@@ -0,0 +1,61 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, TouchableHighlight, View, ViewStyle } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import i18next, { DEFAULT_LANGUAGE } from '../../../base/i18n/i18next';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowRight } from '../../../base/icons/svg';
import { updateSettings } from '../../../base/settings/actions';
import Switch from '../../../base/ui/components/native/Switch';
import { navigate } from '../../../mobile/navigation/components/settings/SettingsNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import FormRow from './FormRow';
import FormSection from './FormSection';
import styles from './styles';
const GeneralSection = () => {
const { t } = useTranslation();
const dispatch = useDispatch();
const {
disableSelfView,
} = useSelector((state: IReduxState) => state['features/base/settings']);
const { language = DEFAULT_LANGUAGE } = i18next;
const onSelfViewToggled = useCallback((enabled?: boolean) =>
dispatch(updateSettings({ disableSelfView: enabled }))
, [ dispatch, updateSettings ]);
const navigateToLanguageSelect = useCallback(() => {
navigate(screen.settings.language);
}, [ navigate, screen ]);
return (
<FormSection>
<FormRow label = 'videothumbnail.hideSelfView'>
<Switch
checked = { Boolean(disableSelfView) }
onChange = { onSelfViewToggled } />
</FormRow>
<FormRow label = 'settings.language'>
<View style = { styles.languageButtonContainer as ViewStyle }>
<TouchableHighlight onPress = { navigateToLanguageSelect }>
<View style = { styles.languageButton as ViewStyle }>
<Text
style = { styles.languageText }>{t(`languages:${language}`)}</Text>
<Icon
size = { 24 }
src = { IconArrowRight } />
</View>
</TouchableHighlight>
</View>
</FormRow>
</FormSection>
);
};
export default GeneralSection;

View File

@@ -0,0 +1,79 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, Text, TouchableHighlight, View, ViewStyle } from 'react-native';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import i18next, { DEFAULT_LANGUAGE, LANGUAGES } from '../../../base/i18n/i18next';
import { IconArrowLeft } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import BaseThemeNative from '../../../base/ui/components/BaseTheme.native';
import HeaderNavigationButton from '../../../mobile/navigation/components/HeaderNavigationButton';
import { goBack, navigate } from '../../../mobile/navigation/components/settings/SettingsNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import styles from './styles';
const LanguageSelectView = ({ isInWelcomePage }: { isInWelcomePage?: boolean; }) => {
const { t } = useTranslation();
const navigation = useNavigation();
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
const { language: currentLanguage = DEFAULT_LANGUAGE } = i18next;
const setLanguage = useCallback(language => () => {
i18next.changeLanguage(language);
conference?.setTranscriptionLanguage(language);
navigate(screen.settings.main);
}, [ conference, i18next ]);
const headerLeft = () => (
<HeaderNavigationButton
color = { BaseThemeNative.palette.link01 }
onPress = { goBack }
src = { IconArrowLeft }
style = { styles.backBtn }
twoActions = { true } />
);
useLayoutEffect(() => {
navigation.setOptions({
headerLeft
});
}, [ navigation ]);
return (
<JitsiScreen
disableForcedKeyboardDismiss = { true }
// @ts-ignore
safeAreaInsets = { [ !isInWelcomePage && 'bottom', 'left', 'right' ].filter(Boolean) }
style = { styles.settingsViewContainer }>
<ScrollView
bounces = { isInWelcomePage }
contentContainerStyle = { styles.profileView as ViewStyle }>
{
LANGUAGES.map(language => (
<TouchableHighlight
disabled = { currentLanguage === language }
key = { language }
onPress = { setLanguage(language) }>
<View
style = { styles.languageOption as ViewStyle }>
<Text
style = { [
styles.text,
styles.fieldLabelText,
currentLanguage === language && styles.selectedLanguage ] }>
{ t(`languages:${language}`) }
</Text>
</View>
</TouchableHighlight>
))
}
</ScrollView>
</JitsiScreen>
);
};
export default LanguageSelectView;

View File

@@ -0,0 +1,57 @@
import React, { useCallback, useMemo } from 'react';
import { Linking, View, ViewStyle } from 'react-native';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getLegalUrls } from '../../../base/config/functions.native';
import Button from '../../../base/ui/components/native/Button';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import FormSection from './FormSection';
import styles from './styles';
const LinksSection = () => {
const {
privacy,
helpCentre,
terms
} = useSelector((state: IReduxState) => getLegalUrls(state));
const links = useMemo(() => [
{
label: 'settingsView.help',
link: helpCentre
},
{
label: 'settingsView.terms',
link: terms
},
{
label: 'settingsView.privacy',
link: privacy
}
], [ privacy, helpCentre, terms ]);
const onLinkPress = useCallback(link => () => Linking.openURL(link), [ Linking ]);
return (
<FormSection>
<View style = { styles.linksSection as ViewStyle }>
{
links.map(({ label, link }) => (
<Button
accessibilityLabel = { label }
key = { label }
labelKey = { label }
onClick = { onLinkPress(link) }
style = { styles.linksButton }
type = { BUTTON_TYPES.TERTIARY } />
))
}
</View>
</FormSection>
);
};
export default LinksSection;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { WithTranslation } from 'react-i18next';
import ConfirmDialog
from '../../../base/dialog/components/native/ConfirmDialog';
interface ILogoutDialogProps extends WithTranslation {
onLogout: Function;
}
const LogoutDialog: React.FC<ILogoutDialogProps> = ({ onLogout }: ILogoutDialogProps) => (
<ConfirmDialog
cancelLabel = 'dialog.Cancel'
confirmLabel = 'dialog.Yes'
descriptionKey = 'dialog.logoutQuestion'
onSubmit = { onLogout } />
);
export default LogoutDialog;

View File

@@ -0,0 +1,145 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import {
setFollowMe,
setFollowMeRecorder,
setStartMutedPolicy,
setStartReactionsMuted
} from '../../../base/conference/actions';
import { updateSettings } from '../../../base/settings/actions';
import Switch from '../../../base/ui/components/native/Switch';
import { getModeratorTabProps } from '../../functions.native';
import FormRow from './FormRow';
import FormSection from './FormSection';
const ModeratorSection = () => {
const dispatch = useDispatch();
const {
audioModerationEnabled,
chatWithPermissionsEnabled,
followMeActive,
followMeEnabled,
followMeRecorderActive,
followMeRecorderEnabled,
startAudioMuted,
startVideoMuted,
startReactionsMuted,
videoModerationEnabled
} = useSelector((state: IReduxState) => getModeratorTabProps(state));
const { disableReactionsModeration } = useSelector((state: IReduxState) => state['features/base/config']);
const onStartAudioMutedToggled = useCallback((enabled?: boolean) => {
dispatch(setStartMutedPolicy(
Boolean(enabled), Boolean(startVideoMuted)));
}, [ startVideoMuted, dispatch, setStartMutedPolicy ]);
const onStartVideoMutedToggled = useCallback((enabled?: boolean) => {
dispatch(setStartMutedPolicy(
Boolean(startAudioMuted), Boolean(enabled)));
}, [ startAudioMuted, dispatch, setStartMutedPolicy ]);
const onFollowMeToggled = useCallback((enabled?: boolean) => {
dispatch(setFollowMe(Boolean(enabled)));
}, [ dispatch, setFollowMe ]);
const onFollowMeRecorderToggled = useCallback((enabled?: boolean) => {
dispatch(setFollowMeRecorder(Boolean(enabled)));
}, [ dispatch, setFollowMeRecorder ]);
const onStartReactionsMutedToggled = useCallback((enabled?: boolean) => {
dispatch(setStartReactionsMuted(Boolean(enabled), true));
dispatch(updateSettings({ soundsReactions: enabled }));
}, [ dispatch, updateSettings, setStartReactionsMuted ]);
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
const onChatWithPermissionsToggled = useCallback((enabled?: boolean) => {
const currentPermissions = conference?.getMetadataHandler().getMetadata().permissions || {};
conference?.getMetadataHandler().setMetadata('permissions', {
...currentPermissions,
groupChatRestricted: enabled
});
}, [ dispatch, conference ]);
const followMeRecorderChecked = followMeRecorderEnabled && !followMeRecorderActive;
const moderationSettings = useMemo(() => {
const moderation = [
{
disabled: audioModerationEnabled,
label: 'settings.startAudioMuted',
state: startAudioMuted,
onChange: onStartAudioMutedToggled
},
{
disabled: videoModerationEnabled,
label: 'settings.startVideoMuted',
state: startVideoMuted,
onChange: onStartVideoMutedToggled
},
{
disabled: followMeActive || followMeRecorderActive,
label: 'settings.followMe',
state: followMeEnabled && !followMeActive && !followMeRecorderChecked,
onChange: onFollowMeToggled
},
{
disabled: followMeRecorderActive || followMeActive,
label: 'settings.followMeRecorder',
state: followMeRecorderChecked,
onChange: onFollowMeRecorderToggled
},
{
disabled: false,
label: 'settings.startReactionsMuted',
state: startReactionsMuted,
onChange: onStartReactionsMutedToggled
},
{
label: 'settings.chatWithPermissions',
state: chatWithPermissionsEnabled,
onChange: onChatWithPermissionsToggled
},
];
if (disableReactionsModeration) {
moderation.pop();
}
return moderation;
}, [ startAudioMuted,
startVideoMuted,
followMeEnabled,
followMeRecorderEnabled,
disableReactionsModeration,
onStartAudioMutedToggled,
onStartVideoMutedToggled,
onFollowMeToggled,
onFollowMeRecorderToggled,
onStartReactionsMutedToggled,
startReactionsMuted ]);
return (
<FormSection
label = 'settings.playSounds'>
{
moderationSettings.map(({ label, state, onChange, disabled }) => (
<FormRow
key = { label }
label = { label }>
<Switch
checked = { Boolean(state) }
disabled = { disabled }
onChange = { onChange } />
</FormRow>
))
}
</FormSection>
);
};
export default ModeratorSection;

View File

@@ -0,0 +1,139 @@
import React, { useCallback, useMemo } from 'react';
import { Divider } from 'react-native-paper';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { updateSettings } from '../../../base/settings/actions';
import Switch from '../../../base/ui/components/native/Switch';
import { getNotificationsTabProps } from '../../functions.any';
import FormRow from './FormRow';
import FormSection from './FormSection';
import styles from './styles';
const NotificationsSection = () => {
const dispatch = useDispatch();
const {
soundsIncomingMessage,
soundsParticipantJoined,
soundsParticipantKnocking,
soundsParticipantLeft,
soundsReactions,
soundsTalkWhileMuted,
enableReactions,
enabledNotifications,
disabledSounds,
moderatorMutedSoundsReactions
} = useSelector((state: IReduxState) => getNotificationsTabProps(state));
const sounds = useMemo(() => {
const partialSounds = [
{
label: 'settings.reactions',
state: soundsReactions,
name: 'soundsReactions',
disabled: Boolean(moderatorMutedSoundsReactions
|| disabledSounds.includes('REACTION_SOUND'))
},
{
label: 'settings.incomingMessage',
state: soundsIncomingMessage,
name: 'soundsIncomingMessage'
},
{
label: 'settings.participantJoined',
state: soundsParticipantJoined,
name: 'soundsParticipantJoined'
},
{
label: 'settings.participantLeft',
state: soundsParticipantLeft,
name: 'soundsParticipantLeft'
},
{
label: 'settings.talkWhileMuted',
state: soundsTalkWhileMuted,
name: 'soundsTalkWhileMuted'
},
{
label: 'settings.participantKnocking',
state: soundsParticipantKnocking,
name: 'soundsParticipantKnocking'
}
];
if (!enableReactions) {
partialSounds.shift();
}
return partialSounds;
}, [ soundsReactions,
soundsIncomingMessage,
soundsParticipantJoined,
soundsParticipantLeft,
soundsTalkWhileMuted,
soundsParticipantKnocking,
enableReactions ]);
const onSoundToggled = useCallback((name: string) => (enabled?: boolean) => {
dispatch(updateSettings({ [name]: enabled }));
}, [ dispatch, updateSettings ]);
const onNotificationToggled = useCallback((name: string) => (enabled?: boolean) => {
dispatch(updateSettings({
userSelectedNotifications: {
...enabledNotifications,
[name]: Boolean(enabled)
}
}
)
);
}, [ dispatch, updateSettings, enabledNotifications ]);
return (
<>
<FormSection
label = 'settings.playSounds'>
{
sounds.map(({ label, state, name, disabled }) => (
<FormRow
key = { label }
label = { label }>
<Switch
checked = { Boolean(state) }
disabled = { disabled }
onChange = { onSoundToggled(name) } />
</FormRow>
))
}
</FormSection>
{
Object.keys(enabledNotifications).length > 0 && (
<>
{/* @ts-ignore */}
<Divider style = { styles.fieldSeparator } />
<FormSection
label = 'notify.displayNotifications'>
{
Object.keys(enabledNotifications).map(name => (
<FormRow
key = { name }
label = { name }>
<Switch
checked = { Boolean(enabledNotifications[name]) }
onChange = { onNotificationToggled(name) } />
</FormRow>)
)
}
</FormSection>
</>
)
}
</>
);
};
export default NotificationsSection;

View File

@@ -0,0 +1,165 @@
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useLayoutEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, Text, View, ViewStyle } from 'react-native';
import { Edge } from 'react-native-safe-area-context';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { login, logout } from '../../../authentication/actions.native';
import Avatar from '../../../base/avatar/components/Avatar';
import { IconArrowLeft } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { getLocalParticipant } from '../../../base/participants/functions';
import { updateSettings } from '../../../base/settings/actions';
import BaseThemeNative from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import Input from '../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import HeaderNavigationButton
from '../../../mobile/navigation/components/HeaderNavigationButton';
import {
goBack,
navigate
} from '../../../mobile/navigation/components/settings/SettingsNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import FormSection from './FormSection';
import { AVATAR_SIZE } from './constants';
import styles from './styles';
const ProfileView = ({ isInWelcomePage }: {
isInWelcomePage?: boolean;
}) => {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigation = useNavigation();
const { displayName: reduxDisplayName, email: reduxEmail } = useSelector(
(state: IReduxState) => state['features/base/settings']
);
const participant = useSelector((state: IReduxState) => getLocalParticipant(state));
const { locationURL } = useSelector((state: IReduxState) => state['features/base/connection']);
const [ displayName, setDisplayName ] = useState(reduxDisplayName);
const [ email, setEmail ] = useState(reduxEmail);
const { authLogin: isAutenticated } = useSelector((state: IReduxState) => state['features/base/conference']);
const onDisplayNameChanged = useCallback(newDisplayName => {
setDisplayName(newDisplayName);
}, [ setDisplayName ]);
const onEmailChanged = useCallback(newEmail => {
setEmail(newEmail);
}, [ setEmail ]);
const onApplySettings = useCallback(() => {
dispatch(updateSettings({
displayName,
email
}));
navigate(screen.settings.main);
},
[ dispatch, updateSettings, email, displayName ]);
const onLogin = useCallback(() => {
dispatch(login());
}, [ dispatch ]);
const onLogout = useCallback(() => {
dispatch(logout());
}, [ dispatch ]);
const headerLeft = () => (
<HeaderNavigationButton
color = { BaseThemeNative.palette.link01 }
onPress = { goBack }
src = { IconArrowLeft }
style = { styles.backBtn }
twoActions = { true } />
);
const headerRight = () => {
if (isAutenticated) {
return (
<HeaderNavigationButton
label = { t('toolbar.logout') }
onPress = { onLogout }
style = { styles.logBtn }
twoActions = { true } />
);
}
return (
<HeaderNavigationButton
label = { t('toolbar.login') }
onPress = { onLogin }
style = { styles.logBtn }
twoActions = { true } />
);
};
useLayoutEffect(() => {
navigation.setOptions({
headerLeft,
headerRight: !isInWelcomePage
&& !locationURL?.hostname?.includes('8x8.vc')
&& headerRight
});
}, [ navigation ]);
return (
<JitsiScreen
disableForcedKeyboardDismiss = { true }
hasBottomTextInput = { true }
// @ts-ignore
safeAreaInsets = { [ !isInWelcomePage && 'bottom', 'left', 'right' ].filter(Boolean) as Edge[] }
style = { styles.settingsViewContainer }>
<ScrollView
bounces = { isInWelcomePage }
contentContainerStyle = { styles.profileView as ViewStyle }>
<View>
<View style = { styles.avatarContainer as ViewStyle }>
<Avatar
participantId = { participant?.id }
size = { AVATAR_SIZE } />
</View>
<FormSection>
<Input
customStyles = {{ container: styles.customContainer }}
label = { t('settingsView.displayName') }
onChange = { onDisplayNameChanged }
placeholder = { t('settingsView.displayNamePlaceholderText') }
textContentType = { 'name' } // iOS only
value = { displayName ?? '' } />
<Input
autoCapitalize = 'none'
customStyles = {{ container: styles.customContainer }}
keyboardType = { 'email-address' }
label = { t('settingsView.email') }
onChange = { onEmailChanged }
placeholder = { t('settingsView.emailPlaceholderText') }
textContentType = { 'emailAddress' } // iOS only
value = { email ?? '' } />
<Text style = { styles.gavatarMessageContainer }>
{ t('settingsView.gavatarMessage') }
</Text>
</FormSection>
</View>
<Button
accessibilityLabel = { t('settingsView.apply') }
labelKey = { 'settingsView.apply' }
onClick = { onApplySettings }
style = { styles.applyProfileSettingsButton }
type = { BUTTON_TYPES.PRIMARY } />
</ScrollView>
</JitsiScreen>
);
};
export default ProfileView;

View File

@@ -0,0 +1,101 @@
import React from 'react';
import {
ScrollView,
Text,
TextStyle,
TouchableHighlight,
View,
ViewStyle
} from 'react-native';
import { Divider } from 'react-native-paper';
import { Edge } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import Icon from '../../../base/icons/components/Icon';
import { IconArrowRight } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import { getLocalParticipant } from '../../../base/participants/functions';
import { navigate } from '../../../mobile/navigation/components/settings/SettingsNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { shouldShowModeratorSettings } from '../../functions.native';
import AdvancedSection from './AdvancedSection';
import ConferenceSection from './ConferenceSection';
import GeneralSection from './GeneralSection';
import LinksSection from './LinksSection';
import ModeratorSection from './ModeratorSection';
import NotificationsSection from './NotificationsSection';
import { AVATAR_SIZE } from './constants';
import styles from './styles';
interface IProps {
isInWelcomePage?: boolean | undefined;
}
const SettingsView = ({ isInWelcomePage }: IProps) => {
const { displayName } = useSelector((state: IReduxState) => state['features/base/settings']);
const localParticipant = useSelector((state: IReduxState) => getLocalParticipant(state));
const showModeratorSettings = useSelector((state: IReduxState) => shouldShowModeratorSettings(state));
const { visible } = useSelector((state: IReduxState) => state['features/settings']);
const addBottomInset = !isInWelcomePage;
const localParticipantId = localParticipant?.id;
const scrollBounces = Boolean(isInWelcomePage);
if (visible !== undefined && !visible) {
return null;
}
return (
<JitsiScreen
disableForcedKeyboardDismiss = { true }
safeAreaInsets = { [ addBottomInset && 'bottom', 'left', 'right' ].filter(Boolean) as Edge[] }
style = { styles.settingsViewContainer }>
<ScrollView bounces = { scrollBounces }>
<View style = { styles.profileContainerWrapper as ViewStyle }>
<TouchableHighlight
/* eslint-disable react/jsx-no-bind */
onPress = { () => navigate(screen.settings.profile) }>
<View
style = { styles.profileContainer as ViewStyle }>
<Avatar
participantId = { localParticipantId }
size = { AVATAR_SIZE } />
<Text style = { styles.displayName as TextStyle }>
{ displayName }
</Text>
<Icon
size = { 24 }
src = { IconArrowRight }
style = { styles.profileViewArrow } />
</View>
</TouchableHighlight>
</View>
<GeneralSection />
{ isInWelcomePage && <>
<Divider style = { styles.fieldSeparator as ViewStyle } />
<ConferenceSection />
</> }
<Divider style = { styles.fieldSeparator as ViewStyle } />
<NotificationsSection />
{ showModeratorSettings
&& <>
<Divider style = { styles.fieldSeparator as ViewStyle } />
<ModeratorSection />
</> }
<Divider style = { styles.fieldSeparator as ViewStyle } />
<AdvancedSection />
<Divider style = { styles.fieldSeparator as ViewStyle } />
<LinksSection />
</ScrollView>
</JitsiScreen>
);
};
export default SettingsView;

View File

@@ -0,0 +1 @@
export const AVATAR_SIZE = 64;

View File

@@ -0,0 +1,244 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export const ANDROID_UNDERLINE_COLOR = 'transparent';
export const PLACEHOLDER_COLOR = BaseTheme.palette.focus01;
/**
* The styles of the native components of the feature {@code settings}.
*/
export default {
profileContainerWrapper: {
margin: BaseTheme.spacing[4]
},
profileContainer: {
backgroundColor: BaseTheme.palette.ui02,
borderRadius: BaseTheme.shape.borderRadius,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'flex-start',
padding: BaseTheme.spacing[3]
},
profileView: {
flexGrow: 1,
flexDirection: 'column',
justifyContent: 'space-between'
},
applyProfileSettingsButton: {
marginHorizontal: BaseTheme.spacing[4],
marginVertical: BaseTheme.spacing[3]
},
avatarContainer: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
padding: BaseTheme.spacing[3],
margin: BaseTheme.spacing[4]
},
gavatarMessageContainer: {
marginHorizontal: BaseTheme.spacing[4],
color: BaseTheme.palette.text02,
marginTop: -BaseTheme.spacing[2],
...BaseTheme.typography.bodyShortRegular
},
displayName: {
...BaseTheme.typography.bodyLongRegularLarge,
color: BaseTheme.palette.text01,
marginLeft: BaseTheme.spacing[3],
position: 'relative'
},
profileViewArrow: {
position: 'absolute',
right: BaseTheme.spacing[3]
},
/**
* Style for screen container.
*/
settingsViewContainer: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
/**
* Standardized style for a field container {@code View}.
*/
fieldContainer: {
alignItems: 'center',
flexDirection: 'row',
minHeight: BaseTheme.spacing[8],
paddingHorizontal: BaseTheme.spacing[2],
justifyContent: 'space-between'
},
/**
* * Appended style for column layout fields.
*/
fieldContainerColumn: {
alignItems: 'flex-start',
flexDirection: 'column'
},
/**
* Standard container for a {@code View} containing a field label.
*/
fieldLabelContainer: {
alignItems: 'center',
flexShrink: 1,
flexDirection: 'row',
paddingLeft: BaseTheme.spacing[3],
paddingRight: BaseTheme.spacing[1]
},
/**
* Text of the field labels on the form.
*/
fieldLabelText: {
...BaseTheme.typography.bodyShortRegularLarge
},
/**
* Field container style for all but last row {@code View}.
*/
fieldSeparator: {
marginHorizontal: BaseTheme.spacing[4],
borderBottomWidth: 1,
borderColor: BaseTheme.palette.ui05,
marginVertical: BaseTheme.spacing[3]
},
/**
* Style for the {@code View} containing each
* field values (the actual field).
*/
fieldValueContainer: {
alignItems: 'center',
flexDirection: 'row',
flexShrink: 1,
justifyContent: 'flex-end',
paddingRight: BaseTheme.spacing[3]
},
/**
* Style for the form section separator titles.
*/
formSectionTitleContent: {
backgroundColor: BaseTheme.palette.ui02,
paddingVertical: BaseTheme.spacing[1]
},
formSectionTitleText: {
...BaseTheme.typography.bodyShortBold,
color: BaseTheme.palette.text02,
marginHorizontal: BaseTheme.spacing[4],
marginVertical: BaseTheme.spacing[3]
},
/**
* Global {@code Text} color for the components.
*/
text: {
color: BaseTheme.palette.text01
},
/**
* Text input container style.
*/
customContainer: {
marginBottom: BaseTheme.spacing[3],
marginHorizontal: BaseTheme.spacing[4],
marginTop: BaseTheme.spacing[2]
},
languageButtonContainer: {
borderRadius: BaseTheme.shape.borderRadius,
overflow: 'hidden'
},
languageButton: {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
height: BaseTheme.spacing[7],
justifyContent: 'center'
},
languageOption: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
height: BaseTheme.spacing[6],
marginHorizontal: BaseTheme.spacing[4],
borderBottomWidth: 1,
borderColor: BaseTheme.palette.ui05
},
selectedLanguage: {
color: BaseTheme.palette.text03
},
languageText: {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01,
marginHorizontal: BaseTheme.spacing[2]
},
/**
* Standard text input field style.
*/
textInputField: {
color: BaseTheme.palette.field01,
flex: 1,
...BaseTheme.typography.bodyShortRegularLarge,
textAlign: 'right'
},
/**
* Appended style for column layout fields.
*/
textInputFieldColumn: {
backgroundColor: 'rgb(245, 245, 245)',
borderRadius: 8,
marginVertical: 5,
paddingVertical: 3,
textAlign: 'left'
},
/**
* Style for screen container.
*/
screenContainer: {
flex: 1
},
linksSection: {
display: 'flex',
flexDirection: 'row',
flex: 1,
marginHorizontal: BaseTheme.spacing[3]
},
linksButton: {
width: '33%',
justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
...BaseTheme.typography.bodyShortBoldLarge
},
logBtn: {
marginRight: BaseTheme.spacing[3]
},
backBtn: {
marginLeft: BaseTheme.spacing[3]
}
};

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View 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 } />
);
}

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

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

View File

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

View File

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