summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--gui/packages/desktop/src/renderer/components/BlockingInternetBanner.js69
-rw-r--r--gui/packages/desktop/src/renderer/components/Connect.js114
-rw-r--r--gui/packages/desktop/src/renderer/components/ConnectStyles.js2
-rw-r--r--gui/packages/desktop/src/renderer/components/NotificationArea.js176
-rw-r--r--gui/packages/desktop/src/renderer/components/NotificationBanner.js266
-rw-r--r--gui/packages/desktop/src/renderer/containers/ConnectPage.js1
-rw-r--r--gui/packages/desktop/src/renderer/redux/version/reducers.js1
-rw-r--r--gui/packages/desktop/test/components/Connect.spec.js67
-rw-r--r--gui/packages/desktop/test/components/NotificationArea.spec.js159
9 files changed, 617 insertions, 238 deletions
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/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 caf14ad560..c3d6a07382 100644
--- a/gui/packages/desktop/src/renderer/redux/version/reducers.js
+++ b/gui/packages/desktop/src/renderer/redux/version/reducers.js
@@ -4,6 +4,7 @@ import type { ReduxAction } from '../store';
export type VersionReduxState = {
current: string,
+ currentIsSupported: boolean,
latest: ?string,
latestStable: ?string,
nextUpgrade: ?string,
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;
+ });
+});