diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-08-08 16:53:23 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-08-08 16:53:23 +0200 |
| commit | ac98e48c68eadfdd7250eccd38aa492e6f830744 (patch) | |
| tree | 05bfd7a9f05456220f11f40329093b127a5b20a2 /app | |
| parent | d53fda746465c0bb6525371b9d780cbfac90f942 (diff) | |
| parent | a8f2831cd50e98b1d126b8c3169adcf1ef75b8a2 (diff) | |
| download | mullvadvpn-ac98e48c68eadfdd7250eccd38aa492e6f830744.tar.xz mullvadvpn-ac98e48c68eadfdd7250eccd38aa492e6f830744.zip | |
Merge branch 'remove-auto-login'
Diffstat (limited to 'app')
36 files changed, 706 insertions, 345 deletions
diff --git a/app/app.js b/app/app.js index 3171d69b76..369c51be2d 100644 --- a/app/app.js +++ b/app/app.js @@ -3,7 +3,11 @@ import React from 'react'; import { bindActionCreators } from 'redux'; import { Provider } from 'react-redux'; -import { ConnectedRouter, push as pushHistory } from 'connected-react-router'; +import { + ConnectedRouter, + push as pushHistory, + replace as replaceHistory, +} from 'connected-react-router'; import { createMemoryHistory } from 'history'; import { webFrame, ipcRenderer } from 'electron'; @@ -19,10 +23,12 @@ import configureStore from './redux/store'; import accountActions from './redux/account/actions'; import connectionActions from './redux/connection/actions'; import settingsActions from './redux/settings/actions'; +import daemonActions from './redux/daemon/actions'; import type { RpcCredentials } from './lib/rpc-address-file'; import type { DaemonRpcProtocol, + AccountData, ConnectionObserver as DaemonConnectionObserver, } from './lib/daemon-rpc'; import type { ReduxStore } from './redux/store'; @@ -40,6 +46,7 @@ export default class AppRenderer { _memoryHistory = createMemoryHistory(); _reduxStore: ReduxStore; _reduxActions: *; + _accountDataState = new AccountDataState(); constructor() { const store = configureStore(null, this._memoryHistory); @@ -50,7 +57,14 @@ export default class AppRenderer { account: bindActionCreators(accountActions, dispatch), connection: bindActionCreators(connectionActions, dispatch), settings: bindActionCreators(settingsActions, dispatch), - history: bindActionCreators({ push: pushHistory }, dispatch), + daemon: bindActionCreators(daemonActions, dispatch), + history: bindActionCreators( + { + push: pushHistory, + replace: replaceHistory, + }, + dispatch, + ), }; this._openConnectionObserver = this._daemonRpc.addOpenConnectionObserver(() => { @@ -110,23 +124,27 @@ export default class AppRenderer { async login(accountToken: AccountToken) { const actions = this._reduxActions; + const history = this._memoryHistory; actions.account.startLogin(accountToken); - log.debug('Attempting to login'); + log.debug('Logging in'); try { const accountData = await this._daemonRpc.getAccountData(accountToken); await this._daemonRpc.setAccount(accountToken); - actions.account.loginSuccessful(accountData.expiry); + actions.account.updateAccountExpiry(accountData.expiry); + actions.account.loginSuccessful(); // Redirect the user after some time to allow for // the 'Login Successful' screen to be visible setTimeout(async () => { - actions.history.push('/connect'); + if (history.location.pathname === '/login') { + actions.history.replace('/connect'); + } try { - log.debug('Auto-connecting the tunnel...'); + log.debug('Auto-connecting the tunnel'); await this.connectTunnel(); } catch (error) { log.error(`Failed to auto-connect the tunnel: ${error.message}`); @@ -139,33 +157,33 @@ export default class AppRenderer { } } - async _autologin() { + async _restoreSession() { const actions = this._reduxActions; - actions.account.startLogin(); + const history = this._memoryHistory; - log.debug('Attempting to log in automatically'); - - try { - const accountToken = await this._daemonRpc.getAccount(); - if (!accountToken) { - throw new NoAccountError(); - } + log.debug('Restoring session'); - log.debug(`The daemon had an account number stored: ${accountToken}`); - actions.account.startLogin(accountToken); + const accountToken = await this._daemonRpc.getAccount(); - const accountData = await this._daemonRpc.getAccountData(accountToken); - log.debug('The stored account number still exists:', accountData); + if (accountToken) { + log.debug(`Got account token: ${accountToken}`); + actions.account.updateAccountToken(accountToken); + actions.account.loginSuccessful(); - actions.account.loginSuccessful(accountData.expiry); - actions.history.push('/connect'); - } catch (e) { - log.warn('Unable to autologin,', e.message); + // take user to main view if user is still at launch screen `/` + if (history.location.pathname === '/') { + actions.history.replace('/connect'); + } + } else { + log.debug('No account set, showing login view.'); - actions.account.autoLoginFailed(); - actions.history.push('/'); + // show window when account is not set + ipcRenderer.send('show-window'); - throw e; + // take user to `/login` screen if user is at launch screen `/` + if (history.location.pathname === '/') { + actions.history.replace('/login'); + } } } @@ -179,7 +197,10 @@ export default class AppRenderer { this._fetchAccountHistory(), ]); actions.account.loggedOut(); - actions.history.push('/'); + actions.history.replace('/login'); + + // reset account data state on log out + this._accountDataState = new AccountDataState(); } catch (e) { log.info('Failed to logout: ', e.message); } @@ -262,13 +283,29 @@ export default class AppRenderer { async updateAccountExpiry() { const actions = this._reduxActions; + const accountDataState = this._accountDataState; + + // Bail if something else requested an update to account data + // or if account data cache was updated recently. + if (accountDataState.isUpdating() || !accountDataState.needsUpdate()) { + return; + } + try { const accountToken = await this._daemonRpc.getAccount(); if (!accountToken) { throw new NoAccountError(); } - const accountData = await this._daemonRpc.getAccountData(accountToken); - actions.account.updateAccountExpiry(accountData.expiry); + + const accountData = await accountDataState.update(() => { + return this._daemonRpc.getAccountData(accountToken); + }); + + // Check if account token is still the same after receiving account data + const currentAccountToken = this._reduxStore.getState().account.accountToken; + if (currentAccountToken === accountToken) { + actions.account.updateAccountExpiry(accountData.expiry); + } } catch (e) { log.error(`Failed to update account expiry: ${e.message}`); } @@ -370,6 +407,10 @@ export default class AppRenderer { } async _onOpenConnection() { + // save to redux that the app connected to daemon + this._reduxActions.daemon.connected(); + + // reset the reconnect backoff when connection established. this._reconnectBackoff.reset(); // authenticate once connected @@ -383,16 +424,11 @@ export default class AppRenderer { log.error(`Cannot authenticate: ${error.message}`); } - // autologin + // attempt to restore the session try { - await this._autologin(); + await this._restoreSession(); } catch (error) { - if (error instanceof NoAccountError) { - log.debug('No previously configured account set, showing window'); - ipcRenderer.send('show-window'); - } else { - log.error(`Failed to autologin: ${error.message}`); - } + log.error(`Failed to restore session: ${error.message}`); } // make sure to re-subscribe to state notifications when connection is re-established. @@ -422,6 +458,12 @@ export default class AppRenderer { } async _onCloseConnection(error: ?Error) { + const actions = this._reduxActions; + + // save to redux that the app disconnected from daemon + actions.daemon.disconnected(); + + // recover connection on error if (error) { log.debug(`Lost connection to daemon: ${error.message}`); @@ -436,6 +478,9 @@ export default class AppRenderer { this._reconnectBackoff.attempt(() => { recover(); }); + + // take user back to the launch screen `/`. + actions.history.replace('/'); } } @@ -555,3 +600,32 @@ export default class AppRenderer { } } } + +// Helper class to keep track of account data updates +class AccountDataState { + _expiresAt: ?Date; + _isUpdating = false; + + isUpdating() { + return this._isUpdating; + } + + needsUpdate() { + return !this._expiresAt || this._expiresAt < new Date(); + } + + async update(fn: () => Promise<AccountData>): Promise<AccountData> { + this._isUpdating = true; + + try { + const accountData = await fn(); + this._expiresAt = new Date(Date.now() + 60 * 1000); // 60s expiration + + return accountData; + } catch (error) { + throw error; + } finally { + this._isUpdating = false; + } + } +} diff --git a/app/components/Account.js b/app/components/Account.js index 2edf277fa4..700f33af2b 100644 --- a/app/components/Account.js +++ b/app/components/Account.js @@ -1,7 +1,7 @@ // @flow import moment from 'moment'; import * as React from 'react'; -import { Component, Text, View, App, Types } from 'reactxp'; +import { Component, Text, View } from 'reactxp'; import * as AppButton from './AppButton'; import { Layout, Container } from './Layout'; import NavigationBar, { BackBarItem } from './NavigationBar'; @@ -9,10 +9,11 @@ import SettingsHeader, { HeaderTitle } from './SettingsHeader'; import styles from './AccountStyles'; import Img from './Img'; import { formatAccount } from '../lib/formatters'; +import WindowStateObserver from '../lib/window-state-observer'; import type { AccountToken } from '../lib/daemon-rpc'; -export type AccountProps = { +type Props = { accountToken: AccountToken, accountExpiry: string, expiryLocale: string, @@ -28,27 +29,23 @@ type State = { showAccountTokenCopiedMessage: boolean, }; -export default class Account extends Component<AccountProps, State> { +export default class Account extends Component<Props, State> { state = { isRefreshingExpiry: false, showAccountTokenCopiedMessage: false, }; - _activationStateToken: ?Types.SubscriptionToken; - _isMounted = false; - _copyTimer: ?TimeoutID; + _windowStateObserver = new WindowStateObserver(); componentDidMount() { this._isMounted = true; this._refreshAccountExpiry(); - this._activationStateToken = App.activationStateChangedEvent.subscribe((activationState) => { - if (activationState === Types.AppActivationState.Active) { - this._refreshAccountExpiry(); - } - }); + this._windowStateObserver.onShow = () => { + this._refreshAccountExpiry(); + }; } componentWillUnmount() { @@ -58,11 +55,7 @@ export default class Account extends Component<AccountProps, State> { clearTimeout(this._copyTimer); } - const activationStateToken = this._activationStateToken; - if (activationStateToken) { - activationStateToken.unsubscribe(); - this._activationStateToken = null; - } + this._windowStateObserver.dispose(); } onAccountTokenClick() { @@ -119,7 +112,7 @@ export default class Account extends Component<AccountProps, State> { <Text style={styles.account__row_label}>Paid until</Text> {isOutOfTime ? ( <Text style={styles.account__out_of_time} testName="account__out_of_time"> - OUT OF TIME + {'OUT OF TIME'} </Text> ) : ( <Text style={styles.account__row_value}>{formattedExpiry}</Text> diff --git a/app/components/AccountStyles.js b/app/components/AccountStyles.js index 14fc478a54..a96a8fcff6 100644 --- a/app/components/AccountStyles.js +++ b/app/components/AccountStyles.js @@ -56,6 +56,7 @@ export default { account__row_value: { fontFamily: 'Open Sans', fontSize: 16, + lineHeight: 19, fontWeight: '800', color: colors.white, }, diff --git a/app/components/Cell.js b/app/components/Cell.js index 2b3c06a3b1..80b783352f 100644 --- a/app/components/Cell.js +++ b/app/components/Cell.js @@ -19,6 +19,7 @@ const styles = { flexDirection: 'row', alignItems: 'center', alignContent: 'center', + cursor: 'default', }, cellHover: { backgroundColor: colors.blue80, diff --git a/app/components/Connect.js b/app/components/Connect.js index 037ab02495..9d7f52f7b1 100644 --- a/app/components/Connect.js +++ b/app/components/Connect.js @@ -3,19 +3,20 @@ import moment from 'moment'; import * as React from 'react'; import { Layout, Container, Header } from './Layout'; +import { SettingsBarButton, Brand } from './HeaderBar'; import { Component, Text, View, Types } from 'reactxp'; import * as AppButton from './AppButton'; import Img from './Img'; import Accordion from './Accordion'; import styles from './ConnectStyles'; - import { NoCreditError, NoInternetError } from '../errors'; import Map from './Map'; +import WindowStateObserver from '../lib/window-state-observer'; import type { HeaderBarStyle } from './HeaderBar'; import type { ConnectionReduxState } from '../redux/connection/reducers'; -export type ConnectProps = { +type Props = { connection: ConnectionReduxState, accountExpiry: string, selectedRelayName: string, @@ -25,22 +26,24 @@ export type ConnectProps = { onCopyIP: () => void, onDisconnect: () => void, onExternalLink: (type: string) => void, + updateAccountExpiry: () => Promise<void>, }; -type ConnectState = { +type State = { showCopyIPMessage: boolean, mapOffset: [number, number], }; -export default class Connect extends Component<ConnectProps, ConnectState> { +export default class Connect extends Component<Props, State> { state = { showCopyIPMessage: false, mapOffset: [0, 0], }; _copyTimer: ?TimeoutID; + _windowStateObserver = new WindowStateObserver(); - shouldComponentUpdate(nextProps: ConnectProps, nextState: ConnectState) { + shouldComponentUpdate(nextProps: Props, nextState: State) { const { connection: prevConnection, ...otherPrevProps } = this.props; const { connection: nextConnection, ...otherNextProps } = nextProps; @@ -56,10 +59,20 @@ export default class Connect extends Component<ConnectProps, ConnectState> { ); } + componentDidMount() { + this.props.updateAccountExpiry(); + + this._windowStateObserver.onShow = () => { + this.props.updateAccountExpiry(); + }; + } + componentWillUnmount() { if (this._copyTimer) { clearTimeout(this._copyTimer); } + + this._windowStateObserver.dispose(); } render() { @@ -68,12 +81,10 @@ export default class Connect extends Component<ConnectProps, ConnectState> { return ( <Layout> - <Header - style={this.headerStyle()} - showSettings={true} - onSettings={this.props.onSettings} - testName="header" - /> + <Header barStyle={this.headerBarStyle()} testName="header"> + <Brand /> + <SettingsBarButton onPress={this.props.onSettings} /> + </Header> <Container>{child}</Container> </Layout> ); @@ -324,15 +335,17 @@ export default class Connect extends Component<ConnectProps, ConnectState> { // Private - headerStyle(): HeaderBarStyle { - switch (this.props.connection.status) { + headerBarStyle(): HeaderBarStyle { + const { status } = this.props.connection; + switch (status) { case 'disconnected': return 'error'; case 'connecting': case 'connected': return 'success'; + default: + throw new Error(`Invalid ConnectionState: ${(status: empty)}`); } - throw new Error('Invalid ConnectionState'); } networkSecurityStyle(): Types.Style { diff --git a/app/components/CustomScrollbars.js b/app/components/CustomScrollbars.js index be74bfddb5..a149faff75 100644 --- a/app/components/CustomScrollbars.js +++ b/app/components/CustomScrollbars.js @@ -176,7 +176,7 @@ export default class CustomScrollbars extends React.Component<Props, State> { return offsetTop - (scrollable.offsetHeight - child.clientHeight) * 0.5; default: - throw new Error(`Unknown enum type for ScrollPosition: ${scrollPosition}`); + throw new Error(`Unknown enum type for ScrollPosition: ${(scrollPosition: empty)}`); } } diff --git a/app/components/HeaderBar.js b/app/components/HeaderBar.js index a46856eb46..02b1d86e31 100644 --- a/app/components/HeaderBar.js +++ b/app/components/HeaderBar.js @@ -1,55 +1,148 @@ // @flow import React from 'react'; -import { Component, Text, Button, View } from 'reactxp'; - +import { Component, Text, Button, View, Styles } from 'reactxp'; import Img from './Img'; - -import styles from './HeaderBarStyles'; -import platformStyles from './HeaderBarPlatformStyles'; +import { colors } from '../config'; export type HeaderBarStyle = 'default' | 'defaultDark' | 'error' | 'success'; -export type HeaderBarProps = { - style: HeaderBarStyle, - showSettings: boolean, - onSettings: ?() => void, +type HeaderBarProps = { + barStyle: HeaderBarStyle, +}; + +const headerBarStyles = { + container: { + base: Styles.createViewStyle({ + paddingTop: 12, + paddingBottom: 12, + paddingLeft: 12, + paddingRight: 12, + }), + platformOverride: { + darwin: Styles.createViewStyle({ + paddingTop: 24, + }), + linux: Styles.createViewStyle({ + WebkitAppRegion: 'drag', + }), + }, + }, + content: Styles.createViewStyle({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + // the size of "brand" logo + minHeight: 51, + }), + barStyle: { + default: Styles.createViewStyle({ + backgroundColor: colors.blue, + }), + defaultDark: Styles.createViewStyle({ + backgroundColor: colors.darkBlue, + }), + error: Styles.createViewStyle({ + backgroundColor: colors.red, + }), + success: Styles.createViewStyle({ + backgroundColor: colors.green, + }), + }, }; export default class HeaderBar extends Component<HeaderBarProps> { static defaultProps: HeaderBarProps = { - style: 'default', - showSettings: false, - onSettings: null, + barStyle: 'default', }; render() { - const containerClass = [ - styles['headerbar'], - platformStyles[process.platform], - styles['style_' + this.props.style], + const style = [ + headerBarStyles.container.base, + headerBarStyles.container.platformOverride[process.platform], + headerBarStyles.barStyle[this.props.barStyle], + this.props.style, ]; return ( - <View style={containerClass}> - <View style={styles.container} testName="headerbar__container"> - <Img height={50} width={50} source="logo-icon" /> - <Text style={styles.title}>MULLVAD VPN</Text> - </View> + <View style={style}> + <View style={headerBarStyles.content}>{this.props.children}</View> + </View> + ); + } +} - {this.props.showSettings ? ( - <Button - style={styles.settings} - onPress={this.props.onSettings} - testName="headerbar__settings"> - <Img - height={24} - width={24} - source="icon-settings" - style={[styles.settings_icon, platformStyles.settings_icon]} - hoverStyle={styles.settings_icon_hover} - /> - </Button> - ) : null} +const brandStyles = { + container: Styles.createViewStyle({ + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }), + title: Styles.createTextStyle({ + fontFamily: 'DINPro', + fontSize: 24, + fontWeight: '900', + lineHeight: 30, + letterSpacing: -0.5, + color: colors.white60, + marginLeft: 8, + }), +}; + +export class Brand extends Component { + render() { + return ( + <View style={brandStyles.container} testName="headerbar__container"> + <Img width={50} height={50} source="logo-icon" /> + <Text style={brandStyles.title}>{'MULLVAD VPN'}</Text> </View> ); } } + +type SettingsButtonProps = { + onPress: ?() => void, +}; + +const settingsBarButtonStyles = { + container: { + base: Styles.createViewStyle({ + cursor: 'default', + padding: 0, + marginLeft: 8, + }), + platformOverride: { + linux: Styles.createViewStyle({ + WebkitAppRegion: 'no-drag', + }), + }, + }, + icon: { + normal: Styles.createViewStyle({ + color: colors.white60, + }), + hover: Styles.createViewStyle({ + color: colors.white, + }), + }, +}; + +export class SettingsBarButton extends Component<SettingsButtonProps> { + render() { + return ( + <Button + style={[ + settingsBarButtonStyles.container.base, + settingsBarButtonStyles.container.platformOverride[process.platform], + ]} + onPress={this.props.onPress} + testName="headerbar__settings"> + <Img + height={24} + width={24} + source="icon-settings" + style={settingsBarButtonStyles.icon.normal} + hoverStyle={settingsBarButtonStyles.icon.hover} + /> + </Button> + ); + } +} diff --git a/app/components/HeaderBarPlatformStyles.android.js b/app/components/HeaderBarPlatformStyles.android.js deleted file mode 100644 index f2f97beb65..0000000000 --- a/app/components/HeaderBarPlatformStyles.android.js +++ /dev/null @@ -1,2 +0,0 @@ -import { createViewStyles } from '../lib/styles'; -export default { ...createViewStyles({}) }; diff --git a/app/components/HeaderBarPlatformStyles.js b/app/components/HeaderBarPlatformStyles.js deleted file mode 100644 index c60967797a..0000000000 --- a/app/components/HeaderBarPlatformStyles.js +++ /dev/null @@ -1,16 +0,0 @@ -// @flow -import { createViewStyles } from '../lib/styles'; - -export default { - ...createViewStyles({ - darwin: { - paddingTop: 24, - }, - linux: { - WebkitAppRegion: 'drag', - }, - settings_icon: { - WebkitAppRegion: 'no-drag', - }, - }), -}; diff --git a/app/components/HeaderBarStyles.js b/app/components/HeaderBarStyles.js deleted file mode 100644 index 7d3c94cdc9..0000000000 --- a/app/components/HeaderBarStyles.js +++ /dev/null @@ -1,53 +0,0 @@ -// @flow -import { createTextStyles, createViewStyles } from '../lib/styles'; -import { colors } from '../config'; - -export default { - ...createViewStyles({ - headerbar: { - paddingTop: 12, - paddingBottom: 12, - paddingLeft: 12, - paddingRight: 12, - backgroundColor: colors.blue, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - }, - style_defaultDark: { - backgroundColor: colors.darkBlue, - }, - style_error: { - backgroundColor: colors.red, - }, - style_success: { - backgroundColor: colors.green, - }, - container: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - }, - settings: { - cursor: 'default', - padding: 0, - }, - settings_icon: { - color: colors.white60, - }, - settings_icon_hover: { - color: colors.white, - }, - }), - ...createTextStyles({ - title: { - fontFamily: 'DINPro', - fontSize: 24, - fontWeight: '900', - lineHeight: 30, - letterSpacing: -0.5, - color: colors.white60, - marginLeft: 8, - }, - }), -}; diff --git a/app/components/Img.js b/app/components/Img.js index 1d6f6613f4..6b34f99360 100644 --- a/app/components/Img.js +++ b/app/components/Img.js @@ -4,6 +4,8 @@ import { View, Component, Types } from 'reactxp'; type Props = { source: string, + width?: number, + heigth?: number, tintColor?: string, hoverStyle?: Types.ViewStyle, disabled?: boolean, @@ -20,7 +22,7 @@ export default class Img extends Component<Props, State> { getHoverStyle = () => (this.state.hovered ? this.props.hoverStyle || null : null); render() { - const { source, style, onMouseEnter, onMouseLeave, ...otherProps } = this.props; + const { source, width, heigth, style, onMouseEnter, onMouseLeave, ...otherProps } = this.props; const tintColor = this.props.tintColor; const url = './assets/images/' + source + '.svg'; let image; @@ -36,6 +38,8 @@ export default class Img extends Component<Props, State> { }}> <img src={url} + width={width} + height={heigth} style={{ visibility: 'hidden', }} @@ -43,7 +47,7 @@ export default class Img extends Component<Props, State> { </div> ); } else { - image = <img src={url} />; + image = <img src={url} width={width} height={heigth} />; } return ( diff --git a/app/components/Launch.js b/app/components/Launch.js new file mode 100644 index 0000000000..bab877dd53 --- /dev/null +++ b/app/components/Launch.js @@ -0,0 +1,58 @@ +// @flow +import * as React from 'react'; +import { Component, Styles, View, Text } from 'reactxp'; +import { Layout, Container, Header } from './Layout'; +import { SettingsBarButton } from './HeaderBar'; +import Img from './Img'; +import { colors } from '../config'; + +const styles = { + container: Styles.createViewStyle({ + flex: 1, + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + marginTop: -150, + }), + logo: Styles.createViewStyle({ + marginBottom: 4, + }), + title: Styles.createTextStyle({ + fontFamily: 'DINPro', + fontSize: 24, + fontWeight: '900', + lineHeight: 30, + letterSpacing: -0.5, + color: colors.white60, + marginBottom: 4, + }), + subtitle: Styles.createTextStyle({ + fontFamily: 'Open Sans', + fontSize: 14, + lineHeight: 20, + color: colors.white40, + }), +}; + +type Props = { + openSettings: () => void, +}; + +export default class Launch extends Component<Props> { + render() { + return ( + <Layout> + <Header> + <SettingsBarButton onPress={this.props.openSettings} /> + </Header> + <Container> + <View style={styles.container} testName="headerbar__container"> + <Img height={120} width={120} source="logo-icon" style={styles.logo} /> + <Text style={styles.title}>{'MULLVAD VPN'}</Text> + <Text style={styles.subtitle}>{'Connecting to daemon...'}</Text> + </View> + </Container> + </Layout> + ); + } +} diff --git a/app/components/Layout.js b/app/components/Layout.js index 2699b3e43d..1eb6984797 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -1,19 +1,16 @@ // @flow import * as React from 'react'; -import HeaderBar from './HeaderBar'; import { View, Component } from 'reactxp'; - -import type { HeaderBarProps } from './HeaderBar'; - +import HeaderBar from './HeaderBar'; import styles from './LayoutStyles'; -export class Header extends Component<HeaderBarProps> { +export class Header extends Component<React.ElementProps<typeof HeaderBar>> { static defaultProps = HeaderBar.defaultProps; render() { return ( - <View style={styles.header}> - <HeaderBar {...this.props} /> + <View style={[styles.header, this.props.style]}> + <HeaderBar barStyle={this.props.barStyle}>{this.props.children}</HeaderBar> </View> ); } diff --git a/app/components/Login.js b/app/components/Login.js index d902bba1bc..b410f80553 100644 --- a/app/components/Login.js +++ b/app/components/Login.js @@ -2,6 +2,7 @@ import * as React from 'react'; import { Component, Text, View, Animated, Styles, UserInterface } from 'reactxp'; import { Layout, Container, Header } from './Layout'; +import { SettingsBarButton, Brand } from './HeaderBar'; import AccountInput from './AccountInput'; import Accordion from './Accordion'; import { formatAccount } from '../lib/formatters'; @@ -94,7 +95,10 @@ export default class Login extends Component<Props, State> { render() { return ( <Layout> - <Header showSettings={true} onSettings={this.props.openSettings} /> + <Header> + <Brand /> + <SettingsBarButton onPress={this.props.openSettings} /> + </Header> <Container> <View style={styles.login_form}> {this._getStatusIcon()} diff --git a/app/components/LoginStyles.js b/app/components/LoginStyles.js index d9094a2f13..61e5026a71 100644 --- a/app/components/LoginStyles.js +++ b/app/components/LoginStyles.js @@ -14,9 +14,8 @@ export default { }, status_icon: { flex: 0, - height: 48, marginBottom: 30, - justifyContent: 'center', + alignItems: 'center', }, login_form: { flex: 1, @@ -88,13 +87,15 @@ export default { backgroundColor: colors.darkBlue, }, account_dropdown__item: { - paddingTop: 10, - paddingRight: 12, - paddingLeft: 12, - paddingBottom: 12, + paddingTop: 0, + paddingRight: 0, + paddingLeft: 0, + paddingBottom: 0, marginBottom: 0, flexDirection: 'row', + alignItems: 'stretch', backgroundColor: colors.white60, + cursor: 'default', }, account_dropdown__item_hover: { backgroundColor: colors.white40, @@ -102,6 +103,11 @@ export default { account_dropdown__remove: { justifyContent: 'center', color: colors.blue40, + paddingTop: 10, + paddingRight: 12, + paddingBottom: 12, + paddingLeft: 12, + marginLeft: 0, }, account_dropdown__remove_cell_hover: { color: colors.blue60, @@ -136,6 +142,7 @@ export default { subtitle: { fontFamily: 'Open Sans', fontSize: 13, + lineHeight: 15, fontWeight: '600', letterSpacing: -0.2, color: colors.white80, @@ -165,6 +172,11 @@ export default { borderWidth: 0, textAlign: 'left', marginLeft: 0, + paddingTop: 10, + paddingRight: 0, + paddingLeft: 12, + paddingBottom: 12, + cursor: 'default', }, }), }; diff --git a/app/components/Settings.js b/app/components/Settings.js index 4a8784fe0e..0ed45c7685 100644 --- a/app/components/Settings.js +++ b/app/components/Settings.js @@ -10,14 +10,14 @@ import SettingsHeader, { HeaderTitle } from './SettingsHeader'; import CustomScrollbars from './CustomScrollbars'; import styles from './SettingsStyles'; import Img from './Img'; +import WindowStateObserver from '../lib/window-state-observer'; -import type { AccountReduxState } from '../redux/account/reducers'; -import type { SettingsReduxState } from '../redux/settings/reducers'; +import type { LoginState } from '../redux/account/reducers'; -export type SettingsProps = { - account: AccountReduxState, - settings: SettingsReduxState, - version: string, +type Props = { + loginState: LoginState, + accountExpiry: ?string, + appVersion: string, onQuit: () => void, onClose: () => void, onViewAccount: () => void, @@ -25,9 +25,24 @@ export type SettingsProps = { onViewPreferences: () => void, onViewAdvancedSettings: () => void, onExternalLink: (type: string) => void, + updateAccountExpiry: () => Promise<void>, }; -export default class Settings extends Component<SettingsProps> { +export default class Settings extends Component<Props> { + _windowStateObserver = new WindowStateObserver(); + + componentDidMount() { + this.props.updateAccountExpiry(); + + this._windowStateObserver.onShow = () => { + this.props.updateAccountExpiry(); + }; + } + + componentWillUnmount() { + this._windowStateObserver.dispose(); + } + render() { return ( <Layout> @@ -60,17 +75,17 @@ export default class Settings extends Component<SettingsProps> { } _renderTopButtons() { - const isLoggedIn = this.props.account.status === 'ok'; + const isLoggedIn = this.props.loginState === 'ok'; if (!isLoggedIn) { return null; } - let isOutOfTime = false, - formattedExpiry = ''; - const expiryIso = this.props.account.expiry; + let isOutOfTime = false; + let formattedExpiry = ''; + const expiryIso = this.props.accountExpiry; if (isLoggedIn && expiryIso) { - const expiry = moment(this.props.account.expiry); + const expiry = moment(expiryIso); isOutOfTime = expiry.isSameOrBefore(moment()); formattedExpiry = (expiry.fromNow(true) + ' left').toUpperCase(); } @@ -86,7 +101,7 @@ export default class Settings extends Component<SettingsProps> { <Cell.SubText testName="settings__account_paid_until_subtext" style={styles.settings__account_paid_until_Label__error}> - OUT OF TIME + {'OUT OF TIME'} </Cell.SubText> <Img height={12} width={7} source="icon-chevron" /> </Cell.CellButton> @@ -136,7 +151,7 @@ export default class Settings extends Component<SettingsProps> { // the version in package.json has to be semver, but we use a YEAR.release-channel // version scheme. in package.json we thus have to write YEAR.release.X-channel and // this function is responsible for removing .X part. - return this.props.version + return this.props.appVersion .replace('.0-', '-') // remove the .0 in 2018.1.0-beta9 .replace(/\.0$/, ''); // remove the .0 in 2018.1.0 } diff --git a/app/containers/AccountPage.js b/app/containers/AccountPage.js index 078c893748..712466f191 100644 --- a/app/containers/AccountPage.js +++ b/app/containers/AccountPage.js @@ -3,7 +3,7 @@ import { remote } from 'electron'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import Account from '../components/Account'; import accountActions from '../redux/account/actions'; import { links } from '../config'; @@ -19,7 +19,7 @@ const mapStateToProps = (state: ReduxState) => ({ }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { const { copyAccountToken } = bindActionCreators(accountActions, dispatch); - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { updateAccountExpiry: () => props.app.updateAccountExpiry(), onCopyAccountToken: () => copyAccountToken(), @@ -27,7 +27,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => props.app.logout(); }, onClose: () => { - pushHistory('/settings'); + history.goBack(); }, onBuyMore: () => openLink(links['purchase']), }; diff --git a/app/containers/AdvancedSettingsPage.js b/app/containers/AdvancedSettingsPage.js index 9f88228cc3..bf584096b9 100644 --- a/app/containers/AdvancedSettingsPage.js +++ b/app/containers/AdvancedSettingsPage.js @@ -1,7 +1,8 @@ // @flow import { connect } from 'react-redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; +import { bindActionCreators } from 'redux'; import { AdvancedSettings } from '../components/AdvancedSettings'; import RelaySettingsBuilder from '../lib/relay-settings-builder'; import { log } from '../lib/platform'; @@ -26,9 +27,11 @@ const mapStateToProps = (state: ReduxState) => { }; const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => dispatch(push('/settings')), - + onClose: () => { + history.goBack(); + }, onUpdate: async (protocol, port) => { const relayUpdate = RelaySettingsBuilder.normal() .tunnel.openvpn((openvpn) => { diff --git a/app/containers/ConnectPage.js b/app/containers/ConnectPage.js index 6f906f1c82..0ad43e9c0b 100644 --- a/app/containers/ConnectPage.js +++ b/app/containers/ConnectPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push as pushHistory } from 'connected-react-router'; +import { push } from 'connected-react-router'; import { links } from '../config'; import Connect from '../components/Connect'; import connectActions from '../redux/connection/actions'; @@ -56,7 +56,7 @@ const mapStateToProps = (state: ReduxState) => { const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { const { copyIPAddress } = bindActionCreators(connectActions, dispatch); - const history = bindActionCreators({ push: pushHistory }, dispatch); + const history = bindActionCreators({ push }, dispatch); return { onSettings: () => { @@ -83,6 +83,7 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => } }, onExternalLink: (type) => openLink(links[type]), + updateAccountExpiry: () => props.app.updateAccountExpiry(), }; }; diff --git a/app/containers/LaunchPage.js b/app/containers/LaunchPage.js new file mode 100644 index 0000000000..1b265dd030 --- /dev/null +++ b/app/containers/LaunchPage.js @@ -0,0 +1,23 @@ +// @flow +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { push } from 'connected-react-router'; +import Launch from '../components/Launch'; + +import type { ReduxState, ReduxDispatch } from '../redux/store'; +import type { SharedRouteProps } from '../routes'; + +const mapStateToProps = (_state: ReduxState) => ({}); +const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { + const history = bindActionCreators({ push }, dispatch); + return { + openSettings: () => { + history.push('/settings'); + }, + }; +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(Launch); diff --git a/app/containers/LoginPage.js b/app/containers/LoginPage.js index 74823f3925..539f05c5ac 100644 --- a/app/containers/LoginPage.js +++ b/app/containers/LoginPage.js @@ -21,11 +21,11 @@ const mapStateToProps = (state: ReduxState) => { }; }; const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ push }, dispatch); const { resetLoginError, updateAccountToken } = bindActionCreators(accountActions, dispatch); return { openSettings: () => { - pushHistory('/settings'); + history.push('/settings'); }, login: (account) => { props.app.login(account); diff --git a/app/containers/PreferencesPage.js b/app/containers/PreferencesPage.js index 8534a70d74..ee2709c767 100644 --- a/app/containers/PreferencesPage.js +++ b/app/containers/PreferencesPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import Preferences from '../components/Preferences'; import { log, getOpenAtLogin, setOpenAtLogin } from '../lib/platform'; @@ -15,9 +15,11 @@ const mapStateToProps = (state: ReduxState) => ({ }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => pushHistory('/settings'), + onClose: () => { + history.goBack(); + }, getAutoStart: () => { return getOpenAtLogin(); }, diff --git a/app/containers/SelectLocationPage.js b/app/containers/SelectLocationPage.js index 6307e9421b..037dd67a0b 100644 --- a/app/containers/SelectLocationPage.js +++ b/app/containers/SelectLocationPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import SelectLocation from '../components/SelectLocation'; import RelaySettingsBuilder from '../lib/relay-settings-builder'; import { log } from '../lib/platform'; @@ -14,9 +14,9 @@ const mapStateToProps = (state: ReduxState) => ({ settings: state.settings, }); const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => pushHistory('/connect'), + onClose: () => history.goBack(), onSelect: async (relayLocation) => { try { const relayUpdate = RelaySettingsBuilder.normal() @@ -27,9 +27,9 @@ const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => await props.app.fetchRelaySettings(); await props.app.connectTunnel(); - pushHistory('/connect'); + history.goBack(); } catch (e) { - log.error('Failed to select server: ', e.message); + log.error(`Failed to select server: ${e.message}`); } }, }; diff --git a/app/containers/SettingsPage.js b/app/containers/SettingsPage.js index 8ffd0691fd..06c0cd6060 100644 --- a/app/containers/SettingsPage.js +++ b/app/containers/SettingsPage.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { push, goBack } from 'connected-react-router'; import Settings from '../components/Settings'; import { links } from '../config'; import { getAppVersion, openLink, exit } from '../lib/platform'; @@ -11,20 +11,21 @@ import type { ReduxState, ReduxDispatch } from '../redux/store'; import type { SharedRouteProps } from '../routes'; const mapStateToProps = (state: ReduxState) => ({ - account: state.account, - settings: state.settings, - version: getAppVersion(), + loginState: state.account.status, + accountExpiry: state.account.expiry, + appVersion: getAppVersion(), }); -const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { - const { push: pushHistory } = bindActionCreators({ push }, dispatch); +const mapDispatchToProps = (dispatch: ReduxDispatch, props: SharedRouteProps) => { + const history = bindActionCreators({ push, goBack }, dispatch); return { onQuit: () => exit(), - onClose: () => pushHistory('/connect'), - onViewAccount: () => pushHistory('/settings/account'), - onViewSupport: () => pushHistory('/settings/support'), - onViewPreferences: () => pushHistory('/settings/preferences'), - onViewAdvancedSettings: () => pushHistory('/settings/advanced'), + onClose: () => history.goBack(), + onViewAccount: () => history.push('/settings/account'), + onViewSupport: () => history.push('/settings/support'), + onViewPreferences: () => history.push('/settings/preferences'), + onViewAdvancedSettings: () => history.push('/settings/advanced'), onExternalLink: (type) => openLink(links[type]), + updateAccountExpiry: () => props.app.updateAccountExpiry(), }; }; diff --git a/app/containers/SupportPage.js b/app/containers/SupportPage.js index ffab2d2095..78d1b41ebe 100644 --- a/app/containers/SupportPage.js +++ b/app/containers/SupportPage.js @@ -1,7 +1,7 @@ // @flow import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; -import { push } from 'connected-react-router'; +import { goBack } from 'connected-react-router'; import Support from '../components/Support'; import { openItem } from '../lib/platform'; import { collectProblemReport, sendProblemReport } from '../lib/problem-report'; @@ -18,10 +18,10 @@ const mapStateToProps = (state: ReduxState) => ({ const mapDispatchToProps = (dispatch: ReduxDispatch, _props: SharedRouteProps) => { const { saveReportForm, clearReportForm } = bindActionCreators(supportActions, dispatch); - const { push: pushHistory } = bindActionCreators({ push }, dispatch); + const history = bindActionCreators({ goBack }, dispatch); return { - onClose: () => pushHistory('/settings'), + onClose: () => history.goBack(), viewLog: (path) => openItem(path), saveReportForm, clearReportForm, diff --git a/app/lib/daemon-rpc.js b/app/lib/daemon-rpc.js index fb3915e068..163ea170d0 100644 --- a/app/lib/daemon-rpc.js +++ b/app/lib/daemon-rpc.js @@ -327,7 +327,7 @@ export class DaemonRpc implements DaemonRpcProtocol { const validatedObject = validate(RelaySettingsSchema, response); /* $FlowFixMe: - There is no way to express the constraints with string literals, i.e: + There is no way to express constraints with string literals, i.e: RelaySettingsSchema constraint: oneOf(string, object) diff --git a/app/lib/jsonrpc-transport.js b/app/lib/jsonrpc-transport.js index 2fa98df988..cbf1e7dce8 100644 --- a/app/lib/jsonrpc-transport.js +++ b/app/lib/jsonrpc-transport.js @@ -123,7 +123,7 @@ const DEFAULT_TIMEOUT_MILLIS = 5000; export default class JsonRpcTransport extends EventEmitter { _unansweredRequests: Map<string, UnansweredRequest> = new Map(); _subscriptions: Map<string | number, (mixed) => void> = new Map(); - _websocket: ?WebSocket; + _webSocket: ?WebSocket; _websocketFactory: (string) => WebSocket; constructor(websocketFactory: ?(string) => WebSocket) { @@ -133,46 +133,60 @@ export default class JsonRpcTransport extends EventEmitter { } /// Connect websocket - connect(connectionString: string) { - this.disconnect(); + connect(connectionString: string): Promise<void> { + return new Promise((resolve, reject) => { + this.disconnect(); - log.info('Connecting to websocket', connectionString); + log.info('Connecting to websocket', connectionString); - const websocket = this._websocketFactory(connectionString); + const webSocket = this._websocketFactory(connectionString); - websocket.onopen = () => { - log.info('Websocket is connected'); - this.emit('open'); - }; + // A flag used to determine if Promise was resolved. + let isPromiseResolved = false; - websocket.onmessage = (event) => { - const data = event.data; - if (typeof data === 'string') { - this._onMessage(data); - } else { - log.error('Got invalid reply from the server', event); - } - }; + webSocket.onopen = () => { + log.info('Websocket is connected'); + this.emit('open'); + + // Resolve the Promise + resolve(); + isPromiseResolved = true; + }; + + webSocket.onmessage = (event) => { + const data = event.data; + if (typeof data === 'string') { + this._onMessage(data); + } else { + log.error('Got invalid reply from the server', event); + } + }; - websocket.onclose = (event) => { - log.info(`The websocket connection closed with code: ${event.code}`); + webSocket.onclose = (event) => { + log.info(`The websocket connection closed with code: ${event.code}`); - // Remove all subscriptions since they are connection based - this._subscriptions.clear(); + // Remove all subscriptions since they are connection based + this._subscriptions.clear(); - // 1000 is a code used for normal connection closure. - const connectionError = event.code === 1000 ? null : new ConnectionError(event.code); + // 1000 is a code used for normal connection closure. + const connectionError = event.code === 1000 ? null : new ConnectionError(event.code); - this.emit('close', connectionError); - }; + this.emit('close', connectionError); - this._websocket = websocket; + // Prevent rejecting a previously resolved Promise. + if (!isPromiseResolved) { + reject(connectionError); + } + }; + + this._webSocket = webSocket; + }); } disconnect() { - if (this._websocket) { - this._websocket.close(); - this._websocket = null; + if (this._webSocket) { + this._webSocket.close(); + this._webSocket = null; } } @@ -195,19 +209,14 @@ export default class JsonRpcTransport extends EventEmitter { } } - async send( - action: string, - data: mixed, - timeout: number = DEFAULT_TIMEOUT_MILLIS, - ): Promise<mixed> { - let socket: WebSocket; - try { - socket = await this._getWebSocket(); - } catch (error) { - throw error; - } - + send(action: string, data: mixed, timeout: number = DEFAULT_TIMEOUT_MILLIS): Promise<mixed> { return new Promise((resolve, reject) => { + const webSocket = this._webSocket; + if (!webSocket) { + reject(new Error('Websocket is not connected.')); + return; + } + const id = uuid.v4(); const payload = this._prepareParams(data); const timerId = setTimeout(() => this._onTimeout(id), timeout); @@ -221,9 +230,14 @@ export default class JsonRpcTransport extends EventEmitter { try { log.silly('Sending message', id, action); - socket.send(JSON.stringify(message)); + webSocket.send(JSON.stringify(message)); } catch (error) { log.error(`Failed sending RPC message "${action}": ${error.message}`); + + // clean up on error + this._unansweredRequests.delete(id); + clearTimeout(timerId); + throw error; } }); @@ -245,25 +259,6 @@ export default class JsonRpcTransport extends EventEmitter { } } - _getWebSocket(): Promise<WebSocket> { - if (this._websocket && this._websocket.readyState === 1) { - return Promise.resolve(this._websocket); - } else { - return new Promise((resolve, reject) => { - log.debug('Waiting for websocket to connect'); - - this.once('open', () => { - const ws = this._websocket; - if (ws) { - resolve(ws); - } else { - reject(new Error('Internal error')); - } - }); - }); - } - } - _onTimeout(requestId) { const request = this._unansweredRequests.get(requestId); diff --git a/app/lib/reconnection-backoff.js b/app/lib/reconnection-backoff.js index 1cd8203dac..d777e86e57 100644 --- a/app/lib/reconnection-backoff.js +++ b/app/lib/reconnection-backoff.js @@ -1,13 +1,11 @@ +// @flow + /* * Used to calculate the time to wait before reconnecting to the daemon. * It uses a linear backoff function that goes from 500ms to 3000ms. */ export default class ReconnectionBackoff { - _attempt: number; - - constructor() { - this._attempt = 0; - } + _attempt = 0; attempt(handler: () => void) { setTimeout(handler, this._getIncreasedBackoff()); diff --git a/app/lib/window-state-observer.js b/app/lib/window-state-observer.js new file mode 100644 index 0000000000..67252d8f6a --- /dev/null +++ b/app/lib/window-state-observer.js @@ -0,0 +1,64 @@ +// @flow + +import { remote } from 'electron'; + +type EventListener = () => void; + +// Tiny helper for detecting the window state. +export default class WindowStateObserver { + _onShow: ?EventListener; + _onHide: ?EventListener; + + get onShow() { + return this._onShow; + } + + set onShow(listener: ?EventListener) { + const currentWindow = remote.getCurrentWindow(); + const oldListener = this._onShow; + if (oldListener) { + currentWindow.removeListener('show', oldListener); + } + + if (listener) { + currentWindow.addListener('show', listener); + } + + this._onShow = listener; + } + + get onHide() { + return this._onHide; + } + + set onHide(listener: ?EventListener) { + const currentWindow = remote.getCurrentWindow(); + const oldListener = this._onHide; + if (oldListener) { + currentWindow.removeListener('hide', oldListener); + } + + if (listener) { + currentWindow.addListener('hide', listener); + } + + this._onHide = listener; + } + + constructor() { + // Because BrowserWindow persists between page reloads, + // it's important to release event handlers when that happens. + window.addEventListener('beforeunload', this._onBeforeUnload); + } + + _onBeforeUnload = () => { + this.dispose(); + }; + + dispose() { + this.onShow = null; + this.onHide = null; + + window.removeEventListener('beforeunload', this._onBeforeUnload); + } +} diff --git a/app/redux/account/actions.js b/app/redux/account/actions.js index 3cc4b83681..ae42d91b6c 100644 --- a/app/redux/account/actions.js +++ b/app/redux/account/actions.js @@ -4,23 +4,13 @@ import { Clipboard } from 'reactxp'; import type { AccountToken } from '../../lib/daemon-rpc'; import type { ReduxThunk } from '../store'; -const copyAccountToken = (): ReduxThunk => { - return (_, getState) => { - const accountToken = getState().account.accountToken; - if (accountToken) { - Clipboard.setText(accountToken); - } - }; -}; - type StartLoginAction = { type: 'START_LOGIN', - accountToken?: AccountToken, + accountToken: AccountToken, }; type LoginSuccessfulAction = { type: 'LOGIN_SUCCESSFUL', - expiry?: string, }; type LoginFailedAction = { @@ -61,24 +51,23 @@ export type AccountAction = | UpdateAccountHistoryAction | UpdateAccountExpiryAction; -function startLogin(accountToken?: AccountToken): StartLoginAction { +function startLogin(accountToken: AccountToken): StartLoginAction { return { type: 'START_LOGIN', accountToken: accountToken, }; } -function loginSuccessful(expiry: string): LoginSuccessfulAction { +function loginSuccessful(): LoginSuccessfulAction { return { type: 'LOGIN_SUCCESSFUL', - expiry, }; } function loginFailed(error: Error): LoginFailedAction { return { type: 'LOGIN_FAILED', - error: error, + error, }; } @@ -88,10 +77,6 @@ function loggedOut(): LoggedOutAction { }; } -function autoLoginFailed(): LoggedOutAction { - return loggedOut(); -} - function resetLoginError(): ResetLoginErrorAction { return { type: 'RESET_LOGIN_ERROR', @@ -101,14 +86,14 @@ function resetLoginError(): ResetLoginErrorAction { function updateAccountToken(token: AccountToken): UpdateAccountTokenAction { return { type: 'UPDATE_ACCOUNT_TOKEN', - token: token, + token, }; } function updateAccountHistory(accountHistory: Array<AccountToken>): UpdateAccountHistoryAction { return { type: 'UPDATE_ACCOUNT_HISTORY', - accountHistory: accountHistory, + accountHistory, }; } @@ -119,15 +104,23 @@ function updateAccountExpiry(expiry: string): UpdateAccountExpiryAction { }; } +function copyAccountToken(): ReduxThunk { + return (_, getState) => { + const accountToken = getState().account.accountToken; + if (accountToken) { + Clipboard.setText(accountToken); + } + }; +} + export default { - copyAccountToken, startLogin, loginSuccessful, loginFailed, loggedOut, - autoLoginFailed, resetLoginError, updateAccountToken, updateAccountHistory, updateAccountExpiry, + copyAccountToken, }; diff --git a/app/redux/account/reducers.js b/app/redux/account/reducers.js index dbd854a6c0..7263b51166 100644 --- a/app/redux/account/reducers.js +++ b/app/redux/account/reducers.js @@ -40,7 +40,6 @@ export default function( ...{ status: 'ok', error: null, - expiry: action.expiry, }, }; case 'LOGIN_FAILED': diff --git a/app/redux/connection/actions.js b/app/redux/connection/actions.js index bb155aa485..915eedd273 100644 --- a/app/redux/connection/actions.js +++ b/app/redux/connection/actions.js @@ -4,21 +4,14 @@ import { Clipboard } from 'reactxp'; import type { ReduxThunk } from '../store'; import type { Ip } from '../../lib/daemon-rpc'; -const copyIPAddress = (): ReduxThunk => { - return (_, getState) => { - const ip = getState().connection.ip; - if (ip) { - Clipboard.setText(ip); - } - }; -}; - type ConnectingAction = { type: 'CONNECTING', }; + type ConnectedAction = { type: 'CONNECTED', }; + type DisconnectedAction = { type: 'DISCONNECTED', }; @@ -88,12 +81,21 @@ function offline(): OfflineAction { }; } +function copyIPAddress(): ReduxThunk { + return (_, getState) => { + const ip = getState().connection.ip; + if (ip) { + Clipboard.setText(ip); + } + }; +} + export default { - copyIPAddress, newLocation, connecting, connected, disconnected, online, offline, + copyIPAddress, }; diff --git a/app/redux/daemon/actions.js b/app/redux/daemon/actions.js new file mode 100644 index 0000000000..904cb3bd61 --- /dev/null +++ b/app/redux/daemon/actions.js @@ -0,0 +1,24 @@ +// @flow + +export type DaemonConnectedAction = { + type: 'DAEMON_CONNECTED', +}; +export type DaemonDisconnectedAction = { + type: 'DAEMON_DISCONNECTED', +}; + +export type DaemonAction = DaemonConnectedAction | DaemonDisconnectedAction; + +function connected(): DaemonConnectedAction { + return { + type: 'DAEMON_CONNECTED', + }; +} + +function disconnected(): DaemonDisconnectedAction { + return { + type: 'DAEMON_DISCONNECTED', + }; +} + +export default { connected, disconnected }; diff --git a/app/redux/daemon/reducers.js b/app/redux/daemon/reducers.js new file mode 100644 index 0000000000..5e9e707ff6 --- /dev/null +++ b/app/redux/daemon/reducers.js @@ -0,0 +1,33 @@ +// @flow + +import type { ReduxAction } from '../store'; + +export type DaemonReduxState = { + isConnected: boolean, +}; + +const initialState: DaemonReduxState = { + isConnected: false, +}; + +export default function( + state: DaemonReduxState = initialState, + action: ReduxAction, +): DaemonReduxState { + switch (action.type) { + case 'DAEMON_CONNECTED': + return { + ...state, + isConnected: true, + }; + + case 'DAEMON_DISCONNECTED': + return { + ...state, + isConnected: false, + }; + + default: + return state; + } +} diff --git a/app/redux/store.js b/app/redux/store.js index 8afe22d716..e269d9902b 100644 --- a/app/redux/store.js +++ b/app/redux/store.js @@ -11,6 +11,8 @@ import settings from './settings/reducers'; import settingsActions from './settings/actions'; import support from './support/reducers'; import supportActions from './support/actions'; +import daemon from './daemon/reducers'; +import daemonActions from './daemon/actions'; import type { Store, StoreEnhancer } from 'redux'; import type { History } from 'history'; @@ -18,20 +20,28 @@ import type { AccountReduxState } from './account/reducers'; import type { ConnectionReduxState } from './connection/reducers'; import type { SettingsReduxState } from './settings/reducers'; import type { SupportReduxState } from './support/reducers'; +import type { DaemonReduxState } from './daemon/reducers'; import type { AccountAction } from './account/actions'; import type { ConnectionAction } from './connection/actions'; import type { SettingsAction } from './settings/actions'; import type { SupportAction } from './support/actions'; +import type { DaemonAction } from './daemon/actions'; export type ReduxState = { account: AccountReduxState, connection: ConnectionReduxState, settings: SettingsReduxState, support: SupportReduxState, + daemon: DaemonReduxState, }; -export type ReduxAction = AccountAction | ConnectionAction | SettingsAction | SupportAction; +export type ReduxAction = + | AccountAction + | ConnectionAction + | SettingsAction + | SupportAction + | DaemonAction; export type ReduxStore = Store<ReduxState, ReduxAction, ReduxDispatch>; export type ReduxGetState = () => ReduxState; export type ReduxDispatch = (action: ReduxAction | ReduxThunk) => any; @@ -48,6 +58,7 @@ export default function configureStore( ...connectionActions, ...settingsActions, ...supportActions, + ...daemonActions, pushRoute: (route) => push(route), replaceRoute: (route) => replace(route), }; @@ -57,6 +68,7 @@ export default function configureStore( connection, settings, support, + daemon, }; const middlewares = [thunk, router]; diff --git a/app/routes.js b/app/routes.js index 95ad24e876..107ee616be 100644 --- a/app/routes.js +++ b/app/routes.js @@ -4,6 +4,7 @@ import * as React from 'react'; import { Switch, Route, Redirect } from 'react-router'; import TransitionContainer from './components/TransitionContainer'; import PlatformWindow from './components/PlatformWindow'; +import LaunchPage from './containers/LaunchPage'; import LoginPage from './containers/LoginPage'; import ConnectPage from './containers/ConnectPage'; import SettingsPage from './containers/SettingsPage'; @@ -38,7 +39,6 @@ export default function makeRoutes( // example: <PublicRoute path="/" component={ MyComponent } /> const PublicRoute = ({ component, ...otherProps }) => { return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route {...otherProps} render={(routeProps) => { @@ -52,7 +52,6 @@ export default function makeRoutes( // example: <PrivateRoute path="/protected" component={ MyComponent } /> const PrivateRoute = ({ component, ...otherProps }) => { return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route {...otherProps} render={(routeProps) => { @@ -62,7 +61,7 @@ export default function makeRoutes( if (isLoggedIn) { return renderMergedProps(component, routeProps, otherProps); } else { - return <Redirect to={'/'} />; + return <Redirect to={'/login'} />; } }} /> @@ -74,7 +73,6 @@ export default function makeRoutes( // example: <LoginRoute path="/login" component={ MyComponent } /> const LoginRoute = ({ component, ...otherProps }) => { return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route {...otherProps} render={(routeProps) => { @@ -91,11 +89,29 @@ export default function makeRoutes( ); }; + // Renders launch route that is only available when daemon is not connected. + // Otherwise this route redirects user to /login. + // example: <LaunchRoute path="/" component={ MyComponent } /> + const LaunchRoute = ({ component, ...otherProps }) => { + return ( + <Route + {...otherProps} + render={(routeProps) => { + const { daemon } = getState(); + if (daemon.isConnected) { + return <Redirect to={'/login'} />; + } else { + return renderMergedProps(component, routeProps, otherProps); + } + }} + /> + ); + }; + // store previous route let previousRoute: ?string; return ( - // $FlowFixMe: This has been fixed in Flow 0.71 <Route render={({ location }) => { const toRoute = location.pathname; @@ -107,7 +123,8 @@ export default function makeRoutes( <PlatformWindow> <TransitionContainer {...transitionProps}> <Switch key={location.key} location={location}> - <LoginRoute exact path="/" component={LoginPage} /> + <LaunchRoute exact path="/" component={LaunchPage} /> + <LoginRoute exact path="/login" component={LoginPage} /> <PrivateRoute exact path="/connect" component={ConnectPage} /> <PublicRoute exact path="/settings" component={SettingsPage} /> <PrivateRoute exact path="/settings/account" component={AccountPage} /> |
