[RN] Add app-settings feature

[RN] Fix PR feedbacks, write persistency docs
This commit is contained in:
zbettenbuk 2017-12-14 18:02:32 +01:00 committed by Paweł Domas
parent 871ef9ff0e
commit bfcd34358b
29 changed files with 1288 additions and 76 deletions

View File

@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { default as AppSettings } from './AppSettings';

View File

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

View File

View File

@ -0,0 +1,5 @@
export * from './actions';
export * from './components';
export * from './functions';
import './reducer';

View File

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

View File

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

View File

@ -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;
}
/**

View File

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

View File

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

View File

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

View File

@ -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 || {} : {};
}

View File

@ -0,0 +1,5 @@
export * from './actions';
export * from './functions';
import './middleware';
import './reducer';

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
export * from './functions';
export { default as MiddlewareRegistry } from './MiddlewareRegistry';
export { default as ReducerRegistry } from './ReducerRegistry';
import './middleware';

View File

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

View File

@ -0,0 +1,5 @@
{
"features/base/profile": {
"profile": true
}
}

View File

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

View File

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

View File

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

View File

@ -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.
*/