diff --git a/app/actions/storage/index.js b/app/actions/storage/index.js new file mode 100644 index 000000000..012dfd065 --- /dev/null +++ b/app/actions/storage/index.js @@ -0,0 +1,52 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {AsyncStorage} from 'react-native'; +import {GeneralTypes, UsersTypes} from 'service/constants'; +import Client from 'service/client'; + +export function loadStorage() { + return async (dispatch, getState) => { + try { + const data = JSON.parse(await AsyncStorage.getItem('storage')); + dispatch({type: GeneralTypes.RECEIVED_APP_CREDENTIALS, data}, getState); + } catch (error) { + // Error loading data + dispatch({type: GeneralTypes.REMOVED_APP_CREDENTIALS, error}, getState); + } + }; +} + +export function saveStorage() { + return async (dispatch, getState) => { + try { + const data = { + token: Client.getToken(), + url: Client.getUrl() + }; + + await AsyncStorage.setItem('storage', JSON.stringify(data)); + dispatch({type: GeneralTypes.RECEIVED_APP_CREDENTIALS, data}, getState); + } catch (error) { + // Error saving data + dispatch({type: GeneralTypes.REMOVED_APP_CREDENTIALS, error}, getState); + } + }; +} + +export function removeStorage() { + return async (dispatch, getState) => { + try { + await AsyncStorage.removeItem('storage'); + } catch (error) { + // Error removing data + } + dispatch({type: UsersTypes.RESET_LOGOUT_STATE}, getState); + }; +} + +export default { + loadStorage, + saveStorage, + removeStorage +}; diff --git a/app/actions/views/root.js b/app/actions/views/root.js index baddc7b99..7e308488b 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -1,9 +1,14 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import {batchActions} from 'redux-batched-actions'; import {NavigationTypes} from 'app/constants'; import Routes from 'app/navigation/routes'; +import Client from 'service/client'; +import {forceLogoutIfNecessary} from 'service/actions/helpers'; +import {PreferencesTypes, TeamsTypes, UsersTypes} from 'service/constants'; + export function goToSelectServer() { return async (dispatch, getState) => { dispatch({ @@ -13,3 +18,85 @@ export function goToSelectServer() { }, getState); }; } + +export function setStoreFromLocalData(data) { + return async (dispatch, getState) => { + Client.setToken(data.token); + Client.setUrl(data.url); + + let user; + dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); + try { + user = await Client.getMe(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState); + return; + } + + let preferences; + dispatch({type: PreferencesTypes.MY_PREFERENCES_REQUEST}, getState); + try { + preferences = await Client.getMyPreferences(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch({type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, getState); + return; + } + + let teams; + dispatch({type: TeamsTypes.FETCH_TEAMS_REQUEST}, getState); + try { + teams = await Client.getAllTeams(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch); + dispatch({type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, getState); + return; + } + + // TODO: uncomment and sorround with try/catch when PLT-4167 is merged + // const teamMembers = Client.getMyTeamMembers(); + const teamMembers = Object.keys(teams).map((key) => { + return { + team_id: key, + user_id: user.id + }; + }); + + dispatch(batchActions([ + { + type: UsersTypes.RECEIVED_ME, + data: user + }, + { + type: UsersTypes.LOGIN_SUCCESS + }, + { + type: PreferencesTypes.RECEIVED_PREFERENCES, + data: preferences + }, + { + type: PreferencesTypes.MY_PREFERENCES_SUCCESS + }, + { + type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS, + data: teamMembers + }, + { + type: TeamsTypes.MY_TEAM_MEMBERS_SUCCESS + }, + { + type: TeamsTypes.RECEIVED_ALL_TEAMS, + data: teams + }, + { + type: TeamsTypes.FETCH_TEAMS_SUCCESS + } + ]), getState); + }; +} + +export default { + goToSelectServer, + setStoreFromLocalData +}; diff --git a/app/constants/index.js b/app/constants/index.js index e42c42e3e..76d9737d0 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -2,9 +2,11 @@ // See License.txt for license information. import NavigationTypes from './navigation'; +import StorageTypes from './storage'; import ViewTypes from './view'; export { NavigationTypes, + StorageTypes, ViewTypes }; diff --git a/app/constants/storage.js b/app/constants/storage.js new file mode 100644 index 000000000..a3937ce92 --- /dev/null +++ b/app/constants/storage.js @@ -0,0 +1,15 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import keymirror from 'keymirror'; + +const StorageTypes = keymirror({ + SAVE_TO_STORAGE: null, + SAVE_TO_STORAGE_ERROR: null, + LOAD_FROM_STORAGE: null, + LOAD_FROM_STORAGE_ERROR: null, + REMOVE_FROM_STORAGE: null, + REMOVE_FROM_STORAGE_ERROR: null +}); + +export default StorageTypes; diff --git a/app/layouts/root_layout/root_layout.js b/app/layouts/root_layout/root_layout.js index bb77931a7..2e520ab52 100644 --- a/app/layouts/root_layout/root_layout.js +++ b/app/layouts/root_layout/root_layout.js @@ -2,16 +2,39 @@ // See License.txt for license information. import React from 'react'; - +import {AppState} from 'react-native'; import {getTranslations} from 'service/i18n'; import {IntlProvider} from 'react-intl'; export default class RootLayout extends React.Component { static propTypes = { children: React.PropTypes.node, - locale: React.PropTypes.string.isRequired + locale: React.PropTypes.string.isRequired, + actions: React.PropTypes.shape({ + setAppState: React.PropTypes.func + }).isRequired }; + constructor(props) { + super(props); + + this.handleAppStateChange = this.handleAppStateChange.bind(this); + + this.props.actions.setAppState(AppState.currentState === 'active'); + } + + componentDidMount() { + AppState.addEventListener('change', this.handleAppStateChange); + } + + componentWillUnmount() { + AppState.removeEventListener('change', this.handleAppStateChange); + } + + handleAppStateChange(appState) { + this.props.actions.setAppState(appState === 'active'); + } + render() { const locale = this.props.locale; diff --git a/app/layouts/root_layout/root_layout_container.js b/app/layouts/root_layout/root_layout_container.js index 192b62551..7d1f98567 100644 --- a/app/layouts/root_layout/root_layout_container.js +++ b/app/layouts/root_layout/root_layout_container.js @@ -1,10 +1,11 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; +import Config from 'config/index'; -import Config from 'config'; - +import {setAppState} from 'service/actions/general'; import RootLayout from './root_layout'; function mapStateToProps(state, ownProps) { @@ -22,4 +23,12 @@ function mapStateToProps(state, ownProps) { }; } -export default connect(mapStateToProps)(RootLayout); +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + setAppState + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(RootLayout); diff --git a/app/navigation/router.js b/app/navigation/router.js index ed110c8fd..3c692c66e 100644 --- a/app/navigation/router.js +++ b/app/navigation/router.js @@ -19,7 +19,7 @@ class Router extends React.Component { actions: React.PropTypes.shape({ goBack: React.PropTypes.func }).isRequired - } + }; renderTransition = (transitionProps) => { let title; @@ -61,7 +61,7 @@ class Router extends React.Component { {title} ); - } + }; renderTitle = ({scene}) => { const title = scene.route.title; @@ -78,20 +78,20 @@ class Router extends React.Component { /> ); - } + }; renderScene = ({scene}) => { const SceneComponent = getComponentForScene(scene.route.key); return ; - } + }; configureTransition = () => { return { duration: 500, easing: Easing.inOut(Easing.ease) }; - } + }; render = () => { return ( @@ -102,7 +102,7 @@ class Router extends React.Component { configureTransition={this.configureTransition} /> ); - } + }; } function mapStateToProps(state) { diff --git a/app/reducers/index.js b/app/reducers/index.js index bb4b6c92a..c4374e89c 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -1,10 +1,10 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import views from './views'; import navigation from './navigation'; +import views from './views'; export default { - views, - navigation + navigation, + views }; diff --git a/app/reducers/views/i18n.js b/app/reducers/views/i18n.js index fe8c1e918..da7628b29 100644 --- a/app/reducers/views/i18n.js +++ b/app/reducers/views/i18n.js @@ -3,7 +3,7 @@ import {combineReducers} from 'redux'; -import Config from 'config'; +import Config from 'config/index'; import {UsersTypes} from 'service/constants'; function locale(state = Config.DefaultLocale, action) { diff --git a/app/reducers/views/select_server.js b/app/reducers/views/select_server.js index 6c6b6af85..5f5bd0ff5 100644 --- a/app/reducers/views/select_server.js +++ b/app/reducers/views/select_server.js @@ -3,7 +3,7 @@ import {combineReducers} from 'redux'; -import Config from 'config'; +import Config from 'config/index'; import {ViewTypes} from 'app/constants'; diff --git a/app/scenes/channel/channel.js b/app/scenes/channel/channel.js index d909f33a7..20143cf70 100644 --- a/app/scenes/channel/channel.js +++ b/app/scenes/channel/channel.js @@ -14,7 +14,7 @@ import {StatusBar, Text, TouchableHighlight, View} from 'react-native'; export default class Channel extends React.Component { static propTypes = { actions: React.PropTypes.object.isRequired, - currentTeam: React.PropTypes.object.isRequired, + currentTeam: React.PropTypes.object, currentChannel: React.PropTypes.object, channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, theme: React.PropTypes.object.isRequired @@ -36,26 +36,26 @@ export default class Channel extends React.Component { } componentWillReceiveProps(nextProps) { - if (this.props.currentTeam.id !== nextProps.currentTeam.id) { + if (this.props.currentTeam && nextProps.currentTeam && this.props.currentTeam.id !== nextProps.currentTeam.id) { this.props.actions.fetchMyChannelsAndMembers(nextProps.currentTeam.id); } } openLeftSidebar = () => { this.setState({leftSidebarOpen: true}); - } + }; closeLeftSidebar = () => { this.setState({leftSidebarOpen: false}); - } + }; openRightSidebar = () => { this.setState({rightSidebarOpen: true}); - } + }; closeRightSidebar = () => { this.setState({rightSidebarOpen: false}); - } + }; render() { const { diff --git a/app/scenes/login/login.js b/app/scenes/login/login.js index 1abfd55ec..9ea681e22 100644 --- a/app/scenes/login/login.js +++ b/app/scenes/login/login.js @@ -35,7 +35,7 @@ class Login extends Component { componentWillReceiveProps(nextProps) { if (this.props.loginRequest.status === RequestStatus.STARTED && nextProps.loginRequest.status === RequestStatus.SUCCESS) { - this.props.actions.goToSelectTeam(); + this.props.actions.saveStorage().then(this.props.actions.goToSelectTeam); } } diff --git a/app/scenes/login/login_container.js b/app/scenes/login/login_container.js index 68d0c147d..d22434e85 100644 --- a/app/scenes/login/login_container.js +++ b/app/scenes/login/login_container.js @@ -6,6 +6,7 @@ import {connect} from 'react-redux'; import {getClientConfig, getLicenseConfig} from 'service/actions/general'; import * as LoginActions from 'app/actions/views/login'; +import * as StorageActions from 'app/actions/storage'; import {goToSelectTeam} from 'app/actions/navigation'; import {login} from 'service/actions/users'; @@ -26,6 +27,7 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ ...LoginActions, + ...StorageActions, login, getClientConfig, getLicenseConfig, diff --git a/app/scenes/root/root.js b/app/scenes/root/root.js index afb78760f..3e334cd6d 100644 --- a/app/scenes/root/root.js +++ b/app/scenes/root/root.js @@ -2,19 +2,47 @@ // See License.txt for license information. import React from 'react'; +import Loading from 'app/components/loading'; + +import {RequestStatus} from 'service/constants'; export default class Root extends React.Component { static propTypes = { - actions: React.PropTypes.object.isRequired + credentials: React.PropTypes.object, + logoutRequest: React.PropTypes.object, + actions: React.PropTypes.shape({ + goToSelectServer: React.PropTypes.func, + goToSelectTeam: React.PropTypes.func, + loadStorage: React.PropTypes.func, + removeStorage: React.PropTypes.func, + setStoreFromLocalData: React.PropTypes.func, + resetLogout: React.PropTypes.func + }).isRequired }; componentDidMount() { // Any initialization logic for navigation, setting up the client, etc should go here - this.props.actions.goToSelectServer(); + if (this.props.logoutRequest.status === RequestStatus.SUCCESS) { + this.props.actions.removeStorage().then(() => { + setTimeout(this.loadStoreAndScene, 1000); + }); + } else { + this.loadStoreAndScene(); + } } + loadStoreAndScene = () => { + this.props.actions.loadStorage().then(() => { + if (this.props.credentials.token && this.props.credentials.url) { + this.props.actions.setStoreFromLocalData(this.props.credentials).then(this.props.actions.goToSelectTeam); + } else { + this.props.actions.goToSelectServer(); + } + }); + }; + render() { - return null; + return ; } } diff --git a/app/scenes/root/root_container.js b/app/scenes/root/root_container.js index 15e78b260..a62997435 100644 --- a/app/scenes/root/root_container.js +++ b/app/scenes/root/root_container.js @@ -4,18 +4,30 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {goToSelectServer} from 'app/actions/views/root'; +import {loadStorage, removeStorage} from 'app/actions/storage'; +import {goToSelectServer, setStoreFromLocalData} from 'app/actions/views/root'; +import {goToSelectTeam} from 'app/actions/navigation'; +import {resetLogout} from 'service/actions/users'; import Root from './root'; function mapStateToProps(state, ownProps) { - return ownProps; + return { + ...ownProps, + credentials: state.entities.general.credentials, + logoutRequest: state.requests.users.logout + }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - goToSelectServer + goToSelectServer, + goToSelectTeam, + loadStorage, + removeStorage, + setStoreFromLocalData, + resetLogout }, dispatch) }; } diff --git a/service/actions/general.js b/service/actions/general.js index da753467f..0b2459c8d 100644 --- a/service/actions/general.js +++ b/service/actions/general.js @@ -42,3 +42,17 @@ export function logClientError(message, level = 'ERROR') { level ); } + +export function setAppState(state) { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.RECEIVED_APP_STATE, data: state}, getState); + }; +} + +export default { + getPing, + getClientConfig, + getLicenseConfig, + logClientError, + setAppState +}; diff --git a/service/actions/helpers.js b/service/actions/helpers.js index d9eca41e7..2888bb3c2 100644 --- a/service/actions/helpers.js +++ b/service/actions/helpers.js @@ -7,6 +7,7 @@ const HTTP_UNAUTHORIZED = 401; export async function forceLogoutIfNecessary(err, dispatch) { if (err.status_code === HTTP_UNAUTHORIZED && err.url.indexOf('/login') === -1) { + dispatch({type: UsersTypes.LOGOUT_REQUEST}); await Client.logout(); dispatch({type: UsersTypes.LOGOUT_SUCCESS}); } diff --git a/service/client/client.js b/service/client/client.js index 888abbed8..f4260502e 100644 --- a/service/client/client.js +++ b/service/client/client.js @@ -22,10 +22,22 @@ export default class Client { }; } + getUrl() { + return this.url; + } + setUrl(url) { this.url = url; } + getToken() { + return this.token; + } + + setToken(token) { + this.token = token; + } + getBaseRoute() { return `${this.url}${this.urlVersion}`; } @@ -192,6 +204,13 @@ export default class Client { if (response.headers.has(HEADER_TOKEN)) { this.token = response.headers.get(HEADER_TOKEN); + } else { + // weird case where fetch does not parse the header correctly + const parseHeader = response.headers.get(HEADER_CONTENT_TYPE).split('\n'); + const filter = parseHeader.filter((h) => h.indexOf('Token:') > -1); + if (filter.length) { + this.token = filter[0].replace('Token: ', ''); + } } return data; @@ -242,6 +261,13 @@ export default class Client { ); }; + getMe = async () => { + return this.doFetch( + `${this.getUsersRoute()}/me`, + {method: 'get'} + ); + }; + getProfiles = async (offset, limit) => { return this.doFetch( `${this.getUsersRoute()}/${offset}/${limit}`, diff --git a/service/constants/general.js b/service/constants/general.js index f25af4346..e5efd3086 100644 --- a/service/constants/general.js +++ b/service/constants/general.js @@ -4,6 +4,10 @@ import keymirror from 'keymirror'; const GeneralTypes = keymirror({ + RECEIVED_APP_STATE: null, + RECEIVED_APP_CREDENTIALS: null, + REMOVED_APP_CREDENTIALS: null, + PING_REQUEST: null, PING_SUCCESS: null, PING_FAILURE: null, diff --git a/service/constants/users.js b/service/constants/users.js index c46425e15..0de62d572 100644 --- a/service/constants/users.js +++ b/service/constants/users.js @@ -54,7 +54,8 @@ const UserTypes = keymirror({ RECEIVED_SESSIONS: null, RECEIVED_REVOKED_SESSION: null, RECEIVED_AUDITS: null, - RECEIVED_STATUSES: null + RECEIVED_STATUSES: null, + RESET_LOGOUT_STATE: null }); export default UserTypes; diff --git a/service/reducers/entities/general.js b/service/reducers/entities/general.js index 750e23885..b564b47f2 100644 --- a/service/reducers/entities/general.js +++ b/service/reducers/entities/general.js @@ -26,7 +26,31 @@ function license(state = {}, action) { } } +function appState(state = false, action) { + switch (action.type) { + case GeneralTypes.RECEIVED_APP_STATE: + return action.data; + + default: + return state; + } +} + +function credentials(state = {}, action) { + switch (action.type) { + case GeneralTypes.RECEIVED_APP_CREDENTIALS: + return Object.assign({}, state, action.data); + + case UsersTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + export default combineReducers({ + appState, + credentials, config, license }); diff --git a/service/reducers/requests/helpers.js b/service/reducers/requests/helpers.js index ed0c3629d..e174f3a1e 100644 --- a/service/reducers/requests/helpers.js +++ b/service/reducers/requests/helpers.js @@ -23,13 +23,18 @@ export function handleRequest(REQUEST, SUCCESS, FAILURE, state, action) { status: RequestStatus.SUCCESS, error: null }; - case FAILURE: + case FAILURE: { + let error = action.error; + if (error instanceof Error) { + error = error.toString(); + } + return { ...state, status: RequestStatus.FAILURE, - error: action.error + error }; - + } default: return state; } diff --git a/service/reducers/requests/users.js b/service/reducers/requests/users.js index 2d68f979d..8b844984d 100644 --- a/service/reducers/requests/users.js +++ b/service/reducers/requests/users.js @@ -26,13 +26,22 @@ function login(state = initialRequestState(), action) { } function logout(state = initialRequestState(), action) { - return handleRequest( - UsersTypes.LOGOUT_REQUEST, - UsersTypes.LOGOUT_SUCCESS, - UsersTypes.LOGOUT_FAILURE, - state, - action - ); + switch (action.type) { + case UsersTypes.LOGOUT_REQUEST: + return {...state, status: RequestStatus.STARTED}; + + case UsersTypes.LOGOUT_SUCCESS: + return {...state, status: RequestStatus.SUCCESS, error: null}; + + case UsersTypes.LOGOUT_FAILURE: + return {...state, status: RequestStatus.FAILURE, error: action.error}; + + case UsersTypes.RESET_LOGOUT_STATE: + return initialRequestState(); + + default: + return state; + } } function getProfiles(state = initialRequestState(), action) { diff --git a/test/service/actions/channels.test.js b/test/service/actions/channels.test.js index 67128449a..354dcceff 100644 --- a/test/service/actions/channels.test.js +++ b/test/service/actions/channels.test.js @@ -96,7 +96,7 @@ describe('Actions.Channels', () => { await getProfiles(0)(store.dispatch, store.getState); Actions.createDirectChannel(TestHelper.basicUser.id, user.id)(store.dispatch, store.getState); }); - }); + }).timeout(3000); it('updateChannel', (done) => { TestHelper.initBasic(Client).then(() => { diff --git a/test/service/actions/teams.test.js b/test/service/actions/teams.test.js index 47ba3c495..b923b477e 100644 --- a/test/service/actions/teams.test.js +++ b/test/service/actions/teams.test.js @@ -317,5 +317,5 @@ describe('Actions.Teams', () => { Actions.addUserToTeam(TestHelper.basicTeam.id, user.id)(store.dispatch, store.getState); }); - }); + }).timeout(3000); });