diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2018-10-10 15:43:40 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2018-10-10 15:43:40 +0200 |
| commit | 68f55f46a0f6d922083acffa77f4651afa3c96b7 (patch) | |
| tree | cf0519bbaa87f6ad242d06df383f43c2978adf8d | |
| parent | a4b3566e0744a23a41ee626a3fcd2469fa2f06a9 (diff) | |
| parent | 6c80a59a69996b6b78b074520e8cb359220a75e4 (diff) | |
| download | mullvadvpn-68f55f46a0f6d922083acffa77f4651afa3c96b7.tar.xz mullvadvpn-68f55f46a0f6d922083acffa77f4651afa3c96b7.zip | |
Merge branch 'show-outdated-version-banner'
12 files changed, 654 insertions, 373 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d355213654..3c5e159df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ Line wrap the file at 100 chars. Th ### Added - Fall back and try to connect over TCP port 443 if protocol is set to automatic and two attempts with UDP fail in a row. If that also fails, alternate between UDP and TCP with random ports. +- Add new in-app notifications to inform the user when the app becomes outdated, unsupported or + might have security issues. ### Fixed - Place Mssfix setting inside scrollable area diff --git a/gui/packages/desktop/src/renderer/components/BlockingInternetBanner.js b/gui/packages/desktop/src/renderer/components/BlockingInternetBanner.js deleted file mode 100644 index 78c2b57611..0000000000 --- a/gui/packages/desktop/src/renderer/components/BlockingInternetBanner.js +++ /dev/null @@ -1,69 +0,0 @@ -// @flow - -import * as React from 'react'; -import { View, Text, Component, Styles } from 'reactxp'; -import { colors } from '../../config'; - -const styles = { - container: Styles.createViewStyle({ - flexDirection: 'row', - backgroundColor: 'rgba(25, 38, 56, 0.95)', - paddingTop: 8, - paddingLeft: 20, - paddingRight: 20, - paddingBottom: 8, - }), - icon: Styles.createViewStyle({ - width: 10, - height: 10, - flex: 0, - borderRadius: 5, - marginTop: 4, - marginRight: 8, - backgroundColor: colors.red, - }), - textContainer: Styles.createViewStyle({ - flex: 1, - }), - title: Styles.createTextStyle({ - fontFamily: 'Open Sans', - fontSize: 12, - fontWeight: '800', - lineHeight: 17, - color: colors.white60, - }), - subtitle: Styles.createTextStyle({ - fontFamily: 'Open Sans', - fontSize: 12, - fontWeight: '800', - lineHeight: 17, - color: colors.white40, - }), -}; - -export class BannerTitle extends Component { - render() { - return <Text style={styles.title}>{this.props.children}</Text>; - } -} - -export class BannerSubtitle extends Component { - render() { - return React.Children.count(this.props.children) > 0 ? ( - <Text style={styles.subtitle}>{this.props.children}</Text> - ) : null; - } -} - -export default class BlockingInternetBanner extends Component<{ - children: Array<React.Element<typeof BannerTitle> | React.Element<typeof BannerSubtitle>>, -}> { - render() { - return ( - <View style={styles.container}> - <View style={styles.icon} /> - <View style={styles.textContainer}>{this.props.children}</View> - </View> - ); - } -} diff --git a/gui/packages/desktop/src/renderer/components/Connect.js b/gui/packages/desktop/src/renderer/components/Connect.js index b514d63f15..a50227b289 100644 --- a/gui/packages/desktop/src/renderer/components/Connect.js +++ b/gui/packages/desktop/src/renderer/components/Connect.js @@ -3,22 +3,24 @@ import moment from 'moment'; import * as React from 'react'; import { Component, Text, View, Types } from 'reactxp'; -import { Accordion, SecuredLabel, SecuredDisplayStyle } from '@mullvad/components'; +import { SecuredLabel, SecuredDisplayStyle } from '@mullvad/components'; import { Layout, Container, Header } from './Layout'; import { SettingsBarButton, Brand } from './HeaderBar'; -import BlockingInternetBanner, { BannerTitle, BannerSubtitle } from './BlockingInternetBanner'; +import NotificationArea from './NotificationArea'; import * as AppButton from './AppButton'; import Img from './Img'; import Map from './Map'; import styles from './ConnectStyles'; import { NoCreditError, NoInternetError } from '../errors'; -import type { BlockReason, TunnelState, TunnelStateTransition } from '../lib/daemon-rpc'; +import type { TunnelState } from '../lib/daemon-rpc'; import type { HeaderBarStyle } from './HeaderBar'; import type { ConnectionReduxState } from '../redux/connection/reducers'; +import type { VersionReduxState } from '../redux/version/reducers'; type Props = { connection: ConnectionReduxState, + version: VersionReduxState, accountExpiry: ?string, selectedRelayName: string, onSettings: () => void, @@ -28,27 +30,6 @@ type Props = { onExternalLink: (type: string) => void, }; -function getBlockReasonMessage(blockReason: BlockReason): string { - switch (blockReason.reason) { - case 'auth_failed': { - const details = - blockReason.details || - 'Check that the account is valid, has time left and not too many connections'; - return `Authentication failed: ${details}`; - } - case 'ipv6_unavailable': - return 'Could not configure IPv6, please enable it on your system or disable it in the app'; - case 'set_security_policy_error': - return 'Failed to apply security policy'; - case 'start_tunnel_error': - return 'Failed to start tunnel connection'; - case 'no_matching_relay': - return 'No relay server matches the current settings'; - default: - return `Unknown error: ${(blockReason.reason: empty)}`; - } -} - export default class Connect extends Component<Props> { render() { const error = this.checkForErrors(); @@ -89,7 +70,7 @@ export default class Connect extends Component<Props> { <View style={styles.error_message}>{message}</View> {error instanceof NoCreditError ? ( <View> - <AppButton.GreenButton onPress={this.onExternalLink.bind(this, 'purchase')}> + <AppButton.GreenButton onPress={() => this.props.onExternalLink('purchase')}> <AppButton.Label>Buy more time</AppButton.Label> <Img source="icon-extLink" height={16} width={16} /> </AppButton.GreenButton> @@ -161,8 +142,6 @@ export default class Connect extends Component<Props> { <Map style={{ width: '100%', height: '100%' }} {...this._getMapProps()} /> </View> <View style={styles.container}> - <TunnelBanner tunnelState={this.props.connection.status} /> - {/* show spinner when connecting */} {this.props.connection.status.state === 'connecting' ? ( <View style={styles.status_icon}> @@ -181,17 +160,18 @@ export default class Connect extends Component<Props> { onDisconnect={this.props.onDisconnect} onSelectLocation={this.props.onSelectLocation} /> + + <NotificationArea + style={styles.notification_area} + tunnelState={this.props.connection.status} + version={this.props.version} + openExternalLink={this.props.onExternalLink} + /> </View> </View> ); } - // Handlers - - onExternalLink(type: string) { - this.props.onExternalLink(type); - } - // Private headerBarStyle(): HeaderBarStyle { @@ -234,74 +214,6 @@ export default class Connect extends Component<Props> { } } -type TunnelBannerProps = { - tunnelState: TunnelStateTransition, -}; - -type TunnerBannerState = { - visible: boolean, - title: string, - subtitle: string, -}; - -export class TunnelBanner extends Component<TunnelBannerProps, TunnerBannerState> { - state = { - visible: false, - title: '', - subtitle: '', - }; - - constructor(props: TunnelBannerProps) { - super(); - this.state = this._deriveState(props.tunnelState); - } - - componentDidUpdate(oldProps: TunnelBannerProps, _oldState: TunnerBannerState) { - if ( - oldProps.tunnelState.state !== this.props.tunnelState.state || - oldProps.tunnelState.details !== this.props.tunnelState.details - ) { - const nextState = this._deriveState(this.props.tunnelState); - this.setState(nextState); - } - } - - render() { - return ( - <Accordion style={styles.blocking_container} height={this.state.visible ? 'auto' : 0}> - <BlockingInternetBanner> - <BannerTitle>{this.state.title}</BannerTitle> - <BannerSubtitle>{this.state.subtitle}</BannerSubtitle> - </BlockingInternetBanner> - </Accordion> - ); - } - - _deriveState(tunnelState: TunnelStateTransition) { - switch (tunnelState.state) { - case 'connecting': - return { - visible: true, - title: 'BLOCKING INTERNET', - subtitle: '', - }; - - case 'blocked': - return { - visible: true, - title: 'BLOCKING INTERNET', - subtitle: getBlockReasonMessage(tunnelState.details), - }; - - default: - return { - ...this.state, - visible: false, - }; - } - } -} - type TunnelControlProps = { tunnelState: TunnelState, selectedRelayName: string, diff --git a/gui/packages/desktop/src/renderer/components/ConnectStyles.js b/gui/packages/desktop/src/renderer/components/ConnectStyles.js index 54b784c415..1af5c3ed16 100644 --- a/gui/packages/desktop/src/renderer/components/ConnectStyles.js +++ b/gui/packages/desktop/src/renderer/components/ConnectStyles.js @@ -50,7 +50,7 @@ export default { switch_location_button: Styles.createViewStyle({ marginBottom: 16, }), - blocking_container: Styles.createViewStyle({ + notification_area: Styles.createViewStyle({ width: '100%', position: 'absolute', }), diff --git a/gui/packages/desktop/src/renderer/components/Img.js b/gui/packages/desktop/src/renderer/components/Img.js index f6369a4d6f..c39244b9c8 100644 --- a/gui/packages/desktop/src/renderer/components/Img.js +++ b/gui/packages/desktop/src/renderer/components/Img.js @@ -6,7 +6,7 @@ import { View, Component, Types } from 'reactxp'; type Props = { source: string, width?: number, - heigth?: number, + height?: number, tintColor?: string, hoverStyle?: Types.ViewStyle, disabled?: boolean, @@ -23,24 +23,27 @@ export default class Img extends Component<Props, State> { getHoverStyle = () => (this.state.hovered ? this.props.hoverStyle || null : null); render() { - const { source, width, heigth, style, onMouseEnter, onMouseLeave, ...otherProps } = this.props; + const { source, width, height, style, onMouseEnter, onMouseLeave, ...otherProps } = this.props; const tintColor = this.props.tintColor; const url = '../assets/images/' + source + '.svg'; let image; if (tintColor) { + const maskWidth = typeof width === 'number' ? `${width}px` : 'auto'; + const maskHeight = typeof height === 'number' ? `${height}px` : 'auto'; image = ( <div style={{ WebkitMaskImage: `url('${url}')`, WebkitMaskRepeat: 'no-repeat', + WebkitMaskSize: `${maskWidth} ${maskHeight}`, backgroundColor: tintColor, lineHeight: 0, }}> <img src={url} width={width} - height={heigth} + height={height} style={{ visibility: 'hidden', }} @@ -48,7 +51,7 @@ export default class Img extends Component<Props, State> { </div> ); } else { - image = <img src={url} width={width} height={heigth} />; + image = <img src={url} width={width} height={height} />; } return ( diff --git a/gui/packages/desktop/src/renderer/components/NotificationArea.js b/gui/packages/desktop/src/renderer/components/NotificationArea.js new file mode 100644 index 0000000000..e469d657a4 --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/NotificationArea.js @@ -0,0 +1,176 @@ +// @flow +import * as React from 'react'; +import { Component, Types } from 'reactxp'; +import { + NotificationBanner, + NotificationIndicator, + NotificationContent, + NotificationActions, + NotificationTitle, + NotificationSubtitle, + NotificationOpenLinkAction, +} from './NotificationBanner'; + +import type { BlockReason, TunnelStateTransition } from '../lib/daemon-rpc'; +import type { VersionReduxState } from '../redux/version/reducers'; + +type Props = { + style?: Types.ViewStyleRuleSet, + tunnelState: TunnelStateTransition, + version: VersionReduxState, + openExternalLink: (string) => void, +}; + +type NotificationAreaPresentation = + | { type: 'blocking', reason: string } + | { type: 'inconsistent-version' } + | { type: 'unsupported-version', upgradeVersion: string } + | { type: 'update-available', upgradeVersion: string }; + +type State = NotificationAreaPresentation & { + visible: boolean, +}; + +function getBlockReasonMessage(blockReason: BlockReason): string { + switch (blockReason.reason) { + case 'auth_failed': { + const details = + blockReason.details || + 'Check that the account is valid, has time left and not too many connections'; + return `Authentication failed: ${details}`; + } + case 'ipv6_unavailable': + return 'Could not configure IPv6, please enable it on your system or disable it in the app'; + case 'set_security_policy_error': + return 'Failed to apply security policy'; + case 'start_tunnel_error': + return 'Failed to start tunnel connection'; + case 'no_matching_relay': + return 'No relay server matches the current settings'; + default: + return `Unknown error: ${(blockReason.reason: empty)}`; + } +} + +export default class NotificationArea extends Component<Props, State> { + state = { + type: 'blocking', + reason: '', + visible: false, + }; + + static getDerivedStateFromProps(props: Props, state: State) { + const { version, tunnelState } = props; + + switch (tunnelState.state) { + case 'connecting': + return { + visible: true, + type: 'blocking', + reason: '', + }; + + case 'blocked': + return { + visible: true, + type: 'blocking', + reason: getBlockReasonMessage(tunnelState.details), + }; + + default: + if (!version.consistent) { + return { + visible: true, + type: 'inconsistent-version', + }; + } + + if (!version.currentIsSupported && version.nextUpgrade) { + return { + visible: true, + type: 'unsupported-version', + upgradeVersion: version.nextUpgrade, + }; + } + + if (!version.upToDate && version.nextUpgrade) { + return { + visible: true, + type: 'update-available', + upgradeVersion: version.nextUpgrade, + }; + } + + return { + ...state, + visible: false, + }; + } + } + + render() { + return ( + <NotificationBanner style={this.props.style} visible={this.state.visible}> + {this.state.type === 'blocking' && ( + <React.Fragment> + <NotificationIndicator type={'error'} /> + <NotificationContent> + <NotificationTitle>{'BLOCKING INTERNET'}</NotificationTitle> + <NotificationSubtitle>{this.state.reason}</NotificationSubtitle> + </NotificationContent> + </React.Fragment> + )} + + {this.state.type === 'inconsistent-version' && ( + <React.Fragment> + <NotificationIndicator type={'error'} /> + <NotificationContent> + <NotificationTitle>{'INCONSISTENT VERSION'}</NotificationTitle> + <NotificationSubtitle> + {'Inconsistent internal version information, please restart the app'} + </NotificationSubtitle> + </NotificationContent> + </React.Fragment> + )} + + {this.state.type === 'unsupported-version' && ( + <React.Fragment> + <NotificationIndicator type={'error'} /> + <NotificationContent> + <NotificationTitle>{'UNSUPPORTED VERSION'}</NotificationTitle> + <NotificationSubtitle>{`This app version might have security issues. Please upgrade to ${ + this.state.upgradeVersion + }`}</NotificationSubtitle> + </NotificationContent> + <NotificationActions> + <NotificationOpenLinkAction + onPress={() => { + this.props.openExternalLink('download'); + }} + /> + </NotificationActions> + </React.Fragment> + )} + + {this.state.type === 'update-available' && ( + <React.Fragment> + <NotificationIndicator type={'warning'} /> + <NotificationContent> + <NotificationTitle>{`UPDATE AVAILABLE`}</NotificationTitle> + <NotificationSubtitle>{`Install Mullvad VPN (${ + this.state.upgradeVersion + }) to stay up to date`}</NotificationSubtitle> + </NotificationContent> + <NotificationActions> + <NotificationOpenLinkAction + onPress={() => { + this.props.openExternalLink('download'); + }} + /> + </NotificationActions> + </React.Fragment> + )} + </NotificationBanner> + ); + } +} diff --git a/gui/packages/desktop/src/renderer/components/NotificationBanner.js b/gui/packages/desktop/src/renderer/components/NotificationBanner.js new file mode 100644 index 0000000000..03f0247e88 --- /dev/null +++ b/gui/packages/desktop/src/renderer/components/NotificationBanner.js @@ -0,0 +1,266 @@ +// @flow + +import * as React from 'react'; +import { Animated, View, Button, Text, Component, UserInterface, Styles, Types } from 'reactxp'; +import { colors } from '../../config'; +import Img from './Img'; + +const styles = { + collapsible: Styles.createViewStyle({ + backgroundColor: 'rgba(25, 38, 56, 0.95)', + overflow: 'hidden', + }), + drawer: Styles.createViewStyle({ + justifyContent: 'flex-end', + }), + container: Styles.createViewStyle({ + flexDirection: 'row', + paddingTop: 8, + paddingLeft: 20, + paddingRight: 10, + paddingBottom: 8, + }), + indicator: { + base: Styles.createViewStyle({ + width: 10, + height: 10, + flex: 0, + borderRadius: 5, + marginTop: 4, + marginRight: 8, + }), + warning: Styles.createViewStyle({ + backgroundColor: colors.yellow, + }), + success: Styles.createViewStyle({ + backgroundColor: colors.green, + }), + error: Styles.createViewStyle({ + backgroundColor: colors.red, + }), + }, + textContainer: Styles.createViewStyle({ + flex: 1, + }), + actionContainer: Styles.createViewStyle({ + flex: 0, + flexDirection: 'column', + justifyContent: 'center', + }), + actionButton: Styles.createButtonStyle({ + flex: 1, + justifyContent: 'center', + cursor: 'default', + paddingLeft: 5, + paddingRight: 5, + }), + title: Styles.createTextStyle({ + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '800', + lineHeight: 18, + color: colors.white, + }), + subtitle: Styles.createTextStyle({ + fontFamily: 'Open Sans', + fontSize: 13, + fontWeight: '600', + lineHeight: 18, + color: colors.white60, + }), +}; + +export class NotificationTitle extends Component { + render() { + return <Text style={styles.title}>{this.props.children}</Text>; + } +} + +export class NotificationSubtitle extends Component { + render() { + return React.Children.count(this.props.children) > 0 ? ( + <Text style={styles.subtitle}>{this.props.children}</Text> + ) : null; + } +} + +export class NotificationOpenLinkAction extends Component<{ onPress: () => void }> { + state = { + hovered: false, + }; + + render() { + return ( + <Button + style={styles.actionButton} + onPress={this.props.onPress} + onHoverStart={this._onHoverStart} + onHoverEnd={this._onHoverEnd}> + <Img + height={12} + width={12} + tintColor={this.state.hovered ? colors.white80 : colors.white60} + source="icon-extLink" + /> + </Button> + ); + } + + _onHoverStart = () => { + this.setState({ hovered: true }); + }; + + _onHoverEnd = () => { + this.setState({ hovered: false }); + }; +} + +export class NotificationContent extends Component { + render() { + return <View style={styles.textContainer}>{this.props.children}</View>; + } +} + +export class NotificationActions extends Component { + render() { + return <View style={styles.actionContainer}>{this.props.children}</View>; + } +} + +export class NotificationIndicator extends Component<{ type: 'success' | 'warning' | 'error' }> { + render() { + return <View style={[styles.indicator.base, styles.indicator[this.props.type]]} />; + } +} + +type NotificationBannerProps = { + children: Array< + React.Element<typeof NotificationContent> | React.Element<typeof NotificationActions>, + >, + visible: boolean, + animationDuration: number, +}; + +type NotificationBannerState = { + contentPinnedToBottom: boolean, +}; + +export class NotificationBanner extends Component< + NotificationBannerProps, + NotificationBannerState, +> { + static defaultProps = { + animationDuration: 350, + }; + + _containerRef = React.createRef(); + _contentHeight = 0; + _heightValue = Animated.createValue(0); + _animationStyle: Types.AnimatedViewStyle; + _animation: ?Types.Animated.CompositeAnimation = null; + _didFinishFirstLayoutPass = false; + + state = { + contentPinnedToBottom: false, + }; + + constructor(props: NotificationBannerProps) { + super(props); + + this._animationStyle = Styles.createAnimatedViewStyle({ + height: this._heightValue, + }); + } + + shouldComponentUpdate(nextProps: NotificationBannerProps, nextState: NotificationBannerState) { + return ( + this.props.children !== nextProps.children || + this.props.visible !== nextProps.visible || + this.state.contentPinnedToBottom !== nextState.contentPinnedToBottom + ); + } + + componentDidUpdate(prevProps: NotificationBannerProps) { + if (prevProps.visible !== this.props.visible) { + // enable drawer-like animation when changing banner's visibility + this.setState({ contentPinnedToBottom: true }, () => { + this._animateHeightChanges(); + }); + } + } + + componentWillUnmount() { + if (this._animation) { + this._animation.stop(); + } + } + + render() { + return ( + <Animated.View + style={[ + styles.collapsible, + this.state.contentPinnedToBottom ? styles.drawer : undefined, + this._animationStyle, + this.props.style, + ]} + ref={this._containerRef}> + <View onLayout={this._onLayout}> + <View style={styles.container}>{this.props.children}</View> + </View> + </Animated.View> + ); + } + + _onLayout = ({ height }) => { + const oldHeight = this._contentHeight; + this._contentHeight = height; + + // The first layout pass should not be animated because this would cause the initially visible + // notification banner to slide down each time the component is mounted. + if (this._didFinishFirstLayoutPass) { + if (oldHeight !== height) { + this._animateHeightChanges(); + } + } else { + this._didFinishFirstLayoutPass = true; + if (this.props.visible) { + this._heightValue.setValue(height); + } + } + }; + + async _animateHeightChanges() { + const containerView = this._containerRef.current; + if (!containerView) { + return; + } + + if (this._animation) { + this._animation.stop(); + this._animation = null; + } + + // calculate the animation duration based on travel distance + const layout = await UserInterface.measureLayoutRelativeToWindow(containerView); + const toValue = this.props.visible ? this._contentHeight : 0; + const multiplier = Math.abs(toValue - layout.height) / Math.max(1, this._contentHeight); + const duration = Math.ceil(this.props.animationDuration * multiplier); + + const animation = Animated.timing(this._heightValue, { + toValue, + easing: Animated.Easing.InOut(), + duration, + useNativeDriver: true, + }); + + this._animation = animation; + + animation.start(({ finished }) => { + if (finished) { + // disable drawer-like animations for content updates when the banner is visible + this.setState({ contentPinnedToBottom: false }); + } + }); + } +} diff --git a/gui/packages/desktop/src/renderer/containers/ConnectPage.js b/gui/packages/desktop/src/renderer/containers/ConnectPage.js index e98398a32c..25e0cbfc61 100644 --- a/gui/packages/desktop/src/renderer/containers/ConnectPage.js +++ b/gui/packages/desktop/src/renderer/containers/ConnectPage.js @@ -60,6 +60,7 @@ const mapStateToProps = (state: ReduxState) => { accountExpiry: state.account.expiry, selectedRelayName: getRelayName(state.settings.relaySettings, state.settings.relayLocations), connection: state.connection, + version: state.version, }; }; diff --git a/gui/packages/desktop/src/renderer/redux/version/reducers.js b/gui/packages/desktop/src/renderer/redux/version/reducers.js index 4463d1cd5b..c3d6a07382 100644 --- a/gui/packages/desktop/src/renderer/redux/version/reducers.js +++ b/gui/packages/desktop/src/renderer/redux/version/reducers.js @@ -4,23 +4,45 @@ import type { ReduxAction } from '../store'; export type VersionReduxState = { current: string, + currentIsSupported: boolean, latest: ?string, latestStable: ?string, + nextUpgrade: ?string, upToDate: boolean, consistent: boolean, }; const initialState: VersionReduxState = { current: '', + currentIsSupported: true, latest: null, latestStable: null, + nextUpgrade: null, upToDate: true, consistent: true, }; -const checkIfLatest = (current: string, latest: ?string, latestStable: ?string): boolean => { - return latest === null || latestStable === null || current === latest || current === latestStable; -}; +function isBeta(version: string) { + return version.includes('-'); +} + +function nextUpgrade(current: string, latest: ?string, latestStable: ?string): ?string { + if (isBeta(current)) { + return current === latest ? null : latest; + } else { + return current === latestStable ? null : latestStable; + } +} + +function checkIfLatest(current: string, latest: ?string, latestStable: ?string): boolean { + // perhaps -beta? + if (isBeta(current)) { + return current === latest || latest === null; + } else { + // must be stable + return current === latestStable || latestStable === null; + } +} export default function( state: VersionReduxState = initialState, @@ -28,13 +50,16 @@ export default function( ): VersionReduxState { switch (action.type) { case 'UPDATE_LATEST': { + const currentIsSupported = action.latestInfo.currentIsSupported; const latest = action.latestInfo.latest.latest; const latestStable = action.latestInfo.latest.latestStable; return { ...state, + currentIsSupported, latest, latestStable, + nextUpgrade: nextUpgrade(state.current, latest, latestStable), upToDate: checkIfLatest(state.current, latest, latestStable), }; } @@ -44,6 +69,7 @@ export default function( ...state, current: action.version, consistent: action.consistent, + nextUpgrade: nextUpgrade(action.version, state.latest, state.latestStable), upToDate: checkIfLatest(action.version, state.latest, state.latestStable), }; diff --git a/gui/packages/desktop/test/components/Connect.spec.js b/gui/packages/desktop/test/components/Connect.spec.js deleted file mode 100644 index c6a55ffd4c..0000000000 --- a/gui/packages/desktop/test/components/Connect.spec.js +++ /dev/null @@ -1,67 +0,0 @@ -// @flow - -import * as React from 'react'; -import { shallow } from 'enzyme'; - -import { TunnelBanner } from '../../src/renderer/components/Connect'; - -describe('components/Connect', () => { - describe('TunnelBanner', () => { - it('invisible when disconnecting', () => { - for (const reason of ['nothing', 'block', 'reconnect']) { - const component = shallow( - <TunnelBanner - tunnelState={{ - state: 'disconnecting', - details: { reason }, - }} - />, - ); - expect(component.state('visible')).to.be.false; - } - }); - - it('invisible when connected or disconnected', () => { - for (const state of ['connected', 'disconnected']) { - const component = shallow( - <TunnelBanner - tunnelState={{ - state, - }} - />, - ); - expect(component.state('visible')).to.be.false; - } - }); - - it('visible when connecting', () => { - const component = shallow( - <TunnelBanner - tunnelState={{ - state: 'connecting', - }} - />, - ); - - expect(component.state('visible')).to.be.true; - expect(component.state('title')).to.not.be.empty; - }); - - it('visible when blocked', () => { - const component = shallow( - <TunnelBanner - tunnelState={{ - state: 'blocked', - details: { - reason: 'no_matching_relay', - }, - }} - />, - ); - - expect(component.state('visible')).to.be.true; - expect(component.state('title')).to.not.be.empty; - expect(component.state('subtitle')).to.not.be.empty; - }); - }); -}); diff --git a/gui/packages/desktop/test/components/NotificationArea.spec.js b/gui/packages/desktop/test/components/NotificationArea.spec.js new file mode 100644 index 0000000000..bdd001e8ab --- /dev/null +++ b/gui/packages/desktop/test/components/NotificationArea.spec.js @@ -0,0 +1,159 @@ +// @flow + +import * as React from 'react'; +import { shallow } from 'enzyme'; +import NotificationArea from '../../src/renderer/components/NotificationArea'; + +describe('components/NotificationArea', () => { + const defaultVersion = { + consistent: true, + currentIsSupported: true, + upToDate: true, + current: '2018.2', + latest: '2018.2-beta1', + latestStable: '2018.2', + nextUpgrade: null, + }; + + it('handles disconnecting state', () => { + for (const reason of ['nothing', 'block', 'reconnect']) { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'disconnecting', + details: { reason }, + }} + version={defaultVersion} + />, + ); + expect(component.state('visible')).to.be.false; + } + }); + + it('handles connected or disconnected states', () => { + for (const state of ['connected', 'disconnected']) { + const component = shallow( + <NotificationArea + tunnelState={{ + state, + }} + version={defaultVersion} + />, + ); + + expect(component.state('visible')).to.be.false; + } + }); + + it('handles connecting state', () => { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'connecting', + }} + version={defaultVersion} + />, + ); + + expect(component.state('type')).to.be.equal('blocking'); + expect(component.state('visible')).to.be.true; + }); + + it('handles blocked state', () => { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'blocked', + details: { + reason: 'no_matching_relay', + }, + }} + version={defaultVersion} + />, + ); + + expect(component.state('type')).to.be.equal('blocking'); + expect(component.state('visible')).to.be.true; + }); + + it('handles inconsistent version', () => { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'disconnected', + }} + version={{ + ...defaultVersion, + consistent: false, + }} + />, + ); + + expect(component.state('type')).to.be.equal('inconsistent-version'); + expect(component.state('visible')).to.be.true; + }); + + it('handles unsupported version', () => { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'disconnected', + }} + version={{ + ...defaultVersion, + currentIsSupported: false, + upToDate: false, + current: '2018.1', + nextUpgrade: '2018.2', + }} + />, + ); + + expect(component.state('type')).to.be.equal('unsupported-version'); + expect(component.state('visible')).to.be.true; + }); + + it('handles stable update available', () => { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'disconnected', + }} + version={{ + ...defaultVersion, + upToDate: false, + current: '2018.2', + latest: '2018.4-beta2', + latestStable: '2018.3', + nextUpgrade: '2018.3', + }} + />, + ); + + expect(component.state('type')).to.be.equal('update-available'); + expect(component.state('upgradeVersion')).to.be.equal('2018.3'); + expect(component.state('visible')).to.be.true; + }); + + it('handles beta update available', () => { + const component = shallow( + <NotificationArea + tunnelState={{ + state: 'disconnected', + }} + version={{ + ...defaultVersion, + upToDate: false, + current: '2018.4-beta1', + latest: '2018.4-beta3', + latestStable: '2018.3', + nextUpgrade: '2018.4-beta3', + }} + />, + ); + + expect(component.state('type')).to.be.equal('update-available'); + expect(component.state('upgradeVersion')).to.be.equal('2018.4-beta3'); + expect(component.state('visible')).to.be.true; + }); +}); diff --git a/gui/packages/desktop/test/components/Switch.spec.js b/gui/packages/desktop/test/components/Switch.spec.js deleted file mode 100644 index cacbddb771..0000000000 --- a/gui/packages/desktop/test/components/Switch.spec.js +++ /dev/null @@ -1,128 +0,0 @@ -// @flow - -import * as React from 'react'; -import ReactDOM from 'react-dom'; -import ReactTestUtils, { Simulate } from 'react-dom/test-utils'; -import Switch from '../../src/renderer/components/Switch'; - -describe('components/Switch', () => { - let container: ?HTMLElement; - - function renderIntoDocument(instance: React.Element<*>): React.Component<*, *> { - if (container) { - throw new Error('Unmount previously rendered component first.'); - } - - container = document.createElement('div'); - if (!document.documentElement) { - throw new Error('document.documentElement cannot be null.'); - } - - document.documentElement.appendChild(container); - - return ReactDOM.render(instance, container); - } - - // unmount container and clean up DOM - afterEach(() => { - if (container) { - ReactDOM.unmountComponentAtNode(container); - container = null; - } - }); - - it('should switch on', (done) => { - const onChange = (isOn) => { - expect(isOn).to.be.true; - done(); - }; - const component = renderIntoDocument(<Switch isOn={false} onChange={onChange} />); - const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input'); - // See: https://github.com/facebook/flow/pull/5841 - if (domNode) { - Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 }); - Simulate.mouseUp(domNode, { clientX: 100, clientY: 0 }); - Simulate.change(domNode, { target: { checked: true } }); - } - }); - - it('should switch off', (done) => { - const onChange = (isOn) => { - expect(isOn).to.be.false; - done(); - }; - const component = renderIntoDocument(<Switch isOn={true} onChange={onChange} />); - const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input'); - // See: https://github.com/facebook/flow/pull/5841 - if (domNode) { - Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 }); - Simulate.mouseUp(domNode, { clientX: 100, clientY: 0 }); - Simulate.change(domNode, { target: { checked: false } }); - } - }); - - it('should handle left to right swipe', (done) => { - const onChange = (isOn) => { - expect(isOn).to.be.true; - done(); - }; - const component = renderIntoDocument(<Switch isOn={false} onChange={onChange} />); - const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input'); - // See: https://github.com/facebook/flow/pull/5841 - if (domNode) { - Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 }); - } - - // Switch listens to events on document - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 150, clientY: 0 })); - document.dispatchEvent(new MouseEvent('mouseup', { clientX: 150, clientY: 0 })); - }); - - it('should handle right to left swipe', (done) => { - const onChange = (isOn) => { - expect(isOn).to.be.false; - done(); - }; - const component = renderIntoDocument(<Switch isOn={true} onChange={onChange} />); - const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input'); - - // See: https://github.com/facebook/flow/pull/5841 - if (domNode) { - Simulate.mouseDown(domNode, { clientX: 150, clientY: 0 }); - } - - // Switch listens to events on document - document.dispatchEvent(new MouseEvent('mousemove', { clientX: 100, clientY: 0 })); - document.dispatchEvent(new MouseEvent('mouseup', { clientX: 100, clientY: 0 })); - }); - - it('should timeout when user holds knob for too long without moving', (done) => { - const onChange = () => { - throw new Error('onChange should not be called on timeout.'); - }; - - const component = renderIntoDocument(<Switch isOn={false} onChange={onChange} />); - - const domNode = ReactTestUtils.findRenderedDOMComponentWithTag(component, 'input'); - // See: https://github.com/facebook/flow/pull/5841 - if (domNode) { - Simulate.mouseDown(domNode, { clientX: 100, clientY: 0 }); - } - - setTimeout(() => { - // Switch listens to events on document - document.dispatchEvent(new MouseEvent('mouseup', { clientX: 100, clientY: 0 })); - - try { - // See: https://github.com/facebook/flow/pull/5841 - if (domNode) { - // should not trigger onChange() - Simulate.change(domNode); - } - done(); - } catch (e) { - done(e); - } - }, 1000); - }); -}); |
