diff --git a/lang/main.json b/lang/main.json index cf1393e190..69a97ac705 100644 --- a/lang/main.json +++ b/lang/main.json @@ -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" } } diff --git a/react/features/app-settings/actionTypes.js b/react/features/app-settings/actionTypes.js new file mode 100644 index 0000000000..08f2ce70c4 --- /dev/null +++ b/react/features/app-settings/actionTypes.js @@ -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'); diff --git a/react/features/app-settings/actions.js b/react/features/app-settings/actions.js new file mode 100644 index 0000000000..359b96cf1f --- /dev/null +++ b/react/features/app-settings/actions.js @@ -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 + }; +} diff --git a/react/features/app-settings/components/AbstractAppSettings.js b/react/features/app-settings/components/AbstractAppSettings.js new file mode 100644 index 0000000000..934c404baa --- /dev/null +++ b/react/features/app-settings/components/AbstractAppSettings.js @@ -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 { + + /** + * 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 + }; +} diff --git a/react/features/app-settings/components/AppSettings.native.js b/react/features/app-settings/components/AppSettings.native.js new file mode 100644 index 0000000000..ea21547d9f --- /dev/null +++ b/react/features/app-settings/components/AppSettings.native.js @@ -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 ( + + + + { t('profileModal.header') } + + + + + + + + + + + + + + + + + + + + + ); + } +} + +export default translate(connect(_mapStateToProps)(AppSettings)); diff --git a/react/features/app-settings/components/AppSettings.web.js b/react/features/app-settings/components/AppSettings.web.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/react/features/app-settings/components/FormRow.native.js b/react/features/app-settings/components/FormRow.native.js new file mode 100644 index 0000000000..0c41507929 --- /dev/null +++ b/react/features/app-settings/components/FormRow.native.js @@ -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 { + + /** + * 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 ( + + + + { t(this.props.i18nLabel) } + + + + { newChild } + + + ); + } + + _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; + + /** + * Assembles the row style array based on the row's props. + * + * @private + * @returns {Array} + */ + _getRowStyle() { + const rowStyle = [ + styles.fieldContainer + ]; + + if (this.props.fieldSeparator) { + rowStyle.push(styles.fieldSeparator); + } + + return rowStyle; + } +} + +export default translate(connect()(FormRow)); diff --git a/react/features/app-settings/components/index.js b/react/features/app-settings/components/index.js new file mode 100644 index 0000000000..3be0dae459 --- /dev/null +++ b/react/features/app-settings/components/index.js @@ -0,0 +1 @@ +export { default as AppSettings } from './AppSettings'; diff --git a/react/features/app-settings/components/styles.js b/react/features/app-settings/components/styles.js new file mode 100644 index 0000000000..a688b2c79d --- /dev/null +++ b/react/features/app-settings/components/styles.js @@ -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' + } +}); diff --git a/react/features/app-settings/functions.js b/react/features/app-settings/functions.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/react/features/app-settings/index.js b/react/features/app-settings/index.js new file mode 100644 index 0000000000..61438458ce --- /dev/null +++ b/react/features/app-settings/index.js @@ -0,0 +1,5 @@ +export * from './actions'; +export * from './components'; +export * from './functions'; + +import './reducer'; diff --git a/react/features/app-settings/reducer.js b/react/features/app-settings/reducer.js new file mode 100644 index 0000000000..62115d19c6 --- /dev/null +++ b/react/features/app-settings/reducer.js @@ -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; + }); diff --git a/react/features/app/actions.js b/react/features/app/actions.js index 2d34ef4388..537ff7c779 100644 --- a/react/features/app/actions.js +++ b/react/features/app/actions.js @@ -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 + }; +} diff --git a/react/features/app/components/AbstractApp.js b/react/features/app/components/AbstractApp.js index 029f14d3c9..df3809e48d 100644 --- a/react/features/app/components/AbstractApp.js +++ b/react/features/app/components/AbstractApp.js @@ -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 ( @@ -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; } /** diff --git a/react/features/base/lib-jitsi-meet/native/Storage.js b/react/features/base/lib-jitsi-meet/native/Storage.js index 333627da2f..f8398fb824 100644 --- a/react/features/base/lib-jitsi-meet/native/Storage.js +++ b/react/features/base/lib-jitsi-meet/native/Storage.js @@ -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(); + }); }); }); } diff --git a/react/features/base/profile/actionTypes.js b/react/features/base/profile/actionTypes.js new file mode 100644 index 0000000000..2301693e39 --- /dev/null +++ b/react/features/base/profile/actionTypes.js @@ -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'); diff --git a/react/features/base/profile/actions.js b/react/features/base/profile/actions.js new file mode 100644 index 0000000000..da7e1d60be --- /dev/null +++ b/react/features/base/profile/actions.js @@ -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 + }; +} diff --git a/react/features/base/profile/functions.js b/react/features/base/profile/functions.js new file mode 100644 index 0000000000..aa289e329c --- /dev/null +++ b/react/features/base/profile/functions.js @@ -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 || {} : {}; +} diff --git a/react/features/base/profile/index.js b/react/features/base/profile/index.js new file mode 100644 index 0000000000..eea8d73936 --- /dev/null +++ b/react/features/base/profile/index.js @@ -0,0 +1,5 @@ +export * from './actions'; +export * from './functions'; + +import './middleware'; +import './reducer'; diff --git a/react/features/base/profile/middleware.js b/react/features/base/profile/middleware.js new file mode 100644 index 0000000000..659268a0be --- /dev/null +++ b/react/features/base/profile/middleware.js @@ -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)); +} diff --git a/react/features/base/profile/reducer.js b/react/features/base/profile/reducer.js new file mode 100644 index 0000000000..2337146c1e --- /dev/null +++ b/react/features/base/profile/reducer.js @@ -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; + }); diff --git a/react/features/base/redux/functions.js b/react/features/base/redux/functions.js index 687eb06f42..b2d736a4ac 100644 --- a/react/features/base/redux/functions.js +++ b/react/features/base/redux/functions.js @@ -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 diff --git a/react/features/base/redux/index.js b/react/features/base/redux/index.js index 9658a87069..aa4e6ec3e3 100644 --- a/react/features/base/redux/index.js +++ b/react/features/base/redux/index.js @@ -1,3 +1,5 @@ export * from './functions'; export { default as MiddlewareRegistry } from './MiddlewareRegistry'; export { default as ReducerRegistry } from './ReducerRegistry'; + +import './middleware'; diff --git a/react/features/base/redux/middleware.js b/react/features/base/redux/middleware.js new file mode 100644 index 0000000000..fb2645669b --- /dev/null +++ b/react/features/base/redux/middleware.js @@ -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; +}); diff --git a/react/features/base/redux/persisterconfig.json b/react/features/base/redux/persisterconfig.json new file mode 100644 index 0000000000..f3cc178f10 --- /dev/null +++ b/react/features/base/redux/persisterconfig.json @@ -0,0 +1,5 @@ +{ + "features/base/profile": { + "profile": true + } +} diff --git a/react/features/base/redux/readme.md b/react/features/base/redux/readme.md new file mode 100644 index 0000000000..6abd64f703 --- /dev/null +++ b/react/features/base/redux/readme.md @@ -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 +``` diff --git a/react/features/welcome/components/AbstractWelcomePage.js b/react/features/welcome/components/AbstractWelcomePage.js index 27588f1a43..92f7abbfb8 100644 --- a/react/features/welcome/components/AbstractWelcomePage.js +++ b/react/features/welcome/components/AbstractWelcomePage.js @@ -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; /** diff --git a/react/features/welcome/components/WelcomePage.native.js b/react/features/welcome/components/WelcomePage.native.js index 80ecca0147..69e55fc248 100644 --- a/react/features/welcome/components/WelcomePage.native.js +++ b/react/features/welcome/components/WelcomePage.native.js @@ -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() - } + + + + + { + this._renderJoinButton() + } + + { 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 diff --git a/react/features/welcome/components/styles.js b/react/features/welcome/components/styles.js index d1babc6b74..0f11248321 100644 --- a/react/features/welcome/components/styles.js +++ b/react/features/welcome/components/styles.js @@ -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. */