[RN] Fix base/profile and recent-list bugs

This commit is contained in:
Lyubo Marinov 2018-02-27 14:21:28 -06:00
parent e4da0e988e
commit d727ee80b2
16 changed files with 364 additions and 329 deletions

View File

@ -4,7 +4,6 @@ import { setRoom } from '../base/conference';
import { configWillLoad, loadConfigError, setConfig } from '../base/config'; import { configWillLoad, loadConfigError, setConfig } from '../base/config';
import { setLocationURL } from '../base/connection'; import { setLocationURL } from '../base/connection';
import { loadConfig } from '../base/lib-jitsi-meet'; import { loadConfig } from '../base/lib-jitsi-meet';
import { getProfile } from '../base/profile';
import { parseURIString } from '../base/util'; import { parseURIString } from '../base/util';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from './actionTypes'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from './actionTypes';
@ -15,7 +14,7 @@ declare var APP: Object;
* Triggers an in-app navigation to a specific route. Allows navigation to be * Triggers an in-app navigation to a specific route. Allows navigation to be
* abstracted between the mobile/React Native and Web/React applications. * abstracted between the mobile/React Native and Web/React applications.
* *
* @param {(string|undefined)} uri - The URI to which to navigate. It may be a * @param {string|undefined} uri - The URI to which to navigate. It may be a
* full URL with an HTTP(S) scheme, a full or partial URI with the app-specific * full URL with an HTTP(S) scheme, a full or partial URI with the app-specific
* scheme, or a mere room name. * scheme, or a mere room name.
* @returns {Function} * @returns {Function}
@ -83,11 +82,10 @@ function _appNavigateToMandatoryLocation(
}); });
} }
const profile = getProfile(getState()); const profile = getState()['features/base/profile'];
return promise.then(() => dispatch(setConfig( return promise.then(() =>
_mergeConfigWithProfile(config, profile) dispatch(setConfig(_mergeConfigWithProfile(config, profile))));
)));
} }
} }

View File

@ -12,7 +12,7 @@ import {
localParticipantJoined, localParticipantJoined,
localParticipantLeft localParticipantLeft
} from '../../base/participants'; } from '../../base/participants';
import { getProfile } from '../../base/profile'; import '../../base/profile';
import { Fragment, RouteRegistry } from '../../base/react'; import { Fragment, RouteRegistry } from '../../base/react';
import { MiddlewareRegistry, ReducerRegistry } from '../../base/redux'; import { MiddlewareRegistry, ReducerRegistry } from '../../base/redux';
import { PersistenceRegistry } from '../../base/storage'; import { PersistenceRegistry } from '../../base/storage';
@ -123,7 +123,7 @@ export class AbstractApp extends Component {
*/ */
componentWillMount() { componentWillMount() {
this._init.then(() => { this._init.then(() => {
const { dispatch } = this._getStore(); const { dispatch, getState } = this._getStore();
dispatch(appWillMount(this)); dispatch(appWillMount(this));
@ -144,7 +144,7 @@ export class AbstractApp extends Component {
} }
// Profile is the new React compatible settings. // Profile is the new React compatible settings.
const profile = getProfile(this._getStore().getState()); const profile = getState()['features/base/profile'];
if (profile) { if (profile) {
localParticipant.email localParticipant.email
@ -381,7 +381,8 @@ export class AbstractApp extends Component {
return ( return (
this.props.defaultURL this.props.defaultURL
|| getProfile(this._getStore().getState()).serverURL || this._getStore().getState()['features/base/profile']
.serverURL
|| DEFAULT_URL); || DEFAULT_URL);
} }

View File

@ -128,7 +128,7 @@ function _conferenceFailedOrLeft({ dispatch, getState }, next, action) {
const state = getState(); const state = getState();
const { audioOnly } = state['features/base/conference']; const { audioOnly } = state['features/base/conference'];
const { startAudioOnly } = state['features/base/profile'].profile; const { startAudioOnly } = state['features/base/profile'];
// FIXME: Consider implementing a standalone audio-only feature that handles // FIXME: Consider implementing a standalone audio-only feature that handles
// all these state changes. // all these state changes.

View File

@ -1,15 +0,0 @@
/* @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 || {};
}

View File

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

View File

@ -2,7 +2,6 @@
import { setAudioOnly } from '../conference'; import { setAudioOnly } from '../conference';
import { getLocalParticipant, participantUpdated } from '../participants'; import { getLocalParticipant, participantUpdated } from '../participants';
import { getProfile } from '../profile';
import { MiddlewareRegistry, toState } from '../redux'; import { MiddlewareRegistry, toState } from '../redux';
import { PROFILE_UPDATED } from './actionTypes'; import { PROFILE_UPDATED } from './actionTypes';
@ -33,11 +32,11 @@ MiddlewareRegistry.register(store => next => action => {
* @param {Object} action - The redux action. * @param {Object} action - The redux action.
* @returns {void} * @returns {void}
*/ */
function _maybeUpdateStartAudioOnly(store, action) { function _maybeUpdateStartAudioOnly(
const { profile } = action; { dispatch },
{ profile: { startAudioOnly } }) {
if (typeof profile.startAudioOnly === 'boolean') { if (typeof startAudioOnly === 'boolean') {
store.dispatch(setAudioOnly(profile.startAudioOnly)); dispatch(setAudioOnly(startAudioOnly));
} }
} }
@ -50,7 +49,7 @@ function _maybeUpdateStartAudioOnly(store, action) {
function _updateLocalParticipant(store) { function _updateLocalParticipant(store) {
const state = toState(store); const state = toState(store);
const localParticipant = getLocalParticipant(state); const localParticipant = getLocalParticipant(state);
const profile = getProfile(state); const profile = state['features/base/profile'];
store.dispatch(participantUpdated({ store.dispatch(participantUpdated({
// Identify that the participant to update i.e. the local participant: // Identify that the participant to update i.e. the local participant:

View File

@ -1,26 +1,53 @@
// @flow // @flow
import { APP_WILL_MOUNT } from '../../app';
import { ReducerRegistry } from '../redux'; import { ReducerRegistry } from '../redux';
import { PersistenceRegistry } from '../storage'; import { PersistenceRegistry } from '../storage';
import { PROFILE_UPDATED } from './actionTypes'; import { PROFILE_UPDATED } from './actionTypes';
/**
* The default/initial redux state of the feature {@code base/profile}.
*
* @type Object
*/
const DEFAULT_STATE = {};
const STORE_NAME = 'features/base/profile'; const STORE_NAME = 'features/base/profile';
/** /**
* Sets up the persistence of the feature base/profile. * Sets up the persistence of the feature {@code base/profile}.
*/ */
PersistenceRegistry.register(STORE_NAME); PersistenceRegistry.register(STORE_NAME);
ReducerRegistry.register( ReducerRegistry.register(STORE_NAME, (state = DEFAULT_STATE, action) => {
STORE_NAME, (state = {}, action) => { switch (action.type) {
switch (action.type) { case APP_WILL_MOUNT:
case PROFILE_UPDATED: // XXX APP_WILL_MOUNT is the earliest redux action of ours dispatched in
return { // the store. For the purposes of legacy support, make sure that the
...state, // deserialized base/profile's state is in the format deemed current by
...action.profile // the current app revision.
}; if (state && typeof state === 'object') {
} // In an enterprise/internal build of Jitsi Meet for Android and iOS
// we had base/profile's state as an object with property profile.
const { profile } = state;
return state; if (profile && typeof profile === 'object') {
}); return { ...profile };
}
} else {
// In the weird case that we have previously persisted/serialized
// null.
return DEFAULT_STATE;
}
break;
case PROFILE_UPDATED:
return {
...state,
...action.profile
};
}
return state;
});

View File

@ -1,25 +1,23 @@
// @flow // @flow
import Logger from 'jitsi-meet-logger';
import md5 from 'js-md5'; import md5 from 'js-md5';
const logger = Logger.getLogger(__filename); const logger = require('jitsi-meet-logger').getLogger(__filename);
/** /**
* The name of the localStorage store where the app persists its values to. * The name of the {@code localStorage} store where the app persists its values.
*/ */
const PERSISTED_STATE_NAME = 'jitsi-state'; const PERSISTED_STATE_NAME = 'jitsi-state';
/** /**
* Mixed type of the element (subtree) config. If it's a boolean, * Mixed type of the element (subtree) config. If it's a {@code boolean} (and is
* (and is true) we persist the entire subtree. If it's an object, * {@code true}), we persist the entire subtree. If it's an {@code Object}, we
* we perist a filtered subtree based on the properties in the * perist a filtered subtree based on the properties of the config object.
* config object.
*/ */
declare type ElementConfig = Object | boolean; declare type ElementConfig = boolean | Object;
/** /**
* The type of the name-config pairs stored in this reducer. * The type of the name-config pairs stored in {@code PersistenceRegistry}.
*/ */
declare type PersistencyConfigMap = { [name: string]: ElementConfig }; declare type PersistencyConfigMap = { [name: string]: ElementConfig };
@ -30,74 +28,64 @@ declare type PersistencyConfigMap = { [name: string]: ElementConfig };
class PersistenceRegistry { class PersistenceRegistry {
_checksum: string; _checksum: string;
_elements: PersistencyConfigMap; _elements: PersistencyConfigMap = {};
/** /**
* Initializes a new {@ code PersistenceRegistry} instance. * Returns the persisted redux state. Takes the {@link #_elements} into
*/ * account as we may have persisted something in the past that we don't want
constructor() { * to retreive anymore. The next {@link #persistState} will remove such
this._elements = {}; * values.
}
/**
* Returns the persisted redux state. This function takes the
* {@link #_elements} 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} * @returns {Object}
*/ */
getPersistedState() { getPersistedState() {
let filteredPersistedState = {}; let filteredPersistedState = {};
let persistedState = window.localStorage.getItem(PERSISTED_STATE_NAME);
if (persistedState) { // localStorage key per feature
// This is the legacy implementation, for (const subtreeName of Object.keys(this._elements)) {
// must be removed in a later version. // Assumes that the persisted value is stored under the same key as
try { // the feature's redux state name.
persistedState = JSON.parse(persistedState); // TODO We'll need to introduce functions later that can control the
} catch (error) { // persist key's name. Similar to control serialization and
logger.error( // deserialization. But that should be a straightforward change.
'Error parsing persisted state', const persistedSubtree
persistedState, = this._getPersistedSubtree(
error); subtreeName,
persistedState = {}; this._elements[subtreeName]);
}
filteredPersistedState if (persistedSubtree !== undefined) {
= this._getFilteredState(persistedState); filteredPersistedState[subtreeName] = persistedSubtree;
// legacy values must be written to the new store format and
// old values to be deleted, so then it'll never be used again.
this.persistState(filteredPersistedState);
window.localStorage.removeItem(PERSISTED_STATE_NAME);
} else {
// new, split-keys implementation
for (const subtreeName of Object.keys(this._elements)) {
/*
* this assumes that the persisted value is stored under the
* same key as the feature's redux state name.
* We'll need to introduce functions later that can control
* the persist key's name. Similar to control serialization
* and deserialization.
* But that should be a straightforward change.
*/
const persistedSubtree
= this._getPersistedSubtree(
subtreeName,
this._elements[subtreeName]
);
if (persistedSubtree !== undefined) {
filteredPersistedState[subtreeName] = persistedSubtree;
}
} }
} }
// initialize checksum // legacy
if (Object.keys(filteredPersistedState).length === 0) {
const { localStorage } = window;
let persistedState = localStorage.getItem(PERSISTED_STATE_NAME);
if (persistedState) {
try {
persistedState = JSON.parse(persistedState);
} catch (error) {
logger.error(
'Error parsing persisted state',
persistedState,
error);
persistedState = {};
}
filteredPersistedState = this._getFilteredState(persistedState);
// Store into the new format and delete the old format so that
// it's not used again.
this.persistState(filteredPersistedState);
localStorage.removeItem(PERSISTED_STATE_NAME);
}
}
// Initialize the checksum.
this._checksum = this._calculateChecksum(filteredPersistedState); this._checksum = this._calculateChecksum(filteredPersistedState);
this._checksum = this._calculateChecksum(filteredPersistedState);
logger.info('redux state rehydrated as', filteredPersistedState); logger.info('redux state rehydrated as', filteredPersistedState);
return filteredPersistedState; return filteredPersistedState;
@ -112,26 +100,25 @@ class PersistenceRegistry {
*/ */
persistState(state: Object) { persistState(state: Object) {
const filteredState = this._getFilteredState(state); const filteredState = this._getFilteredState(state);
const newCheckSum = this._calculateChecksum(filteredState); const checksum = this._calculateChecksum(filteredState);
if (newCheckSum !== this._checksum) { if (checksum !== this._checksum) {
for (const subtreeName of Object.keys(filteredState)) { for (const subtreeName of Object.keys(filteredState)) {
try { try {
window.localStorage.setItem( window.localStorage.setItem(
subtreeName, subtreeName,
JSON.stringify(filteredState[subtreeName])); JSON.stringify(filteredState[subtreeName]));
} catch (error) { } catch (error) {
logger.error('Error persisting redux subtree', logger.error(
'Error persisting redux subtree',
subtreeName, subtreeName,
filteredState[subtreeName], filteredState[subtreeName],
error error);
);
} }
} }
logger.info( logger.info(
`redux state persisted. ${this._checksum} -> ${ `redux state persisted. ${this._checksum} -> ${checksum}`);
newCheckSum}`); this._checksum = checksum;
this._checksum = newCheckSum;
} }
} }
@ -139,8 +126,8 @@ class PersistenceRegistry {
* Registers a new subtree config to be used for the persistency. * Registers a new subtree config to be used for the persistency.
* *
* @param {string} name - The name of the subtree the config belongs to. * @param {string} name - The name of the subtree the config belongs to.
* @param {ElementConfig} config - The config object, or boolean * @param {ElementConfig} config - The config {@code Object}, or
* if the entire subtree needs to be persisted. * {@code boolean} if the entire subtree needs to be persisted.
* @returns {void} * @returns {void}
*/ */
register(name: string, config?: ElementConfig = true) { register(name: string, config?: ElementConfig = true) {
@ -148,32 +135,81 @@ class PersistenceRegistry {
} }
/** /**
* Calculates the checksum of the current or the new values of the state. * Calculates the checksum of a specific state.
* *
* @param {Object} state - The redux state to calculate the checksum of.
* @private * @private
* @param {Object} filteredState - The filtered/persisted redux state. * @returns {string} The checksum of the specified {@code state}.
* @returns {string}
*/ */
_calculateChecksum(filteredState: Object) { _calculateChecksum(state: Object) {
try { try {
return md5.hex(JSON.stringify(filteredState) || ''); return md5.hex(JSON.stringify(state) || '');
} catch (error) { } catch (error) {
logger.error( logger.error('Error calculating checksum for state', state, error);
'Error calculating checksum for state',
filteredState,
error);
return ''; return '';
} }
} }
/**
* Prepares a filtered state from the actual or the persisted redux state,
* based on this registry.
*
* @param {Object} state - The actual or persisted redux state.
* @private
* @returns {Object}
*/
_getFilteredState(state: Object) {
const filteredState = {};
for (const name of Object.keys(this._elements)) {
if (state[name]) {
filteredState[name]
= this._getFilteredSubtree(
state[name],
this._elements[name]);
}
}
return filteredState;
}
/**
* Prepares a filtered subtree based on the config for persisting or for
* retrieval.
*
* @param {Object} subtree - The redux state subtree.
* @param {ElementConfig} subtreeConfig - The related config.
* @private
* @returns {Object}
*/
_getFilteredSubtree(subtree, subtreeConfig) {
let filteredSubtree;
if (typeof subtreeConfig === 'object') {
// Only a filtered subtree gets persisted as specified by
// subtreeConfig.
filteredSubtree = {};
for (const persistedKey of Object.keys(subtree)) {
if (subtreeConfig[persistedKey]) {
filteredSubtree[persistedKey] = subtree[persistedKey];
}
}
} else if (subtreeConfig) {
// Persist the entire subtree.
filteredSubtree = subtree;
}
return filteredSubtree;
}
/** /**
* Retreives a persisted subtree from the storage. * Retreives a persisted subtree from the storage.
* *
* @private
* @param {string} subtreeName - The name of the subtree. * @param {string} subtreeName - The name of the subtree.
* @param {Object} subtreeConfig - The config of the subtree * @param {Object} subtreeConfig - The config of the subtree from
* from this._elements. * {@link #_elements}.
* @private
* @returns {Object} * @returns {Object}
*/ */
_getPersistedSubtree(subtreeName, subtreeConfig) { _getPersistedSubtree(subtreeName, subtreeConfig) {
@ -182,6 +218,7 @@ class PersistenceRegistry {
if (persistedSubtree) { if (persistedSubtree) {
try { try {
persistedSubtree = JSON.parse(persistedSubtree); persistedSubtree = JSON.parse(persistedSubtree);
const filteredSubtree const filteredSubtree
= this._getFilteredSubtree(persistedSubtree, subtreeConfig); = this._getFilteredSubtree(persistedSubtree, subtreeConfig);
@ -197,58 +234,7 @@ class PersistenceRegistry {
} }
} }
return null; return undefined;
}
/**
* Prepares a filtered state from the actual or the persisted redux state,
* based on this registry.
*
* @private
* @param {Object} state - The actual or persisted redux state.
* @returns {Object}
*/
_getFilteredState(state: Object) {
const filteredState = {};
for (const name of Object.keys(this._elements)) {
if (state[name]) {
filteredState[name] = this._getFilteredSubtree(
state[name],
this._elements[name]);
}
}
return filteredState;
}
/**
* Prepares a filtered subtree based on the config for persisting or for
* retrieval.
*
* @private
* @param {Object} subtree - The redux state subtree.
* @param {ElementConfig} subtreeConfig - The related config.
* @returns {Object}
*/
_getFilteredSubtree(subtree, subtreeConfig) {
let filteredSubtree;
if (subtreeConfig === true) {
// we persist the entire subtree
filteredSubtree = subtree;
} else if (typeof subtreeConfig === 'object') {
// only a filtered subtree gets persisted, based on the
// subtreeConfig object.
filteredSubtree = {};
for (const persistedKey of Object.keys(subtree)) {
if (subtreeConfig[persistedKey]) {
filteredSubtree[persistedKey] = subtree[persistedKey];
}
}
}
return filteredSubtree;
} }
} }

View File

@ -8,16 +8,19 @@ import { appNavigate } from '../../app';
* The type of the React {@code Component} props of {@link AbstractRecentList} * The type of the React {@code Component} props of {@link AbstractRecentList}
*/ */
type Props = { type Props = {
_defaultURL: string,
/** _recentList: Array<Object>,
* Indicates if the list is disabled or not.
*/
disabled: boolean,
/** /**
* The redux store's {@code dispatch} function. * The redux store's {@code dispatch} function.
*/ */
dispatch: Dispatch<*> dispatch: Dispatch<*>,
/**
* Whether {@code AbstractRecentList} is enabled.
*/
enabled: boolean
}; };
/** /**
@ -33,18 +36,20 @@ export default class AbstractRecentList extends Component<Props> {
* Joins the selected room. * Joins the selected room.
* *
* @param {string} room - The selected room. * @param {string} room - The selected room.
* @protected
* @returns {void} * @returns {void}
*/ */
_onJoin(room) { _onJoin(room) {
const { disabled, dispatch } = this.props; const { dispatch, enabled } = this.props;
!disabled && room && dispatch(appNavigate(room)); enabled && room && dispatch(appNavigate(room));
} }
/** /**
* Creates a bound onPress action for the list item. * Creates a bound onPress action for the list item.
* *
* @param {string} room - The selected room. * @param {string} room - The selected room.
* @protected
* @returns {Function} * @returns {Function}
*/ */
_onSelect(room) { _onSelect(room) {
@ -53,17 +58,18 @@ export default class AbstractRecentList extends Component<Props> {
} }
/** /**
* Maps redux state to component props. * Maps (parts of) the redux state into {@code AbstractRecentList}'s React
* {@code Component} props.
* *
* @param {Object} state - The redux state. * @param {Object} state - The redux state.
* @returns {{ * @returns {{
* _homeServer: string, * _defaultURL: string,
* _recentList: Array * _recentList: Array
* }} * }}
*/ */
export function _mapStateToProps(state: Object) { export function _mapStateToProps(state: Object) {
return { return {
_homeServer: state['features/app'].app._getDefaultURL(), _defaultURL: state['features/app'].app._getDefaultURL(),
_recentList: state['features/recent-list'] _recentList: state['features/recent-list']
}; };
} }

View File

@ -47,21 +47,20 @@ class RecentList extends AbstractRecentList {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const { _recentList, disabled } = this.props; const { enabled, _recentList } = this.props;
if (!_recentList) { if (!_recentList) {
return null; return null;
} }
const listViewDataSource const listViewDataSource
= this.dataSource.cloneWithRows( = this.dataSource.cloneWithRows(getRecentRooms(_recentList));
getRecentRooms(_recentList));
return ( return (
<View <View
style = { [ style = { [
styles.container, styles.container,
disabled ? styles.containerDisabled : null enabled ? null : styles.containerDisabled
] }> ] }>
<ListView <ListView
dataSource = { listViewDataSource } dataSource = { listViewDataSource }
@ -72,19 +71,19 @@ class RecentList extends AbstractRecentList {
} }
/** /**
* Assembles the style array of the avatar based on if the conference was a * Assembles the style array of the avatar based on if the conference was
* home or remote server conference (based on current app setting). * hosted on the default Jitsi Meet deployment or on a non-default one
* (based on current app setting).
* *
* @param {Object} recentListEntry - The recent list entry being rendered. * @param {Object} recentListEntry - The recent list entry being rendered.
* @private * @private
* @returns {Array<Object>} * @returns {Array<Object>}
*/ */
_getAvatarStyle(recentListEntry) { _getAvatarStyle({ baseURL, serverName }) {
const avatarStyles = [ styles.avatar ]; const avatarStyles = [ styles.avatar ];
if (recentListEntry.baseURL !== this.props._homeServer) { if (baseURL !== this.props._defaultURL) {
avatarStyles.push( avatarStyles.push(this._getColorForServerName(serverName));
this._getColorForServerName(recentListEntry.serverName));
} }
return avatarStyles; return avatarStyles;
@ -115,40 +114,15 @@ class RecentList extends AbstractRecentList {
* @private * @private
* @returns {ReactElement} * @returns {ReactElement}
*/ */
_renderConfDuration({ conferenceDurationString }) { _renderConfDuration({ durationString }) {
if (conferenceDurationString) { if (durationString) {
return ( return (
<View style = { styles.infoWithIcon } > <View style = { styles.infoWithIcon } >
<Icon <Icon
name = 'timer' name = 'timer'
style = { styles.inlineIcon } /> style = { styles.inlineIcon } />
<Text style = { styles.confLength }> <Text style = { styles.confLength }>
{ conferenceDurationString } { durationString }
</Text>
</View>
);
}
return null;
}
/**
* Renders the server info component based on if the entry was on a
* different server or not.
*
* @param {Object} recentListEntry - The recent list entry being rendered.
* @private
* @returns {ReactElement}
*/
_renderServerInfo(recentListEntry) {
if (recentListEntry.baseURL !== this.props._homeServer) {
return (
<View style = { styles.infoWithIcon } >
<Icon
name = 'public'
style = { styles.inlineIcon } />
<Text style = { styles.serverName }>
{ recentListEntry.serverName }
</Text> </Text>
</View> </View>
); );
@ -191,17 +165,38 @@ class RecentList extends AbstractRecentList {
{ data.dateString } { data.dateString }
</Text> </Text>
</View> </View>
{ { this._renderConfDuration(data) }
this._renderConfDuration(data) { this._renderServerInfo(data) }
}
{
this._renderServerInfo(data)
}
</View> </View>
</View> </View>
</TouchableHighlight> </TouchableHighlight>
); );
} }
/**
* Renders the server info component based on whether the entry was on a
* different server.
*
* @param {Object} recentListEntry - The recent list entry being rendered.
* @private
* @returns {ReactElement}
*/
_renderServerInfo({ baseURL, serverName }) {
if (baseURL !== this.props._defaultURL) {
return (
<View style = { styles.infoWithIcon } >
<Icon
name = 'public'
style = { styles.inlineIcon } />
<Text style = { styles.serverName }>
{ serverName }
</Text>
</View>
);
}
return null;
}
} }
export default connect(_mapStateToProps)(RecentList); export default connect(_mapStateToProps)(RecentList);

View File

@ -17,7 +17,6 @@ require('moment/locale/it');
require('moment/locale/nb'); require('moment/locale/nb');
// OC is not available. Please submit OC translation to the MomentJS project. // OC is not available. Please submit OC translation to the MomentJS project.
require('moment/locale/pl'); require('moment/locale/pl');
require('moment/locale/pt'); require('moment/locale/pt');
require('moment/locale/pt-br'); require('moment/locale/pt-br');
@ -47,22 +46,22 @@ export function getRecentRooms(list: Array<Object>): Array<Object> {
const locale = _getSupportedLocale(); const locale = _getSupportedLocale();
for (const e of list) { for (const e of list) {
const location = parseURIString(e.conference); const uri = parseURIString(e.conference);
if (uri && uri.room && uri.hostname) {
const duration
= e.duration || /* legacy */ e.conferenceDuration;
if (location && location.room && location.hostname) {
recentRoomDS.push({ recentRoomDS.push({
baseURL: `${location.protocol}//${location.host}`, baseURL: `${uri.protocol}//${uri.host}`,
conference: e.conference, conference: e.conference,
conferenceDuration: e.conferenceDuration,
conferenceDurationString:
_getDurationString(
e.conferenceDuration,
locale),
dateString: _getDateString(e.date, locale), dateString: _getDateString(e.date, locale),
dateTimeStamp: e.date, dateTimeStamp: e.date,
initials: _getInitials(location.room), duration,
room: location.room, durationString: _getDurationString(duration, locale),
serverName: location.hostname initials: _getInitials(uri.room),
room: uri.room,
serverName: uri.hostname
}); });
} }
} }
@ -124,7 +123,7 @@ function _getInitials(room: string) {
* or duration ({@code number}). * or duration ({@code number}).
* *
* @private * @private
* @param {Date | number} dateOrDuration - The date or duration to format. * @param {Date|number} dateOrDuration - The date or duration to format.
* @param {string} locale - The locale to init the formatter with. Note: The * @param {string} locale - The locale to init the formatter with. Note: The
* specified locale must be supported by the formatter so ensure the * specified locale must be supported by the formatter so ensure the
* prerequisite is met before invoking the function. * prerequisite is met before invoking the function.

View File

@ -15,11 +15,11 @@ import { storeCurrentConference, updateConferenceDuration } from './actions';
MiddlewareRegistry.register(store => next => action => { MiddlewareRegistry.register(store => next => action => {
switch (action.type) { switch (action.type) {
case CONFERENCE_WILL_LEAVE: case CONFERENCE_WILL_LEAVE:
_updateConferenceDuration(store, next); _updateConferenceDuration(store);
break; break;
case SET_ROOM: case SET_ROOM:
_maybeStoreCurrentConference(store, next, action); _maybeStoreCurrentConference(store, action);
break; break;
} }
@ -36,12 +36,11 @@ MiddlewareRegistry.register(store => next => action => {
* @private * @private
* @returns {void} * @returns {void}
*/ */
function _maybeStoreCurrentConference(store, next, action) { function _maybeStoreCurrentConference({ dispatch, getState }, { room }) {
const { locationURL } = store.getState()['features/base/connection'];
const { room } = action;
if (room) { if (room) {
next(storeCurrentConference(locationURL)); const { locationURL } = getState()['features/base/connection'];
dispatch(storeCurrentConference(locationURL));
} }
} }
@ -49,13 +48,11 @@ function _maybeStoreCurrentConference(store, next, action) {
* Updates the duration of the last conference stored in the list. * Updates the duration of the last conference stored in the list.
* *
* @param {Store} store - The redux store. * @param {Store} store - The redux store.
* @param {Dispatch} next - The redux {@code dispatch} function.
* @param {Action} action - The redux action.
* @private * @private
* @returns {void} * @returns {void}
*/ */
function _updateConferenceDuration(store, next) { function _updateConferenceDuration({ dispatch, getState }) {
const { locationURL } = store.getState()['features/base/connection']; const { locationURL } = getState()['features/base/connection'];
next(updateConferenceDuration(locationURL)); dispatch(updateConferenceDuration(locationURL));
} }

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { APP_WILL_MOUNT } from '../app';
import { ReducerRegistry } from '../base/redux'; import { ReducerRegistry } from '../base/redux';
import { PersistenceRegistry } from '../base/storage'; import { PersistenceRegistry } from '../base/storage';
@ -10,6 +11,13 @@ import {
const logger = require('jitsi-meet-logger').getLogger(__filename); const logger = require('jitsi-meet-logger').getLogger(__filename);
/**
* The default/initial redux state of the feature {@code recent-list}.
*
* @type {Array<Object>}
*/
const DEFAULT_STATE = [];
/** /**
* The name of the {@code window.localStorage} item where recent rooms are * The name of the {@code window.localStorage} item where recent rooms are
* stored. * stored.
@ -31,17 +39,20 @@ export const MAX_LIST_SIZE = 30;
const STORE_NAME = 'features/recent-list'; const STORE_NAME = 'features/recent-list';
/** /**
* Sets up the persistence of the feature recent-list. * Sets up the persistence of the feature {@code recent-list}.
*/ */
PersistenceRegistry.register(STORE_NAME); PersistenceRegistry.register(STORE_NAME);
/** /**
* Reduces the redux actions of the feature recent-list. * Reduces redux actions for the purposes of the feature {@code recent-list}.
*/ */
ReducerRegistry.register( ReducerRegistry.register(
STORE_NAME, STORE_NAME,
(state = _getLegacyRecentRoomList(), action) => { (state = _getLegacyRecentRoomList(), action) => {
switch (action.type) { switch (action.type) {
case APP_WILL_MOUNT:
return _appWillMount(state);
case STORE_CURRENT_CONFERENCE: case STORE_CURRENT_CONFERENCE:
return _storeCurrentConference(state, action); return _storeCurrentConference(state, action);
@ -53,18 +64,48 @@ ReducerRegistry.register(
} }
}); });
/**
* Reduces the redux action {@link APP_WILL_MOUNT}.
*
* @param {Object} state - The redux state of the feature {@code recent-list}.
* @param {Action} action - The redux action {@code APP_WILL_MOUNT}.
* @returns {Array<Object>} The next redux state of the feature
* {@code recent-list}.
*/
function _appWillMount(state) {
// XXX APP_WILL_MOUNT is the earliest redux action of ours dispatched in the
// store. For the purposes of legacy support, make sure that the
// deserialized recent-list's state is in the format deemed current by the
// current app revision.
if (state && typeof state === 'object') {
if (Array.isArray(state)) {
return state;
}
// In an enterprise/internal build of Jitsi Meet for Android and iOS we
// had recent-list's state as an object with property list.
const { list } = state;
if (Array.isArray(list) && list.length) {
return list.slice();
}
}
// In the weird case that we have previously persisted/serialized null.
return DEFAULT_STATE;
}
/** /**
* Retrieves the recent room list that was stored using the legacy way. * Retrieves the recent room list that was stored using the legacy way.
* *
* @returns {Array<Object>} * @returns {Array<Object>}
*/ */
export function _getLegacyRecentRoomList(): Array<Object> { function _getLegacyRecentRoomList(): Array<Object> {
try { try {
const list const str = window.localStorage.getItem(LEGACY_STORAGE_KEY);
= JSON.parse(window.localStorage.getItem(LEGACY_STORAGE_KEY));
if (list && list.length) { if (str) {
return list; return JSON.parse(str);
} }
} catch (error) { } catch (error) {
logger.warn('Failed to parse legacy recent-room list!'); logger.warn('Failed to parse legacy recent-room list!');
@ -74,61 +115,79 @@ export function _getLegacyRecentRoomList(): Array<Object> {
} }
/** /**
* Adds a new list entry to the redux store. * Adds a new list entry to the redux store.
* *
* @param {Object} state - The redux state. * @param {Object} state - The redux state of the feature {@code recent-list}.
* @param {Object} action - The redux action. * @param {Object} action - The redux action.
* @returns {Object} * @returns {Object}
*/ */
function _storeCurrentConference(state, action) { function _storeCurrentConference(state, { locationURL }) {
const { locationURL } = action;
const conference = locationURL.href; const conference = locationURL.href;
// If the current conference is already in the list, we remove it to re-add // If the current conference is already in the list, we remove it to re-add
// it to the top. // it to the top.
const list = (Array.isArray(state) ? state : []) const nextState
.filter(e => e.conference !== conference); = state.filter(e => !_urlStringEquals(e.conference, conference));
// The list is a reverse-sorted (i.e. the newer elements are at the end). // The list is a reverse-sorted (i.e. the newer elements are at the end).
list.push({ nextState.push({
conference, conference,
conferenceDuration: 0, // We don't have this data yet! date: Date.now(),
date: Date.now() duration: 0 // We don't have the duration yet!
}); });
// Ensure the list doesn't exceed a/the maximum size. // Ensure the list doesn't exceed a/the maximum size.
list.splice(0, list.length - MAX_LIST_SIZE); nextState.splice(0, nextState.length - MAX_LIST_SIZE);
return list; return nextState;
} }
/** /**
* Updates the conference length when left. * Updates the conference length when left.
* *
* @param {Object} state - The redux state. * @param {Object} state - The redux state of the feature {@code recent-list}.
* @param {Object} action - The redux action. * @param {Object} action - The redux action.
* @returns {Object} * @returns {Object} The next redux state of the feature {@code recent-list}.
*/ */
function _updateConferenceDuration(state, action) { function _updateConferenceDuration(state, { locationURL }) {
const { locationURL } = action; if (locationURL && locationURL.href && state.length) {
const mostRecentIndex = state.length - 1;
const mostRecent = state[mostRecentIndex];
if (locationURL && locationURL.href) { if (_urlStringEquals(mostRecent.conference, locationURL.href)) {
// shallow copy to avoid in-place modification. // The last conference start was stored so we need to update the
const list = (Array.isArray(state) ? state : []).slice(); // length.
const nextMostRecent = {
...mostRecent,
duration: Date.now() - mostRecent.date
};
if (list.length > 0) { delete nextMostRecent.conferenceDuration; // legacy
const mostRecentURL = list[list.length - 1];
if (mostRecentURL.conference === locationURL.href) { // Shallow copy to avoid in-place modification.
// The last conference start was stored so we need to update the const nextState = state.slice();
// length.
mostRecentURL.conferenceDuration
= Date.now() - mostRecentURL.date;
return list; nextState[mostRecentIndex] = nextMostRecent;
}
return nextState;
} }
} }
return state; return state;
} }
/**
* Determines whether two specific URL {@code strings} are equal in the sense
* that they identify one and the same conference resource (irrespective of
* time) for the purposes of the feature {@code recent-list}.
*
* @param {string} a - The URL {@code string} to test for equality to {@code b}.
* @param {string} b - The URL {@code string} to test for equality to {@code a}.
* @returns {boolean}
*/
function _urlStringEquals(a: string, b: string) {
// FIXME Case-sensitive comparison is wrong because the room name at least
// is case insensitive on the server and elsewhere (where it matters) in the
// client. I don't think domain names are case-sensitive either.
return a === b;
}

View File

@ -2,7 +2,7 @@
import { Component } from 'react'; import { Component } from 'react';
import { getProfile, updateProfile } from '../../base/profile'; import { updateProfile } from '../../base/profile';
/** /**
* The type of the React {@code Component} props of * The type of the React {@code Component} props of
@ -12,16 +12,22 @@ type Props = {
/** /**
* The current profile object. * The current profile object.
*
* @protected
*/ */
_profile: Object, _profile: Object,
/** /**
* The default URL for when there is no custom URL set in the profile. * The default URL for when there is no custom URL set in the profile.
*
* @protected
*/ */
_serverURL: string, _serverURL: string,
/** /**
* Whether {@link AbstractSettingsView} is visible. * Whether {@link AbstractSettingsView} is visible.
*
* @protected
*/ */
_visible: boolean, _visible: boolean,
@ -168,7 +174,7 @@ export class AbstractSettingsView extends Component<Props> {
*/ */
export function _mapStateToProps(state: Object) { export function _mapStateToProps(state: Object) {
return { return {
_profile: getProfile(state), _profile: state['features/base/profile'],
_serverURL: state['features/app'].app._getDefaultURL(), _serverURL: state['features/app'].app._getDefaultURL(),
_visible: state['features/settings'].visible _visible: state['features/settings'].visible
}; };

View File

@ -1,6 +1,5 @@
// @flow // @flow
import PropTypes from 'prop-types';
import { Component } from 'react'; import { Component } from 'react';
import { createWelcomePageEvent, sendAnalytics } from '../../analytics'; import { createWelcomePageEvent, sendAnalytics } from '../../analytics';
@ -14,11 +13,6 @@ import { generateRoomWithoutSeparator } from '../functions';
*/ */
type Props = { type Props = {
/**
* Boolean to indicate if the room field is focused or not.
*/
_fieldFocused: boolean,
/** /**
* The user's profile. * The user's profile.
*/ */
@ -32,17 +26,7 @@ type Props = {
* *
* @abstract * @abstract
*/ */
export class AbstractWelcomePage extends Component<*, *> { export class AbstractWelcomePage extends Component<Props, *> {
/**
* {@code AbstractWelcomePage}'s React {@code Component} prop types.
*
* @static
*/
static propTypes = {
_room: PropTypes.string,
dispatch: PropTypes.func
};
_mounted: ?boolean; _mounted: ?boolean;
/** /**
@ -245,12 +229,13 @@ export class AbstractWelcomePage extends Component<*, *> {
* @param {Object} state - The redux state. * @param {Object} state - The redux state.
* @protected * @protected
* @returns {{ * @returns {{
* _profile: Object,
* _room: string * _room: string
* }} * }}
*/ */
export function _mapStateToProps(state: Object) { export function _mapStateToProps(state: Object) {
return { return {
_profile: state['features/base/profile'].profile, _profile: state['features/base/profile'],
_room: state['features/base/conference'].room _room: state['features/base/conference'].room
}; };
} }

View File

@ -40,13 +40,6 @@ import WelcomePageSideBar from './WelcomePageSideBar';
* @extends AbstractWelcomePage * @extends AbstractWelcomePage
*/ */
class WelcomePage extends AbstractWelcomePage { class WelcomePage extends AbstractWelcomePage {
/**
* WelcomePage component's property types.
*
* @static
*/
static propTypes = AbstractWelcomePage.propTypes;
/** /**
* Constructor of the Component. * Constructor of the Component.
* *
@ -140,7 +133,7 @@ class WelcomePage extends AbstractWelcomePage {
{ {
this._renderHintBox() this._renderHintBox()
} }
<RecentList disabled = { this.state._fieldFocused } /> <RecentList enabled = { !this.state._fieldFocused } />
</SafeAreaView> </SafeAreaView>
<SettingsView /> <SettingsView />
</View> </View>