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,9 @@
/**
* The type of (redux) action which sets the visibility of the audio settings popup.
*/
export const SET_AUDIO_SETTINGS_VISIBILITY = 'SET_AUDIO_SETTINGS_VISIBILITY';
/**
* The type of (redux) action which sets the visibility of the video settings popup.
*/
export const SET_VIDEO_SETTINGS_VISIBILITY = 'SET_VIDEO_SETTINGS_VISIBILITY';

View File

@@ -0,0 +1,38 @@
import { Linking } from 'react-native';
import { IStore } from '../app/types';
import { isTokenAuthEnabled } from '../authentication/functions';
import { hangup } from '../base/connection/actions.native';
import { openDialog } from '../base/dialog/actions';
import LogoutDialog from './components/native/LogoutDialog';
/**
* Opens {@code LogoutDialog}.
*
* @returns {Function}
*/
export function openLogoutDialog() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { conference } = state['features/base/conference'];
const config = state['features/base/config'];
const logoutUrl = config.tokenLogoutUrl;
dispatch(openDialog(LogoutDialog, {
onLogout() {
if (isTokenAuthEnabled(config)) {
if (logoutUrl) {
Linking.openURL(logoutUrl);
}
dispatch(hangup(true));
} else {
conference?.room.xmpp.moderator.logout(() => dispatch(hangup(true)));
}
}
}));
};
}

View File

@@ -0,0 +1,342 @@
import { batch } from 'react-redux';
import { IStore } from '../app/types';
import { setTokenAuthUrlSuccess } from '../authentication/actions.web';
import { isTokenAuthEnabled } from '../authentication/functions';
import {
setFollowMe,
setFollowMeRecorder,
setStartMutedPolicy,
setStartReactionsMuted
} from '../base/conference/actions';
import { getConferenceState } from '../base/conference/functions';
import { hangup } from '../base/connection/actions.web';
import { openDialog } from '../base/dialog/actions';
import i18next from '../base/i18n/i18next';
import { browser } from '../base/lib-jitsi-meet';
import { getNormalizedDisplayName } from '../base/participants/functions';
import { updateSettings } from '../base/settings/actions';
import { getLocalVideoTrack } from '../base/tracks/functions.web';
import { appendURLHashParam } from '../base/util/uri';
import { disableKeyboardShortcuts, enableKeyboardShortcuts } from '../keyboard-shortcuts/actions';
import { toggleBackgroundEffect } from '../virtual-background/actions';
import virtualBackgroundLogger from '../virtual-background/logger';
import {
SET_AUDIO_SETTINGS_VISIBILITY,
SET_VIDEO_SETTINGS_VISIBILITY
} from './actionTypes';
import LogoutDialog from './components/web/LogoutDialog';
import SettingsDialog from './components/web/SettingsDialog';
import {
getModeratorTabProps,
getMoreTabProps,
getNotificationsTabProps,
getProfileTabProps,
getShortcutsTabProps
} from './functions.web';
/**
* Opens {@code LogoutDialog}.
*
* @returns {Function}
*/
export function openLogoutDialog() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const config = state['features/base/config'];
const logoutUrl = config.tokenLogoutUrl;
const { conference } = state['features/base/conference'];
const { jwt } = state['features/base/jwt'];
dispatch(openDialog(LogoutDialog, {
onLogout() {
if (isTokenAuthEnabled(config) && config.tokenAuthUrlAutoRedirect && jwt) {
// user is logging out remove auto redirect indication
dispatch(setTokenAuthUrlSuccess(false));
}
if (logoutUrl && browser.isElectron()) {
const url = appendURLHashParam(logoutUrl, 'electron', 'true');
window.open(url, '_blank');
dispatch(hangup(true));
} else {
if (logoutUrl) {
window.location.href = logoutUrl;
return;
}
conference?.room.xmpp.moderator.logout(() => dispatch(hangup(true)));
}
}
}));
};
}
/**
* Opens {@code SettingsDialog}.
*
* @param {string} defaultTab - The tab in {@code SettingsDialog} that should be
* displayed initially.
* @param {boolean} isDisplayedOnWelcomePage - Indicates whether the device selection dialog is displayed on the
* welcome page or not.
* @returns {Function}
*/
export function openSettingsDialog(defaultTab?: string, isDisplayedOnWelcomePage?: boolean) {
return openDialog(SettingsDialog, {
defaultTab,
isDisplayedOnWelcomePage
});
}
/**
* Sets the visibility of the audio settings.
*
* @param {boolean} value - The new value.
* @returns {Function}
*/
function setAudioSettingsVisibility(value: boolean) {
return {
type: SET_AUDIO_SETTINGS_VISIBILITY,
value
};
}
/**
* Sets the visibility of the video settings.
*
* @param {boolean} value - The new value.
* @returns {Function}
*/
function setVideoSettingsVisibility(value: boolean) {
return {
type: SET_VIDEO_SETTINGS_VISIBILITY,
value
};
}
/**
* Submits the settings from the "More" tab of the settings dialog.
*
* @param {Object} newState - The new settings.
* @returns {Function}
*/
export function submitMoreTab(newState: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const currentState = getMoreTabProps(state);
if (newState.maxStageParticipants !== currentState.maxStageParticipants) {
dispatch(updateSettings({ maxStageParticipants: Number(newState.maxStageParticipants) }));
}
if (newState.hideSelfView !== currentState.hideSelfView) {
dispatch(updateSettings({ disableSelfView: newState.hideSelfView }));
}
if (newState.currentLanguage !== currentState.currentLanguage) {
i18next.changeLanguage(newState.currentLanguage);
const { conference } = getConferenceState(state);
conference?.setTranscriptionLanguage(newState.currentLanguage);
}
if (newState.showSubtitlesOnStage !== currentState.showSubtitlesOnStage) {
dispatch(updateSettings({ showSubtitlesOnStage: newState.showSubtitlesOnStage }));
}
};
}
/**
* Submits the settings from the "Moderator" tab of the settings dialog.
*
* @param {Object} newState - The new settings.
* @returns {Function}
*/
export function submitModeratorTab(newState: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const currentState = getModeratorTabProps(getState());
if (newState.followMeEnabled !== currentState.followMeEnabled) {
dispatch(setFollowMe(newState.followMeEnabled));
}
if (newState.followMeRecorderEnabled !== currentState.followMeRecorderEnabled) {
dispatch(setFollowMeRecorder(newState.followMeRecorderEnabled));
}
if (newState.startReactionsMuted !== currentState.startReactionsMuted) {
batch(() => {
// updating settings we want to update and backend (notify the rest of the participants)
dispatch(setStartReactionsMuted(newState.startReactionsMuted, true));
dispatch(updateSettings({ soundsReactions: !newState.startReactionsMuted }));
});
}
if (newState.startAudioMuted !== currentState.startAudioMuted
|| newState.startVideoMuted !== currentState.startVideoMuted) {
dispatch(setStartMutedPolicy(
newState.startAudioMuted, newState.startVideoMuted));
}
if (newState.chatWithPermissionsEnabled !== currentState.chatWithPermissionsEnabled) {
const { conference } = getState()['features/base/conference'];
const currentPermissions = conference?.getMetadataHandler().getMetadata().permissions || {};
conference?.getMetadataHandler().setMetadata('permissions', {
...currentPermissions,
groupChatRestricted: newState.chatWithPermissionsEnabled
});
}
};
}
/**
* Submits the settings from the "Profile" tab of the settings dialog.
*
* @param {Object} newState - The new settings.
* @returns {Function}
*/
export function submitProfileTab(newState: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const currentState = getProfileTabProps(getState());
if (newState.displayName !== currentState.displayName) {
dispatch(updateSettings({ displayName: getNormalizedDisplayName(newState.displayName) }));
}
if (newState.email !== currentState.email) {
APP.conference.changeLocalEmail(newState.email);
}
};
}
/**
* Submits the settings from the "Sounds" tab of the settings dialog.
*
* @param {Object} newState - The new settings.
* @returns {Function}
*/
export function submitNotificationsTab(newState: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const currentState = getNotificationsTabProps(getState());
const shouldNotUpdateReactionSounds = getModeratorTabProps(getState()).startReactionsMuted;
const shouldUpdate = (newState.soundsIncomingMessage !== currentState.soundsIncomingMessage)
|| (newState.soundsParticipantJoined !== currentState.soundsParticipantJoined)
|| (newState.soundsParticipantKnocking !== currentState.soundsParticipantKnocking)
|| (newState.soundsParticipantLeft !== currentState.soundsParticipantLeft)
|| (newState.soundsTalkWhileMuted !== currentState.soundsTalkWhileMuted)
|| (newState.soundsReactions !== currentState.soundsReactions);
if (shouldUpdate) {
const settingsToUpdate = {
soundsIncomingMessage: newState.soundsIncomingMessage,
soundsParticipantJoined: newState.soundsParticipantJoined,
soundsParticipantKnocking: newState.soundsParticipantKnocking,
soundsParticipantLeft: newState.soundsParticipantLeft,
soundsTalkWhileMuted: newState.soundsTalkWhileMuted,
soundsReactions: newState.soundsReactions
};
if (shouldNotUpdateReactionSounds) {
delete settingsToUpdate.soundsReactions;
}
dispatch(updateSettings(settingsToUpdate));
}
const enabledNotifications = newState.enabledNotifications;
if (enabledNotifications !== currentState.enabledNotifications) {
dispatch(updateSettings({
userSelectedNotifications: {
...getState()['features/base/settings'].userSelectedNotifications,
...enabledNotifications
}
}));
}
};
}
/**
* Toggles the visibility of the audio settings.
*
* @returns {void}
*/
export function toggleAudioSettings() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const value = getState()['features/settings'].audioSettingsVisible;
dispatch(setAudioSettingsVisibility(!value));
};
}
/**
* Toggles the visibility of the video settings.
*
* @returns {void}
*/
export function toggleVideoSettings() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const value = getState()['features/settings'].videoSettingsVisible;
dispatch(setVideoSettingsVisibility(!value));
};
}
/**
* Submits the settings from the "Shortcuts" tab of the settings dialog.
*
* @param {Object} newState - The new settings.
* @returns {Function}
*/
export function submitShortcutsTab(newState: any) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const currentState = getShortcutsTabProps(getState());
if (newState.keyboardShortcutsEnabled !== currentState.keyboardShortcutsEnabled) {
if (newState.keyboardShortcutsEnabled) {
dispatch(enableKeyboardShortcuts());
} else {
dispatch(disableKeyboardShortcuts());
}
}
};
}
/**
* Submits the settings from the "Virtual Background" tab of the settings dialog.
*
* @param {Object} newState - The new settings.
* @param {boolean} isCancel - Whether the change represents a cancel.
* @returns {Function}
*/
export function submitVirtualBackgroundTab(newState: any, isCancel = false) {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const track = getLocalVideoTrack(state['features/base/tracks'])?.jitsiTrack;
const { localFlipX } = state['features/base/settings'];
if (newState.options?.selectedThumbnail) {
await dispatch(toggleBackgroundEffect(newState.options, track));
if (!isCancel) {
// Set x scale to default value.
dispatch(updateSettings({
localFlipX
}));
virtualBackgroundLogger.info(`Virtual background type: '${
typeof newState.options.backgroundType === 'undefined'
? 'none' : newState.options.backgroundType}' applied!`);
}
}
};
}

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

View File

@@ -0,0 +1,21 @@
export const SETTINGS_TABS = {
AUDIO: 'audio_tab',
CALENDAR: 'calendar_tab',
MORE: 'more_tab',
MODERATOR: 'moderator-tab',
NOTIFICATIONS: 'notifications_tab',
PROFILE: 'profile_tab',
SHORTCUTS: 'shortcuts_tab',
VIDEO: 'video_tab',
VIRTUAL_BACKGROUND: 'virtual-background_tab'
};
/**
* Default frame rate to be used for capturing screenshare.
*/
export const SS_DEFAULT_FRAME_RATE = 5;
/**
* Supported framerates to be used for capturing screenshare.
*/
export const SS_SUPPORTED_FRAMERATES = [ 5, 15, 30 ];

View File

@@ -0,0 +1,283 @@
import { IReduxState } from '../app/types';
import { MEDIA_TYPE } from '../av-moderation/constants';
import { isEnabledFromState } from '../av-moderation/functions';
import { IStateful } from '../base/app/types';
import { isNameReadOnly } from '../base/config/functions.any';
import { SERVER_URL_CHANGE_ENABLED } from '../base/flags/constants';
import { getFeatureFlag } from '../base/flags/functions';
import i18next, { DEFAULT_LANGUAGE, LANGUAGES } from '../base/i18n/i18next';
import { getLocalParticipant } from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { getHideSelfView } from '../base/settings/functions.any';
import { parseStandardURIString } from '../base/util/uri';
import { isStageFilmstripEnabled } from '../filmstrip/functions';
import { isFollowMeActive, isFollowMeRecorderActive } from '../follow-me/functions';
import { isReactionsEnabled } from '../reactions/functions.any';
import { areClosedCaptionsEnabled } from '../subtitles/functions.any';
import { iAmVisitor } from '../visitors/functions';
import { shouldShowModeratorSettings } from './functions';
/**
* Returns true if user is allowed to change Server URL.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {boolean} True to indicate that user can change Server URL, false otherwise.
*/
export function isServerURLChangeEnabled(stateful: IStateful) {
const state = toState(stateful);
return getFeatureFlag(state, SERVER_URL_CHANGE_ENABLED, true);
}
/**
* Normalizes a URL entered by the user.
* FIXME: Consider adding this to base/util/uri.
*
* @param {string} url - The URL to validate.
* @returns {string|null} - The normalized URL, or null if the URL is invalid.
*/
export function normalizeUserInputURL(url: string) {
/* eslint-disable no-param-reassign */
if (url) {
url = url.replace(/\s/g, '').toLowerCase();
const urlRegExp = new RegExp('^(\\w+://)?(.+)$');
const urlComponents = urlRegExp.exec(url);
if (urlComponents && !urlComponents[1]?.startsWith('http')) {
url = `https://${urlComponents[2]}`;
}
const parsedURI = parseStandardURIString(url);
if (!parsedURI.host) {
return null;
}
return parsedURI.toString();
}
return url;
/* eslint-enable no-param-reassign */
}
/**
* Returns the notification types and their user selected configuration.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {Object} - The section of notifications to be configured.
*/
export function getNotificationsMap(stateful: IStateful): { [key: string]: boolean; } {
const state = toState(stateful);
const { notifications } = state['features/base/config'];
const { userSelectedNotifications } = state['features/base/settings'];
if (!userSelectedNotifications) {
return {};
}
return Object.keys(userSelectedNotifications)
.filter(key => !notifications || notifications.includes(key))
.reduce((notificationsMap, key) => {
return {
...notificationsMap,
[key]: userSelectedNotifications[key]
};
}, {});
}
function normalizeCurrentLanguage(language: string) {
if (!language) {
return;
}
const [ country, lang ] = language.split('-');
const jitsiNormalized = `${country}${lang ?? ''}`;
if (LANGUAGES.includes(jitsiNormalized)) {
return jitsiNormalized;
}
if (LANGUAGES.includes(country)) {
return country;
}
}
/**
* Returns the properties for the "More" tab from settings dialog from Redux
* state.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {Object} - The properties for the "More" tab from settings dialog.
*/
export function getMoreTabProps(stateful: IStateful) {
const state = toState(stateful);
const stageFilmstripEnabled = isStageFilmstripEnabled(state);
const language = normalizeCurrentLanguage(i18next.language) || DEFAULT_LANGUAGE;
const configuredTabs: string[] = interfaceConfig.SETTINGS_SECTIONS || [];
// when self view is controlled by the config we hide the settings
const { disableSelfView, disableSelfViewSettings } = state['features/base/config'];
return {
areClosedCaptionsEnabled: areClosedCaptionsEnabled(state),
currentLanguage: language,
disableHideSelfView: disableSelfViewSettings || disableSelfView,
hideSelfView: getHideSelfView(state),
iAmVisitor: iAmVisitor(state),
languages: LANGUAGES,
maxStageParticipants: state['features/base/settings'].maxStageParticipants,
showLanguageSettings: configuredTabs.includes('language'),
showSubtitlesOnStage: state['features/base/settings'].showSubtitlesOnStage,
stageFilmstripEnabled
};
}
/**
* Returns the properties for the "More" tab from settings dialog from Redux
* state.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {Object} - The properties for the "More" tab from settings dialog.
*/
export function getModeratorTabProps(stateful: IStateful) {
const state = toState(stateful);
const {
conference,
followMeEnabled,
followMeRecorderEnabled,
startAudioMutedPolicy,
startVideoMutedPolicy,
startReactionsMuted
} = state['features/base/conference'];
const { groupChatWithPermissions } = state['features/chat'];
const { disableReactionsModeration } = state['features/base/config'];
const followMeActive = isFollowMeActive(state);
const followMeRecorderActive = isFollowMeRecorderActive(state);
const showModeratorSettings = shouldShowModeratorSettings(state);
const conferenceMetadata = conference?.getMetadataHandler()?.getMetadata();
const disableChatWithPermissions = !conferenceMetadata?.allownersEnabled;
const isAudioModerationEnabled = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
const isVideoModerationEnabled = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
// The settings sections to display.
return {
audioModerationEnabled: isAudioModerationEnabled,
videoModerationEnabled: isVideoModerationEnabled,
chatWithPermissionsEnabled: Boolean(groupChatWithPermissions),
showModeratorSettings: Boolean(conference && showModeratorSettings),
disableChatWithPermissions: Boolean(disableChatWithPermissions),
disableReactionsModeration: Boolean(disableReactionsModeration),
followMeActive: Boolean(conference && followMeActive),
followMeEnabled: Boolean(conference && followMeEnabled),
followMeRecorderActive: Boolean(conference && followMeRecorderActive),
followMeRecorderEnabled: Boolean(conference && followMeRecorderEnabled),
startReactionsMuted: Boolean(conference && startReactionsMuted),
startAudioMuted: Boolean(conference && startAudioMutedPolicy),
startVideoMuted: Boolean(conference && startVideoMutedPolicy)
};
}
/**
* Returns the properties for the "Profile" tab from settings dialog from Redux
* state.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {Object} - The properties for the "Profile" tab from settings
* dialog.
*/
export function getProfileTabProps(stateful: IStateful) {
const state = toState(stateful);
const {
authEnabled,
authLogin,
conference
} = state['features/base/conference'];
const config = state['features/base/config'];
let { hideEmailInSettings } = config;
const localParticipant = getLocalParticipant(state);
if (config.gravatar?.disabled
|| (localParticipant?.avatarURL && localParticipant?.avatarURL.length > 0)) {
hideEmailInSettings = true;
}
return {
authEnabled: Boolean(conference && authEnabled),
authLogin,
displayName: localParticipant?.name,
email: localParticipant?.email,
hideEmailInSettings,
id: localParticipant?.id,
readOnlyName: isNameReadOnly(state)
};
}
/**
* Returns the properties for the "Sounds" tab from settings dialog from Redux
* state.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {boolean} showSoundsSettings - Whether to show the sound settings or not.
* @returns {Object} - The properties for the "Sounds" tab from settings
* dialog.
*/
export function getNotificationsTabProps(stateful: IStateful, showSoundsSettings?: boolean) {
const state = toState(stateful);
const {
soundsIncomingMessage,
soundsParticipantJoined,
soundsParticipantKnocking,
soundsParticipantLeft,
soundsTalkWhileMuted,
soundsReactions
} = state['features/base/settings'];
const enableReactions = isReactionsEnabled(state);
const moderatorMutedSoundsReactions = state['features/base/conference'].startReactionsMuted ?? false;
const enabledNotifications = getNotificationsMap(stateful);
return {
disabledSounds: state['features/base/config'].disabledSounds || [],
enabledNotifications,
showNotificationsSettings: Object.keys(enabledNotifications).length > 0,
soundsIncomingMessage,
soundsParticipantJoined,
soundsParticipantKnocking,
soundsParticipantLeft,
soundsTalkWhileMuted,
soundsReactions,
enableReactions,
moderatorMutedSoundsReactions,
showSoundsSettings
};
}
/**
* Returns the visibility state of the audio settings.
*
* @param {Object} state - The state of the application.
* @returns {boolean}
*/
export function getAudioSettingsVisibility(state: IReduxState) {
return state['features/settings'].audioSettingsVisible;
}
/**
* Returns the visibility state of the video settings.
*
* @param {Object} state - The state of the application.
* @returns {boolean}
*/
export function getVideoSettingsVisibility(state: IReduxState) {
return state['features/settings'].videoSettingsVisible;
}

View File

@@ -0,0 +1,38 @@
import { IStateful } from '../base/app/types';
import { isLocalParticipantModerator } from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { getParticipantsPaneConfig } from '../participants-pane/functions';
export * from './functions.any';
/**
* Used on web.
*
* @param {(Function|Object)} _stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {boolean} _isDisplayedOnWelcomePage - Indicates whether the shortcuts dialog is displayed on the
* welcome page or not.
* @returns {Object} - The properties for the "Shortcuts" tab from settings
* dialog.
*/
export function getShortcutsTabProps(_stateful: any, _isDisplayedOnWelcomePage?: boolean) {
// needed to fix lint error.
return {
keyboardShortcutsEnabled: false
};
}
/**
* Returns true if moderator tab in settings should be visible/accessible.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {boolean} True to indicate that moderator tab should be visible, false otherwise.
*/
export function shouldShowModeratorSettings(stateful: IStateful) {
const state = toState(stateful);
const { hideModeratorSettingsTab } = getParticipantsPaneConfig(state);
const hasModeratorRights = isLocalParticipantModerator(state);
return hasModeratorRights && !hideModeratorSettingsTab;
}

View File

@@ -0,0 +1,147 @@
import { IStateful } from '../base/app/types';
import { createLocalTrack } from '../base/lib-jitsi-meet/functions';
import { isLocalParticipantModerator } from '../base/participants/functions';
import { toState } from '../base/redux/functions';
import { getUserSelectedCameraDeviceId } from '../base/settings/functions.web';
import { areKeyboardShortcutsEnabled, getKeyboardShortcutsHelpDescriptions } from '../keyboard-shortcuts/functions';
import { getParticipantsPaneConfig } from '../participants-pane/functions';
import { isPrejoinPageVisible } from '../prejoin/functions';
export * from './functions.any';
/**
* Returns a promise which resolves with a list of objects containing
* all the video jitsiTracks and appropriate errors for the given device ids.
*
* @param {string[]} ids - The list of the camera ids for which to create tracks.
* @param {number} [timeout] - A timeout for the createLocalTrack function call.
*
* @returns {Promise<Object[]>}
*/
export function createLocalVideoTracks(ids: string[], timeout?: number) {
return Promise.all(ids.map(deviceId => createLocalTrack('video', deviceId, timeout)
.then((jitsiTrack: any) => {
return {
jitsiTrack,
deviceId
};
})
.catch(() => {
return {
jitsiTrack: null,
deviceId,
error: 'deviceSelection.previewUnavailable'
};
})));
}
/**
* Returns a promise which resolves with a list of objects containing
* the audio track and the corresponding audio device information.
*
* @param {Object[]} devices - A list of microphone devices.
* @param {number} [timeout] - A timeout for the createLocalTrack function call.
* @returns {Promise<{
* deviceId: string,
* hasError: boolean,
* jitsiTrack: Object,
* label: string
* }[]>}
*/
export function createLocalAudioTracks(devices: Array<{ deviceId: string; label: string; }>, timeout?: number) {
return Promise.all(
devices.map(async ({ deviceId, label }) => {
let jitsiTrack = null;
let hasError = false;
try {
jitsiTrack = await createLocalTrack('audio', deviceId, timeout);
} catch (err) {
hasError = true;
}
return {
deviceId,
hasError,
jitsiTrack,
label
};
}));
}
/**
* Returns the properties for the "Shortcuts" tab from settings dialog from Redux
* state.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {boolean} isDisplayedOnWelcomePage - Indicates whether the shortcuts dialog is displayed on the
* welcome page or not.
* @returns {Object} - The properties for the "Shortcuts" tab from settings
* dialog.
*/
export function getShortcutsTabProps(stateful: IStateful, isDisplayedOnWelcomePage?: boolean) {
const state = toState(stateful);
return {
displayShortcuts: !isDisplayedOnWelcomePage && !isPrejoinPageVisible(state),
keyboardShortcutsEnabled: areKeyboardShortcutsEnabled(state),
keyboardShortcutsHelpDescriptions: getKeyboardShortcutsHelpDescriptions(state)
};
}
/**
* Returns the properties for the "Virtual Background" tab from settings dialog from Redux
* state.
*
* @param {(Function|Object)} stateful -The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @param {boolean} isDisplayedOnWelcomePage - Indicates whether the device selection dialog is displayed on the
* welcome page or not.
* @returns {Object} - The properties for the "Shortcuts" tab from settings
* dialog.
*/
export function getVirtualBackgroundTabProps(stateful: IStateful, isDisplayedOnWelcomePage?: boolean) {
const state = toState(stateful);
const settings = state['features/base/settings'];
const userSelectedCamera = getUserSelectedCameraDeviceId(state);
let selectedVideoInputId = settings.cameraDeviceId;
if (isDisplayedOnWelcomePage) {
selectedVideoInputId = userSelectedCamera;
}
return {
options: state['features/virtual-background'],
selectedVideoInputId
};
}
/**
* Used for web. Indicates if the setting section is enabled.
*
* @param {string} settingName - The name of the setting section as defined in
* interface_config.js and SettingsMenu.js.
* @returns {boolean} True to indicate that the given setting section
* is enabled, false otherwise.
*/
export function isSettingEnabled(settingName: string) {
return interfaceConfig.SETTINGS_SECTIONS.includes(settingName);
}
/**
* Returns true if moderator tab in settings should be visible/accessible.
*
* @param {(Function|Object)} stateful - The (whole) redux state, or redux's
* {@code getState} function to be used to retrieve the state.
* @returns {boolean} True to indicate that moderator tab should be visible, false otherwise.
*/
export function shouldShowModeratorSettings(stateful: IStateful) {
const state = toState(stateful);
const { hideModeratorSettingsTab } = getParticipantsPaneConfig(state);
const hasModeratorRights = Boolean(isSettingEnabled('moderator') && isLocalParticipantModerator(state));
return hasModeratorRights && !hideModeratorSettingsTab;
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/settings');

View File

@@ -0,0 +1,33 @@
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { SETTINGS_UPDATED } from '../base/settings/actionTypes';
import { getHideSelfView } from '../base/settings/functions.web';
import { showNotification } from '../notifications/actions';
import { DISABLE_SELF_VIEW_NOTIFICATION_ID, NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { openSettingsDialog } from './actions';
import { SETTINGS_TABS } from './constants';
MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
const oldValue = getHideSelfView(getState());
const result = next(action);
switch (action.type) {
case SETTINGS_UPDATED: {
const newValue = action.settings.disableSelfView;
if (newValue !== oldValue && newValue) {
dispatch(showNotification({
uid: DISABLE_SELF_VIEW_NOTIFICATION_ID,
titleKey: 'notify.selfViewTitle',
customActionNameKey: [ 'settings.title' ],
customActionHandler: [ () =>
dispatch(openSettingsDialog(SETTINGS_TABS.MORE))
]
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
}
}
return result;
});

View File

@@ -0,0 +1,28 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
SET_AUDIO_SETTINGS_VISIBILITY,
SET_VIDEO_SETTINGS_VISIBILITY
} from './actionTypes';
export interface ISettingsState {
audioSettingsVisible?: boolean;
videoSettingsVisible?: boolean;
}
ReducerRegistry.register('features/settings', (state: ISettingsState = {}, action) => {
switch (action.type) {
case SET_AUDIO_SETTINGS_VISIBILITY:
return {
...state,
audioSettingsVisible: action.value
};
case SET_VIDEO_SETTINGS_VISIBILITY:
return {
...state,
videoSettingsVisible: action.value
};
}
return state;
});