summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2018-10-10 15:43:40 +0200
committerAndrej Mihajlov <and@mullvad.net>2018-10-10 15:43:40 +0200
commit68f55f46a0f6d922083acffa77f4651afa3c96b7 (patch)
treecf0519bbaa87f6ad242d06df383f43c2978adf8d
parenta4b3566e0744a23a41ee626a3fcd2469fa2f06a9 (diff)
parent6c80a59a69996b6b78b074520e8cb359220a75e4 (diff)
downloadmullvadvpn-68f55f46a0f6d922083acffa77f4651afa3c96b7.tar.xz
mullvadvpn-68f55f46a0f6d922083acffa77f4651afa3c96b7.zip
Merge branch 'show-outdated-version-banner'
-rw-r--r--CHANGELOG.md2
-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/Img.js11
-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.js32
-rw-r--r--gui/packages/desktop/test/components/Connect.spec.js67
-rw-r--r--gui/packages/desktop/test/components/NotificationArea.spec.js159
-rw-r--r--gui/packages/desktop/test/components/Switch.spec.js128
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);
- });
-});