[RN] Add app-settings feature
[RN] Fix PR feedbacks, write persistency docs
This commit is contained in:
parent
871ef9ff0e
commit
bfcd34358b
|
@ -535,5 +535,13 @@
|
|||
"invite": "Invite in __app__",
|
||||
"title": "Call access info",
|
||||
"tooltip": "Get access info about the meeting"
|
||||
},
|
||||
"profileModal": {
|
||||
"displayName": "Display name",
|
||||
"email": "Email",
|
||||
"header": "Settings",
|
||||
"serverURL": "Server URL",
|
||||
"startWithAudioMuted": "Start with audio muted",
|
||||
"startWithVideoMuted": "Start with video muted"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* The type of (redux) action which signals the request
|
||||
* to hide the app settings modal.
|
||||
*
|
||||
* {
|
||||
* type: HIDE_APP_SETTINGS
|
||||
* }
|
||||
*/
|
||||
export const HIDE_APP_SETTINGS = Symbol('HIDE_APP_SETTINGS');
|
||||
|
||||
/**
|
||||
* The type of (redux) action which signals the request
|
||||
* to show the app settings modal where available.
|
||||
*
|
||||
* {
|
||||
* type: SHOW_APP_SETTINGS
|
||||
* }
|
||||
*/
|
||||
export const SHOW_APP_SETTINGS = Symbol('SHOW_APP_SETTINGS');
|
|
@ -0,0 +1,32 @@
|
|||
/* @flow */
|
||||
|
||||
import {
|
||||
HIDE_APP_SETTINGS,
|
||||
SHOW_APP_SETTINGS
|
||||
} from './actionTypes';
|
||||
|
||||
/**
|
||||
* Redux-signals the request to open the app settings modal.
|
||||
*
|
||||
* @returns {{
|
||||
* type: SHOW_APP_SETTINGS
|
||||
* }}
|
||||
*/
|
||||
export function showAppSettings() {
|
||||
return {
|
||||
type: SHOW_APP_SETTINGS
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Redux-signals the request to hide the app settings modal.
|
||||
*
|
||||
* @returns {{
|
||||
* type: HIDE_APP_SETTINGS
|
||||
* }}
|
||||
*/
|
||||
export function hideAppSettings() {
|
||||
return {
|
||||
type: HIDE_APP_SETTINGS
|
||||
};
|
||||
}
|
|
@ -0,0 +1,311 @@
|
|||
// @flow
|
||||
|
||||
import { Component } from 'react';
|
||||
|
||||
import { hideAppSettings } from '../actions';
|
||||
import { getProfile, updateProfile } from '../../base/profile';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link AbstractAppSettings}
|
||||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
* The current profile object.
|
||||
*/
|
||||
_profile: Object,
|
||||
|
||||
/**
|
||||
* The visibility prop of the settings modal.
|
||||
*/
|
||||
_visible: boolean,
|
||||
|
||||
/**
|
||||
* Redux store dispatch function.
|
||||
*/
|
||||
dispatch: Dispatch<*>
|
||||
};
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} state of {@link AbstractAppSettings}.
|
||||
*/
|
||||
type State = {
|
||||
|
||||
/**
|
||||
* The display name field value on the settings screen.
|
||||
*/
|
||||
displayName: string,
|
||||
|
||||
/**
|
||||
* The email field value on the settings screen.
|
||||
*/
|
||||
email: string,
|
||||
|
||||
/**
|
||||
* The server url field value on the settings screen.
|
||||
*/
|
||||
serverURL: string,
|
||||
|
||||
/**
|
||||
* The start audio muted switch value on the settings screen.
|
||||
*/
|
||||
startWithAudioMuted: boolean,
|
||||
|
||||
/**
|
||||
* The start video muted switch value on the settings screen.
|
||||
*/
|
||||
startWithVideoMuted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Base (abstract) class for container component rendering
|
||||
* the app settings page.
|
||||
*
|
||||
* @abstract
|
||||
*/
|
||||
export class AbstractAppSettings extends Component<Props, State> {
|
||||
|
||||
/**
|
||||
* Initializes a new {@code AbstractAppSettings} instance.
|
||||
*
|
||||
* @param {Props} props - The React {@code Component} props to initialize
|
||||
* the component.
|
||||
*/
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this._onChangeDisplayName = this._onChangeDisplayName.bind(this);
|
||||
this._onChangeEmail = this._onChangeEmail.bind(this);
|
||||
this._onChangeServerName = this._onChangeServerName.bind(this);
|
||||
this._onRequestClose = this._onRequestClose.bind(this);
|
||||
this._onSaveDisplayName = this._onSaveDisplayName.bind(this);
|
||||
this._onSaveEmail = this._onSaveEmail.bind(this);
|
||||
this._onSaveServerName = this._onSaveServerName.bind(this);
|
||||
this._onStartAudioMutedChange
|
||||
= this._onStartAudioMutedChange.bind(this);
|
||||
this._onStartVideoMutedChange
|
||||
= this._onStartVideoMutedChange.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes React's {@link Component#componentWillReceiveProps()} to make
|
||||
* sure we have the state Initialized on component mount.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
componentWillMount() {
|
||||
this._updateStateFromProps(this.props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#componentWillReceiveProps()}. Invoked
|
||||
* before this mounted component receives new props.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @param {Props} nextProps - New props component will receive.
|
||||
*/
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
this._updateStateFromProps(nextProps);
|
||||
}
|
||||
|
||||
_onChangeDisplayName: (string) => void;
|
||||
|
||||
/**
|
||||
* Handles the display name field value change.
|
||||
*
|
||||
* @protected
|
||||
* @param {string} text - The value typed in the name field.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onChangeDisplayName(text) {
|
||||
this.setState({
|
||||
displayName: text
|
||||
});
|
||||
}
|
||||
|
||||
_onChangeEmail: (string) => void;
|
||||
|
||||
/**
|
||||
* Handles the email field value change.
|
||||
*
|
||||
* @protected
|
||||
* @param {string} text - The value typed in the email field.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onChangeEmail(text) {
|
||||
this.setState({
|
||||
email: text
|
||||
});
|
||||
}
|
||||
|
||||
_onChangeServerName: (string) => void;
|
||||
|
||||
/**
|
||||
* Handles the server name field value change.
|
||||
*
|
||||
* @protected
|
||||
* @param {string} text - The server URL typed in the server field.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onChangeServerName(text) {
|
||||
this.setState({
|
||||
serverURL: text
|
||||
});
|
||||
}
|
||||
|
||||
_onRequestClose: () => void;
|
||||
|
||||
/**
|
||||
* Handles the hardware back button.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_onRequestClose() {
|
||||
this.props.dispatch(hideAppSettings());
|
||||
}
|
||||
|
||||
_onSaveDisplayName: () => void;
|
||||
|
||||
/**
|
||||
* Handles the display name field onEndEditing.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSaveDisplayName() {
|
||||
this._updateProfile({
|
||||
displayName: this.state.displayName
|
||||
});
|
||||
}
|
||||
|
||||
_onSaveEmail: () => void;
|
||||
|
||||
/**
|
||||
* Handles the email field onEndEditing.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSaveEmail() {
|
||||
this._updateProfile({
|
||||
email: this.state.email
|
||||
});
|
||||
}
|
||||
|
||||
_onSaveServerName: () => void;
|
||||
|
||||
/**
|
||||
* Handles the server name field onEndEditing.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSaveServerName() {
|
||||
let serverURL;
|
||||
|
||||
if (this.state.serverURL.endsWith('/')) {
|
||||
serverURL = this.state.serverURL.substr(
|
||||
0, this.state.serverURL.length - 1
|
||||
);
|
||||
} else {
|
||||
serverURL = this.state.serverURL;
|
||||
}
|
||||
|
||||
this._updateProfile({
|
||||
defaultURL: serverURL
|
||||
});
|
||||
this.setState({
|
||||
serverURL
|
||||
});
|
||||
}
|
||||
|
||||
_onStartAudioMutedChange: (boolean) => void;
|
||||
|
||||
/**
|
||||
* Handles the start audio muted change event.
|
||||
*
|
||||
* @protected
|
||||
* @param {boolean} newValue - The new value for the
|
||||
* start audio muted option.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartAudioMutedChange(newValue) {
|
||||
this.setState({
|
||||
startWithAudioMuted: newValue
|
||||
});
|
||||
|
||||
this._updateProfile({
|
||||
startWithAudioMuted: newValue
|
||||
});
|
||||
}
|
||||
|
||||
_onStartVideoMutedChange: (boolean) => void;
|
||||
|
||||
/**
|
||||
* Handles the start video muted change event.
|
||||
*
|
||||
* @protected
|
||||
* @param {boolean} newValue - The new value for the
|
||||
* start video muted option.
|
||||
* @returns {void}
|
||||
*/
|
||||
_onStartVideoMutedChange(newValue) {
|
||||
this.setState({
|
||||
startWithVideoMuted: newValue
|
||||
});
|
||||
|
||||
this._updateProfile({
|
||||
startWithVideoMuted: newValue
|
||||
});
|
||||
}
|
||||
|
||||
_updateProfile: (Object) => void;
|
||||
|
||||
/**
|
||||
* Updates the persisted profile on any change.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} updateObject - The partial update object for the profile.
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateProfile(updateObject: Object) {
|
||||
this.props.dispatch(updateProfile({
|
||||
...this.props._profile,
|
||||
...updateObject
|
||||
}));
|
||||
}
|
||||
|
||||
_updateStateFromProps: (Object) => void;
|
||||
|
||||
/**
|
||||
* Updates the component state when (new) props are received.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} props - The component's props.
|
||||
* @returns {void}
|
||||
*/
|
||||
_updateStateFromProps(props) {
|
||||
this.setState({
|
||||
displayName: props._profile.displayName,
|
||||
email: props._profile.email,
|
||||
serverURL: props._profile.defaultURL,
|
||||
startWithAudioMuted: props._profile.startWithAudioMuted,
|
||||
startWithVideoMuted: props._profile.startWithVideoMuted
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps (parts of) the redux state to the React {@code Component} props of
|
||||
* {@code AbstractAppSettings}.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @protected
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function _mapStateToProps(state: Object) {
|
||||
return {
|
||||
_profile: getProfile(state),
|
||||
_visible: state['features/app-settings'].visible
|
||||
};
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import {
|
||||
_mapStateToProps,
|
||||
AbstractAppSettings
|
||||
} from './AbstractAppSettings';
|
||||
import FormRow from './FormRow';
|
||||
import styles from './styles';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
/**
|
||||
* The native container rendering the app settings page.
|
||||
*
|
||||
* @extends AbstractAppSettings
|
||||
*/
|
||||
class AppSettings extends AbstractAppSettings {
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}, renders the settings page.
|
||||
*
|
||||
* @inheritdoc
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { t } = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
animationType = 'slide'
|
||||
onRequestClose = { this._onRequestClose }
|
||||
presentationStyle = 'fullScreen'
|
||||
style = { styles.modal }
|
||||
visible = { this.props._visible }>
|
||||
<View style = { styles.headerContainer } >
|
||||
<Text style = { [ styles.text, styles.headerTitle ] } >
|
||||
{ t('profileModal.header') }
|
||||
</Text>
|
||||
</View>
|
||||
<View style = { styles.settingsContainer } >
|
||||
<FormRow
|
||||
fieldSeparator = { true }
|
||||
i18nLabel = 'profileModal.serverURL' >
|
||||
<TextInput
|
||||
autoCapitalize = 'none'
|
||||
onChangeText = { this._onChangeServerName }
|
||||
onEndEditing = { this._onSaveServerName }
|
||||
placeholder = 'https://jitsi.example.com'
|
||||
value = { this.state.serverURL } />
|
||||
</FormRow>
|
||||
<FormRow
|
||||
fieldSeparator = { true }
|
||||
i18nLabel = 'profileModal.displayName' >
|
||||
<TextInput
|
||||
onChangeText = { this._onChangeDisplayName }
|
||||
onEndEditing = { this._onSaveDisplayName }
|
||||
placeholder = 'John Doe'
|
||||
value = { this.state.displayName } />
|
||||
</FormRow>
|
||||
<FormRow
|
||||
fieldSeparator = { true }
|
||||
i18nLabel = 'profileModal.email' >
|
||||
<TextInput
|
||||
onChangeText = { this._onChangeEmail }
|
||||
onEndEditing = { this._onSaveEmail }
|
||||
placeholder = 'email@example.com'
|
||||
value = { this.state.email } />
|
||||
</FormRow>
|
||||
<FormRow
|
||||
fieldSeparator = { true }
|
||||
i18nLabel = 'profileModal.startWithAudioMuted' >
|
||||
<Switch
|
||||
onValueChange = {
|
||||
this._onStartAudioMutedChange
|
||||
}
|
||||
value = { this.state.startWithAudioMuted } />
|
||||
</FormRow>
|
||||
<FormRow
|
||||
i18nLabel = 'profileModal.startWithVideoMuted' >
|
||||
<Switch
|
||||
onValueChange = {
|
||||
this._onStartVideoMutedChange
|
||||
}
|
||||
value = { this.state.startWithVideoMuted } />
|
||||
</FormRow>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect(_mapStateToProps)(AppSettings));
|
|
@ -0,0 +1,138 @@
|
|||
/* @flow */
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import {
|
||||
Text,
|
||||
View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import styles, { ANDROID_UNDERLINE_COLOR } from './styles';
|
||||
|
||||
import { translate } from '../../base/i18n';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link FormRow}
|
||||
*/
|
||||
type Props = {
|
||||
|
||||
/**
|
||||
*/
|
||||
children: Object,
|
||||
|
||||
/**
|
||||
* Prop to decide if a row separator is to be rendered.
|
||||
*/
|
||||
fieldSeparator: boolean,
|
||||
|
||||
/**
|
||||
* The i18n key of the text label of the form field.
|
||||
*/
|
||||
i18nLabel: string,
|
||||
|
||||
/**
|
||||
* Invoked to obtain translated strings.
|
||||
*/
|
||||
t: Function
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Props> {
|
||||
|
||||
/**
|
||||
* Initializes a new {@code FormRow} instance.
|
||||
*
|
||||
* @param {Object} props - Component properties.
|
||||
*/
|
||||
constructor(props) {
|
||||
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}
|
||||
*/
|
||||
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 } >
|
||||
<Text style = { styles.text } >
|
||||
{ t(this.props.i18nLabel) }
|
||||
</Text>
|
||||
</View>
|
||||
<View style = { styles.fieldValueContainer } >
|
||||
{ newChild }
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
_getDefaultFieldProps: (field: Component<*, *>) => Object;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @private
|
||||
* @param {Object} field - The field (child) component.
|
||||
* @returns {Object}
|
||||
*/
|
||||
_getDefaultFieldProps(field: Object) {
|
||||
if (field && field.type) {
|
||||
switch (field.type.displayName) {
|
||||
case 'TextInput':
|
||||
return {
|
||||
style: styles.textInputField,
|
||||
underlineColorAndroid: ANDROID_UNDERLINE_COLOR
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
_getRowStyle: () => Array<Object>;
|
||||
|
||||
/**
|
||||
* Assembles the row style array based on the row's props.
|
||||
*
|
||||
* @private
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
_getRowStyle() {
|
||||
const rowStyle = [
|
||||
styles.fieldContainer
|
||||
];
|
||||
|
||||
if (this.props.fieldSeparator) {
|
||||
rowStyle.push(styles.fieldSeparator);
|
||||
}
|
||||
|
||||
return rowStyle;
|
||||
}
|
||||
}
|
||||
|
||||
export default translate(connect()(FormRow));
|
|
@ -0,0 +1 @@
|
|||
export { default as AppSettings } from './AppSettings';
|
|
@ -0,0 +1,98 @@
|
|||
import {
|
||||
BoxModel,
|
||||
ColorPalette,
|
||||
createStyleSheet
|
||||
} from '../../base/styles';
|
||||
|
||||
const LABEL_TAB = 300;
|
||||
|
||||
export const ANDROID_UNDERLINE_COLOR = 'transparent';
|
||||
|
||||
/**
|
||||
* The styles of the React {@code Components} of the feature welcome including
|
||||
* {@code WelcomePage} and {@code BlankPage}.
|
||||
*/
|
||||
export default createStyleSheet({
|
||||
|
||||
/**
|
||||
* Standardized style for a field container {@code View}.
|
||||
*/
|
||||
fieldContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
minHeight: 65
|
||||
},
|
||||
|
||||
/**
|
||||
* Standard container for a {@code View} containing a field label.
|
||||
*/
|
||||
fieldLabelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: LABEL_TAB
|
||||
},
|
||||
|
||||
/**
|
||||
* Field container style for all but last row {@code View}.
|
||||
*/
|
||||
fieldSeparator: {
|
||||
borderBottomWidth: 1
|
||||
},
|
||||
|
||||
/**
|
||||
* Style for the {@code View} containing each
|
||||
* field values (the actual field).
|
||||
*/
|
||||
fieldValueContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* Page header {@code View}.
|
||||
*/
|
||||
headerContainer: {
|
||||
backgroundColor: ColorPalette.blue,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 2 * BoxModel.margin
|
||||
},
|
||||
|
||||
/**
|
||||
* The title {@code Text} of the header.
|
||||
*/
|
||||
headerTitle: {
|
||||
color: ColorPalette.white,
|
||||
fontSize: 25
|
||||
},
|
||||
|
||||
/**
|
||||
* The top level container {@code View}.
|
||||
*/
|
||||
settingsContainer: {
|
||||
backgroundColor: ColorPalette.white,
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
margin: 0,
|
||||
padding: 2 * BoxModel.padding
|
||||
},
|
||||
|
||||
/**
|
||||
* Global {@code Text} color for the page.
|
||||
*/
|
||||
text: {
|
||||
color: ColorPalette.black,
|
||||
fontSize: 20
|
||||
},
|
||||
|
||||
/**
|
||||
* Standard text input field style.
|
||||
*/
|
||||
textInputField: {
|
||||
fontSize: 20,
|
||||
flex: 1,
|
||||
textAlign: 'right'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
export * from './actions';
|
||||
export * from './components';
|
||||
export * from './functions';
|
||||
|
||||
import './reducer';
|
|
@ -0,0 +1,31 @@
|
|||
// @flow
|
||||
|
||||
import {
|
||||
HIDE_APP_SETTINGS,
|
||||
SHOW_APP_SETTINGS
|
||||
} from './actionTypes';
|
||||
|
||||
import { ReducerRegistry } from '../base/redux';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
visible: false
|
||||
};
|
||||
|
||||
ReducerRegistry.register(
|
||||
'features/app-settings', (state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case HIDE_APP_SETTINGS:
|
||||
return {
|
||||
...state,
|
||||
visible: false
|
||||
};
|
||||
|
||||
case SHOW_APP_SETTINGS:
|
||||
return {
|
||||
...state,
|
||||
visible: true
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
|
@ -4,6 +4,7 @@ import { setRoom } from '../base/conference';
|
|||
import { configWillLoad, loadConfigError, setConfig } from '../base/config';
|
||||
import { setLocationURL } from '../base/connection';
|
||||
import { loadConfig } from '../base/lib-jitsi-meet';
|
||||
import { getProfile } from '../base/profile';
|
||||
import { parseURIString } from '../base/util';
|
||||
|
||||
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from './actionTypes';
|
||||
|
@ -82,7 +83,11 @@ function _appNavigateToMandatoryLocation(
|
|||
});
|
||||
}
|
||||
|
||||
return promise.then(() => dispatch(setConfig(config)));
|
||||
const profile = getProfile(getState());
|
||||
|
||||
return promise.then(() => dispatch(setConfig(
|
||||
_mergeConfigWithProfile(config, profile)
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -245,3 +250,23 @@ function _loadConfig({ contextRoot, host, protocol, room }) {
|
|||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the downloaded config with the current profile values. The profile
|
||||
* values are named the same way as the config values in the config.js so
|
||||
* a clean merge is possible.
|
||||
*
|
||||
* @param {Object|undefined} config - The downloaded config.
|
||||
* @param {Object} profile - The persisted profile.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _mergeConfigWithProfile(config, profile) {
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
...profile
|
||||
};
|
||||
}
|
||||
|
|
|
@ -13,7 +13,12 @@ import {
|
|||
localParticipantLeft
|
||||
} from '../../base/participants';
|
||||
import { Fragment, RouteRegistry } from '../../base/react';
|
||||
import { MiddlewareRegistry, ReducerRegistry } from '../../base/redux';
|
||||
import {
|
||||
getPersistedState,
|
||||
MiddlewareRegistry,
|
||||
ReducerRegistry
|
||||
} from '../../base/redux';
|
||||
import { getProfile } from '../../base/profile';
|
||||
import { toURLString } from '../../base/util';
|
||||
import { OverlayContainer } from '../../overlay';
|
||||
import { BlankPage } from '../../welcome';
|
||||
|
@ -72,6 +77,7 @@ export class AbstractApp extends Component {
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
|
||||
/**
|
||||
* The Route rendered by this {@code AbstractApp}.
|
||||
*
|
||||
|
@ -79,13 +85,35 @@ export class AbstractApp extends Component {
|
|||
*/
|
||||
route: undefined,
|
||||
|
||||
/**
|
||||
* The state of the »possible« async initialization of
|
||||
* the {@code AbstractApp}.
|
||||
*/
|
||||
appAsyncInitialized: false,
|
||||
|
||||
/**
|
||||
* The redux store used by this {@code AbstractApp}.
|
||||
*
|
||||
* @type {Store}
|
||||
*/
|
||||
store: this._maybeCreateStore(props)
|
||||
store: undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* This way we make the mobile version wait until the
|
||||
* {@code AsyncStorage} implementation of {@code Storage}
|
||||
* properly initializes. On web it does actually nothing, see
|
||||
* {@link #_initStorage}.
|
||||
*/
|
||||
this.init = new Promise(resolve => {
|
||||
this._initStorage().then(() => {
|
||||
this.setState({
|
||||
route: undefined,
|
||||
store: this._maybeCreateStore(props)
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -95,29 +123,48 @@ export class AbstractApp extends Component {
|
|||
* @inheritdoc
|
||||
*/
|
||||
componentWillMount() {
|
||||
const { dispatch } = this._getStore();
|
||||
this.init.then(() => {
|
||||
const { dispatch } = this._getStore();
|
||||
|
||||
dispatch(appWillMount(this));
|
||||
dispatch(appWillMount(this));
|
||||
|
||||
// FIXME I believe it makes more sense for a middleware to dispatch
|
||||
// localParticipantJoined on APP_WILL_MOUNT because the order of actions
|
||||
// is important, not the call site. Moreover, we've got localParticipant
|
||||
// business logic in the React Component (i.e. UI) AbstractApp now.
|
||||
let localParticipant;
|
||||
// FIXME I believe it makes more sense for a middleware to dispatch
|
||||
// localParticipantJoined on APP_WILL_MOUNT because the order of
|
||||
// actions is important, not the call site. Moreover, we've got
|
||||
// localParticipant business logic in the React Component
|
||||
// (i.e. UI) AbstractApp now.
|
||||
let localParticipant = {};
|
||||
|
||||
if (typeof APP === 'object') {
|
||||
localParticipant = {
|
||||
avatarID: APP.settings.getAvatarId(),
|
||||
avatarURL: APP.settings.getAvatarUrl(),
|
||||
email: APP.settings.getEmail(),
|
||||
name: APP.settings.getDisplayName()
|
||||
};
|
||||
}
|
||||
dispatch(localParticipantJoined(localParticipant));
|
||||
if (typeof APP === 'object') {
|
||||
localParticipant = {
|
||||
avatarID: APP.settings.getAvatarId(),
|
||||
avatarURL: APP.settings.getAvatarUrl(),
|
||||
email: APP.settings.getEmail(),
|
||||
name: APP.settings.getDisplayName()
|
||||
};
|
||||
}
|
||||
|
||||
// If a URL was explicitly specified to this React Component, then open
|
||||
// it; otherwise, use a default.
|
||||
this._openURL(toURLString(this.props.url) || this._getDefaultURL());
|
||||
// Profile is the new React compatible settings.
|
||||
const profile = getProfile(this._getStore().getState());
|
||||
|
||||
Object.assign(localParticipant, {
|
||||
email: profile.email,
|
||||
name: profile.displayName
|
||||
});
|
||||
|
||||
// We set the initialized state here and not in the contructor to
|
||||
// make sure that {@code componentWillMount} gets invoked before
|
||||
// the app tries to render the actual app content.
|
||||
this.setState({
|
||||
appAsyncInitialized: true
|
||||
});
|
||||
|
||||
dispatch(localParticipantJoined(localParticipant));
|
||||
|
||||
// If a URL was explicitly specified to this React Component,
|
||||
// then open it; otherwise, use a default.
|
||||
this._openURL(toURLString(this.props.url) || this._getDefaultURL());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -130,32 +177,34 @@ export class AbstractApp extends Component {
|
|||
* @returns {void}
|
||||
*/
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// The consumer of this AbstractApp did not provide a redux store.
|
||||
if (typeof nextProps.store === 'undefined'
|
||||
this.init.then(() => {
|
||||
// The consumer of this AbstractApp did not provide a redux store.
|
||||
if (typeof nextProps.store === 'undefined'
|
||||
|
||||
// The consumer of this AbstractApp did provide a redux store
|
||||
// before. Which means that the consumer changed their mind. In
|
||||
// such a case this instance should create its own internal
|
||||
// redux store. If the consumer did not provide a redux store
|
||||
// before, then this instance is using its own internal redux
|
||||
// store already.
|
||||
&& typeof this.props.store !== 'undefined') {
|
||||
this.setState({
|
||||
store: this._maybeCreateStore(nextProps)
|
||||
});
|
||||
}
|
||||
// The consumer of this AbstractApp did provide a redux
|
||||
// store before. Which means that the consumer changed
|
||||
// their mind. In such a case this instance should create
|
||||
// its own internal redux store. If the consumer did not
|
||||
// provide a redux store before, then this instance is
|
||||
// using its own internal redux store already.
|
||||
&& typeof this.props.store !== 'undefined') {
|
||||
this.setState({
|
||||
store: this._maybeCreateStore(nextProps)
|
||||
});
|
||||
}
|
||||
|
||||
// Deal with URL changes.
|
||||
let { url } = nextProps;
|
||||
// Deal with URL changes.
|
||||
let { url } = nextProps;
|
||||
|
||||
url = toURLString(url);
|
||||
if (toURLString(this.props.url) !== url
|
||||
url = toURLString(url);
|
||||
if (toURLString(this.props.url) !== url
|
||||
|
||||
// XXX Refer to the implementation of loadURLObject: in
|
||||
// ios/sdk/src/JitsiMeetView.m for further information.
|
||||
|| this.props.timestamp !== nextProps.timestamp) {
|
||||
this._openURL(url || this._getDefaultURL());
|
||||
}
|
||||
// XXX Refer to the implementation of loadURLObject: in
|
||||
// ios/sdk/src/JitsiMeetView.m for further information.
|
||||
|| this.props.timestamp !== nextProps.timestamp) {
|
||||
this._openURL(url || this._getDefaultURL());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -188,6 +237,23 @@ export class AbstractApp extends Component {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays app start until the {@code Storage} implementation initialises.
|
||||
* This is instantaneous on web, but is async on mobile.
|
||||
*
|
||||
* @private
|
||||
* @returns {ReactElement}
|
||||
*/
|
||||
_initStorage() {
|
||||
return new Promise(resolve => {
|
||||
if (window.localStorage._initializing) {
|
||||
window.localStorage._inited.then(resolve);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements React's {@link Component#render()}.
|
||||
*
|
||||
|
@ -195,10 +261,10 @@ export class AbstractApp extends Component {
|
|||
* @returns {ReactElement}
|
||||
*/
|
||||
render() {
|
||||
const { route } = this.state;
|
||||
const { appAsyncInitialized, route } = this.state;
|
||||
const component = (route && route.component) || BlankPage;
|
||||
|
||||
if (component) {
|
||||
if (appAsyncInitialized && component) {
|
||||
return (
|
||||
<I18nextProvider i18n = { i18next }>
|
||||
<Provider store = { this._getStore() }>
|
||||
|
@ -281,7 +347,7 @@ export class AbstractApp extends Component {
|
|||
middleware = compose(middleware, devToolsExtension());
|
||||
}
|
||||
|
||||
return createStore(reducer, middleware);
|
||||
return createStore(reducer, getPersistedState(), middleware);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -305,7 +371,11 @@ export class AbstractApp extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
return this.props.defaultURL || DEFAULT_URL;
|
||||
const profileDefaultURL = getProfile(
|
||||
this._getStore().getState()
|
||||
).defaultURL;
|
||||
|
||||
return this.props.defaultURL || profileDefaultURL || DEFAULT_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -33,34 +33,53 @@ export default class Storage {
|
|||
if (typeof this._keyPrefix !== 'undefined') {
|
||||
// Load all previously persisted data items from React Native's
|
||||
// AsyncStorage.
|
||||
AsyncStorage.getAllKeys().then((...getAllKeysCallbackArgs) => {
|
||||
// XXX The keys argument of getAllKeys' callback may or may not
|
||||
// be preceded by an error argument.
|
||||
const keys
|
||||
= getAllKeysCallbackArgs[getAllKeysCallbackArgs.length - 1]
|
||||
.filter(key => key.startsWith(this._keyPrefix));
|
||||
|
||||
AsyncStorage.multiGet(keys).then((...multiGetCallbackArgs) => {
|
||||
// XXX The result argument of multiGet may or may not be
|
||||
// preceded by an errors argument.
|
||||
const result
|
||||
= multiGetCallbackArgs[multiGetCallbackArgs.length - 1];
|
||||
const keyPrefixLength
|
||||
= this._keyPrefix && this._keyPrefix.length;
|
||||
/**
|
||||
* A flag to indicate that the async {@code AsyncStorage} is not
|
||||
* initialized yet. This is native specific but it will work
|
||||
* fine on web as well, as it will have no value (== false) there.
|
||||
* This is required to be available as we need a sync way to check
|
||||
* if the storage is inited or not.
|
||||
*/
|
||||
this._initializing = true;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
for (let [ key, value ] of result) {
|
||||
key = key.substring(keyPrefixLength);
|
||||
this._inited = new Promise(resolve => {
|
||||
AsyncStorage.getAllKeys().then((...getAllKeysCallbackArgs) => {
|
||||
// XXX The keys argument of getAllKeys' callback may
|
||||
// or may not be preceded by an error argument.
|
||||
const keys
|
||||
= getAllKeysCallbackArgs[
|
||||
getAllKeysCallbackArgs.length - 1
|
||||
].filter(key => key.startsWith(this._keyPrefix));
|
||||
|
||||
// XXX The loading of the previously persisted data
|
||||
// items from AsyncStorage is asynchronous which means
|
||||
// that it is technically possible to invoke setItem
|
||||
// with a key before the key is loaded from
|
||||
// AsyncStorage.
|
||||
if (!this.hasOwnProperty(key)) {
|
||||
this[key] = value;
|
||||
AsyncStorage.multiGet(keys)
|
||||
.then((...multiGetCallbackArgs) => {
|
||||
// XXX The result argument of multiGet may or may not be
|
||||
// preceded by an errors argument.
|
||||
const result
|
||||
= multiGetCallbackArgs[
|
||||
multiGetCallbackArgs.length - 1
|
||||
];
|
||||
const keyPrefixLength
|
||||
= this._keyPrefix && this._keyPrefix.length;
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
for (let [ key, value ] of result) {
|
||||
key = key.substring(keyPrefixLength);
|
||||
|
||||
// XXX The loading of the previously persisted data
|
||||
// items from AsyncStorage is asynchronous which
|
||||
// means that it is technically possible to invoke
|
||||
// setItem with a key before the key is loaded from
|
||||
// AsyncStorage.
|
||||
if (!this.hasOwnProperty(key)) {
|
||||
this[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._initializing = false;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Create an action for when the local profile is updated.
|
||||
*
|
||||
* {
|
||||
* type: PROFILE_UPDATED,
|
||||
* profile: {
|
||||
* displayName: string,
|
||||
* defaultURL: URL,
|
||||
* email: string,
|
||||
* startWithAudioMuted: boolean,
|
||||
* startWithVideoMuted: boolean
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export const PROFILE_UPDATED = Symbol('PROFILE_UPDATED');
|
|
@ -0,0 +1,23 @@
|
|||
import { PROFILE_UPDATED } from './actionTypes';
|
||||
|
||||
/**
|
||||
* Create an action for when the local profile is updated.
|
||||
*
|
||||
* @param {Object} profile - The new profile data.
|
||||
* @returns {{
|
||||
* type: UPDATE_PROFILE,
|
||||
* profile: {
|
||||
* displayName: string,
|
||||
* defaultURL: URL,
|
||||
* email: string,
|
||||
* startWithAudioMuted: boolean,
|
||||
* startWithVideoMuted: boolean
|
||||
* }
|
||||
* }}
|
||||
*/
|
||||
export function updateProfile(profile) {
|
||||
return {
|
||||
type: PROFILE_UPDATED,
|
||||
profile
|
||||
};
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/* @flow */
|
||||
|
||||
/**
|
||||
* Retreives the current profile settings from redux store. The profile
|
||||
* is persisted to localStorage so it's a good candidate to store settings
|
||||
* in it.
|
||||
*
|
||||
* @param {Object} state - The Redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getProfile(state: Object) {
|
||||
const profileStateSlice = state['features/base/profile'];
|
||||
|
||||
return profileStateSlice ? profileStateSlice.profile || {} : {};
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export * from './actions';
|
||||
export * from './functions';
|
||||
|
||||
import './middleware';
|
||||
import './reducer';
|
|
@ -0,0 +1,43 @@
|
|||
/* @flow */
|
||||
import { PROFILE_UPDATED } from './actionTypes';
|
||||
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
|
||||
|
||||
import { participantUpdated } from '../participants';
|
||||
import { getProfile } from '../profile';
|
||||
import { toState } from '../redux';
|
||||
|
||||
/**
|
||||
* A MiddleWare to update the local participant when the profile
|
||||
* is updated.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
switch (action.type) {
|
||||
case PROFILE_UPDATED:
|
||||
_updateLocalParticipant(store);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* Updates the local participant according to profile changes.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {void}
|
||||
*/
|
||||
function _updateLocalParticipant(store) {
|
||||
const profile = getProfile(toState(store));
|
||||
|
||||
const newLocalParticipant = {
|
||||
email: profile.email,
|
||||
local: true,
|
||||
name: profile.displayName
|
||||
};
|
||||
|
||||
store.dispatch(participantUpdated(newLocalParticipant));
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
// @flow
|
||||
|
||||
import {
|
||||
PROFILE_UPDATED
|
||||
} from './actionTypes';
|
||||
|
||||
import { ReducerRegistry } from '../redux';
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
profile: {}
|
||||
};
|
||||
|
||||
const STORE_NAME = 'features/base/profile';
|
||||
|
||||
ReducerRegistry.register(
|
||||
STORE_NAME, (state = DEFAULT_STATE, action) => {
|
||||
switch (action.type) {
|
||||
case PROFILE_UPDATED:
|
||||
return {
|
||||
profile: action.profile
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
|
@ -1,6 +1,12 @@
|
|||
/* @flow */
|
||||
|
||||
import _ from 'lodash';
|
||||
import Logger from 'jitsi-meet-logger';
|
||||
|
||||
import persisterConfig from './persisterconfig.json';
|
||||
|
||||
const logger = Logger.getLogger(__filename);
|
||||
const PERSISTED_STATE_NAME = 'jitsi-state';
|
||||
|
||||
/**
|
||||
* Sets specific properties of a specific state to specific values and prevents
|
||||
|
@ -38,6 +44,93 @@ export function equals(a: any, b: any) {
|
|||
return _.isEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a filtered state-slice (Redux term) based on the config for
|
||||
* persisting or for retreival.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} persistedSlice - The redux state-slice.
|
||||
* @param {Object} persistedSliceConfig - The related config sub-tree.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _getFilteredSlice(persistedSlice, persistedSliceConfig) {
|
||||
const filteredpersistedSlice = {};
|
||||
|
||||
for (const persistedKey of Object.keys(persistedSlice)) {
|
||||
if (persistedSliceConfig[persistedKey]) {
|
||||
filteredpersistedSlice[persistedKey] = persistedSlice[persistedKey];
|
||||
}
|
||||
}
|
||||
|
||||
return filteredpersistedSlice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares a filtered state from the actual or the
|
||||
* persisted Redux state, based on the config.
|
||||
*
|
||||
* @private
|
||||
* @param {Object} state - The actual or persisted redux state.
|
||||
* @returns {Object}
|
||||
*/
|
||||
function _getFilteredState(state: Object) {
|
||||
const filteredState = {};
|
||||
|
||||
for (const slice of Object.keys(persisterConfig)) {
|
||||
filteredState[slice] = _getFilteredSlice(
|
||||
state[slice],
|
||||
persisterConfig[slice]
|
||||
);
|
||||
}
|
||||
|
||||
return filteredState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persisted redux state. This function takes
|
||||
* the persisterConfig into account as we may have persisted something
|
||||
* in the past that we don't want to retreive anymore. The next
|
||||
* {@link #persistState} will remove those values.
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function getPersistedState() {
|
||||
let persistedState = window.localStorage.getItem(PERSISTED_STATE_NAME);
|
||||
|
||||
if (persistedState) {
|
||||
try {
|
||||
persistedState = JSON.parse(persistedState);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const filteredPersistedState = _getFilteredState(persistedState);
|
||||
|
||||
logger.info('Redux state rehydrated', filteredPersistedState);
|
||||
|
||||
return filteredPersistedState;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a filtered subtree of the redux state into {@code localStorage}.
|
||||
*
|
||||
* @param {Object} state - The redux state.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function persistState(state: Object) {
|
||||
const filteredState = _getFilteredState(state);
|
||||
|
||||
window.localStorage.setItem(
|
||||
PERSISTED_STATE_NAME,
|
||||
JSON.stringify(filteredState)
|
||||
);
|
||||
|
||||
logger.info('Redux state persisted');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a specific property of a specific state to a specific value. Prevents
|
||||
* unnecessary state changes (when the specified {@code value} is equal to the
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
export * from './functions';
|
||||
export { default as MiddlewareRegistry } from './MiddlewareRegistry';
|
||||
export { default as ReducerRegistry } from './ReducerRegistry';
|
||||
|
||||
import './middleware';
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
/* @flow */
|
||||
import _ from 'lodash';
|
||||
|
||||
import { persistState } from './functions';
|
||||
import MiddlewareRegistry from './MiddlewareRegistry';
|
||||
|
||||
import { toState } from '../redux';
|
||||
|
||||
/**
|
||||
* The delay that passes between the last state change and the state to be
|
||||
* persisted in the storage.
|
||||
*/
|
||||
const PERSIST_DELAY = 2000;
|
||||
|
||||
/**
|
||||
* A throttled function to avoid repetitive state persisting.
|
||||
*/
|
||||
const throttledFunc = _.throttle(state => {
|
||||
persistState(state);
|
||||
}, PERSIST_DELAY);
|
||||
|
||||
/**
|
||||
* A master MiddleWare to selectively persist state. Please use the
|
||||
* {@link persisterconfig.json} to set which subtrees of the Redux state
|
||||
* should be persisted.
|
||||
*
|
||||
* @param {Store} store - The redux store.
|
||||
* @returns {Function}
|
||||
*/
|
||||
MiddlewareRegistry.register(store => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
throttledFunc(toState(store));
|
||||
|
||||
return result;
|
||||
});
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"features/base/profile": {
|
||||
"profile": true
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
Jitsi Meet - redux state persistency
|
||||
====================================
|
||||
Jitsi Meet has a persistency layer that persist a subtree (or specific subtrees) into window.localStorage (on web) or
|
||||
AsyncStorage (on mobile).
|
||||
|
||||
Usage
|
||||
=====
|
||||
If a subtree of the redux store should be persisted (e.g. ``'features/base/participants'``), then persistency for that
|
||||
subtree should be enabled in the config file by creating a key in
|
||||
|
||||
```
|
||||
react/features/base/redux/persisterconfig.json
|
||||
```
|
||||
and defining all the fields of the subtree that has to be persisted, e.g.:
|
||||
```json
|
||||
{
|
||||
"features/base/participants": {
|
||||
"avatarID": true,
|
||||
"avatarURL": true,
|
||||
"name": true
|
||||
},
|
||||
"another/subtree": {
|
||||
"someField": true
|
||||
}
|
||||
}
|
||||
```
|
||||
When it's done, Jitsi Meet will persist these subtrees/fields and rehidrate them on startup.
|
||||
|
||||
Throttling
|
||||
==========
|
||||
To avoid too frequent write operations in the storage, we utilise throttling in the persistency layer, meaning that the storage
|
||||
gets persisted only once in every 2 seconds, even if multiple redux state changes occur during this period. This throttling timeout
|
||||
can be configured in
|
||||
```
|
||||
react/features/base/redux/middleware.js#PERSIST_DELAY
|
||||
```
|
|
@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
|
|||
import { Component } from 'react';
|
||||
|
||||
import { appNavigate } from '../../app';
|
||||
import { showAppSettings } from '../../app-settings';
|
||||
import { isRoomValid } from '../../base/conference';
|
||||
|
||||
import { generateRoomWithoutSeparator } from '../functions';
|
||||
|
@ -70,6 +71,7 @@ export class AbstractWelcomePage extends Component<*, *> {
|
|||
= this._animateRoomnameChanging.bind(this);
|
||||
this._onJoin = this._onJoin.bind(this);
|
||||
this._onRoomChange = this._onRoomChange.bind(this);
|
||||
this._onSettingsOpen = this._onSettingsOpen.bind(this);
|
||||
this._updateRoomname = this._updateRoomname.bind(this);
|
||||
}
|
||||
|
||||
|
@ -196,6 +198,18 @@ export class AbstractWelcomePage extends Component<*, *> {
|
|||
this.setState({ room: value });
|
||||
}
|
||||
|
||||
_onSettingsOpen: () => void;
|
||||
|
||||
/**
|
||||
* Sets the app settings modal visible.
|
||||
*
|
||||
* @protected
|
||||
* @returns {void}
|
||||
*/
|
||||
_onSettingsOpen() {
|
||||
this.props.dispatch(showAppSettings());
|
||||
}
|
||||
|
||||
_updateRoomname: () => void;
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,6 +2,8 @@ import React from 'react';
|
|||
import { TextInput, TouchableHighlight, View } from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { AppSettings } from '../../app-settings';
|
||||
import { Icon } from '../../base/font-icons';
|
||||
import { translate } from '../../base/i18n';
|
||||
import { MEDIA_TYPE } from '../../base/media';
|
||||
import { Link, LoadingIndicator, Text } from '../../base/react';
|
||||
|
@ -80,11 +82,23 @@ class WelcomePage extends AbstractWelcomePage {
|
|||
style = { styles.textInput }
|
||||
underlineColorAndroid = 'transparent'
|
||||
value = { this.state.room } />
|
||||
{
|
||||
this._renderJoinButton()
|
||||
}
|
||||
<View style = { styles.buttonRow }>
|
||||
<TouchableHighlight
|
||||
accessibilityLabel = { 'Tap for Settings.' }
|
||||
onPress = { this._onSettingsOpen }
|
||||
style = { [ styles.button, styles.settingsButton ] }
|
||||
underlayColor = { ColorPalette.white }>
|
||||
<Icon
|
||||
name = 'settings'
|
||||
style = { styles.settingsIcon } />
|
||||
</TouchableHighlight>
|
||||
{
|
||||
this._renderJoinButton()
|
||||
}
|
||||
</View>
|
||||
<RecentList />
|
||||
</View>
|
||||
<AppSettings />
|
||||
{
|
||||
this._renderLegalese()
|
||||
}
|
||||
|
@ -127,7 +141,7 @@ class WelcomePage extends AbstractWelcomePage {
|
|||
accessibilityLabel = { 'Tap to Join.' }
|
||||
disabled = { this._isJoinDisabled() }
|
||||
onPress = { this._onJoin }
|
||||
style = { styles.button }
|
||||
style = { [ styles.button, styles.joinButton ] }
|
||||
underlayColor = { ColorPalette.white }>
|
||||
{
|
||||
children
|
||||
|
|
|
@ -37,6 +37,13 @@ export default createStyleSheet({
|
|||
marginTop: BoxModel.margin
|
||||
},
|
||||
|
||||
/**
|
||||
* Layout of the button container.
|
||||
*/
|
||||
buttonRow: {
|
||||
flexDirection: 'row'
|
||||
},
|
||||
|
||||
/**
|
||||
* Join button text style.
|
||||
*/
|
||||
|
@ -46,6 +53,13 @@ export default createStyleSheet({
|
|||
fontSize: 18
|
||||
},
|
||||
|
||||
/**
|
||||
* Style of the join button.
|
||||
*/
|
||||
joinButton: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
/**
|
||||
* The style of the legal-related content such as (hyper)links to Privacy
|
||||
* Policy and Terms of Service displayed on the WelcomePage.
|
||||
|
@ -111,6 +125,22 @@ export default createStyleSheet({
|
|||
marginTop: 5 * BoxModel.margin
|
||||
},
|
||||
|
||||
/**
|
||||
* Style of the settings button.
|
||||
*/
|
||||
settingsButton: {
|
||||
width: 65,
|
||||
marginRight: BoxModel.margin
|
||||
},
|
||||
|
||||
/**
|
||||
* Style of the settings icon on the settings button.
|
||||
*/
|
||||
settingsIcon: {
|
||||
fontSize: 24,
|
||||
alignSelf: 'center'
|
||||
},
|
||||
|
||||
/**
|
||||
* Room input style.
|
||||
*/
|
||||
|
|
Loading…
Reference in New Issue