summaryrefslogtreecommitdiffhomepage
path: root/gui/src/renderer
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-06-04 13:45:06 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-06-10 13:46:17 +0200
commitbfd3b02a3936451ae43867f8f3e2f986a2a2f9bc (patch)
tree3ab241e849f8f22f5bc0a8e5af226c8b30e4552e /gui/src/renderer
parentd7fdccb58e8205ae26023629873922979644bfdd (diff)
downloadmullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.tar.xz
mullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.zip
Categorize notifications and their logic into notification definition
Diffstat (limited to 'gui/src/renderer')
-rw-r--r--gui/src/renderer/components/Connect.tsx6
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx376
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx5
-rw-r--r--gui/src/renderer/containers/NotificationAreaContainer.tsx30
-rw-r--r--gui/src/renderer/lib/auth-failure.ts81
5 files changed, 86 insertions, 412 deletions
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
index f4070c459f..4acab681bc 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/Connect.tsx
@@ -3,8 +3,8 @@ import { Component, Styles, View } from 'reactxp';
import styled from 'styled-components';
import AccountExpiry from '../../shared/account-expiry';
import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer';
-import NotificationAreaContainer from '../containers/NotificationAreaContainer';
-import { AuthFailureKind, parseAuthFailure } from '../lib/auth-failure';
+import NotificationArea from '../components/NotificationArea';
+import { AuthFailureKind, parseAuthFailure } from '../../shared/auth-failure';
import { LoginState } from '../redux/account/reducers';
import { IConnectionReduxState } from '../redux/connection/reducers';
import { Brand, HeaderBarStyle, SettingsBarButton } from './HeaderBar';
@@ -165,7 +165,7 @@ export default class Connect extends Component<IProps, IState> {
onSelectLocation={this.props.onSelectLocation}
/>
- <NotificationAreaContainer style={styles.notificationArea} />
+ <NotificationArea style={styles.notificationArea} />
</View>
</View>
);
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
index 01607c3840..bc76ba0885 100644
--- a/gui/src/renderer/components/NotificationArea.tsx
+++ b/gui/src/renderer/components/NotificationArea.tsx
@@ -1,8 +1,23 @@
-import moment from 'moment';
-import * as React from 'react';
-import { Component, Types } from 'reactxp';
-import { sprintf } from 'sprintf-js';
-import { messages } from '../../shared/gettext';
+import { shell } from 'electron';
+import log from 'electron-log';
+import React, { useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { Types } from 'reactxp';
+import AccountExpiry from '../../shared/account-expiry';
+import {
+ AccountExpiryNotificationProvider,
+ BlockWhenDisconnectedNotificationProvider,
+ ConnectingNotificationProvider,
+ ErrorNotificationProvider,
+ InAppNotificationProvider,
+ InconsistentVersionNotificationProvider,
+ NotificationAction,
+ ReconnectingNotificationProvider,
+ UnsupportedVersionNotificationProvider,
+ UpdateAvailableNotificationProvider,
+} from '../../shared/notifications/notification';
+import { useAppContext } from '../context';
+import { IReduxState } from '../redux/store';
import {
NotificationActions,
NotificationBanner,
@@ -13,313 +28,82 @@ import {
NotificationTitle,
} from './NotificationBanner';
-import AccountExpiry from '../../shared/account-expiry';
-import { ErrorStateCause, TunnelParameterError, TunnelState } from '../../shared/daemon-rpc-types';
-import { parseAuthFailure } from '../lib/auth-failure';
-import { IVersionReduxState } from '../redux/version/reducers';
-
interface IProps {
style?: Types.ViewStyleRuleSet;
- accountExpiry?: AccountExpiry;
- tunnelState: TunnelState;
- version: IVersionReduxState;
- blockWhenDisconnected: boolean;
- onOpenDownloadLink: () => Promise<void>;
- onOpenBuyMoreLink: () => Promise<void>;
}
-type NotificationAreaPresentation =
- | { type: 'failure-unsecured'; reason: string }
- | { type: 'blocking'; reason: string }
- | { type: 'inconsistent-version' }
- | { type: 'unsupported-version'; upgradeVersion: string }
- | { type: 'update-available'; upgradeVersion: string }
- | { type: 'expires-soon'; timeLeft: string };
+export default function NotificationArea(props: IProps) {
+ const accountExpiry = useSelector((state: IReduxState) =>
+ state.account.expiry
+ ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
+ : undefined,
+ );
+ const tunnelState = useSelector((state: IReduxState) => state.connection.status);
+ const version = useSelector((state: IReduxState) => state.version);
+ const blockWhenDisconnected = useSelector(
+ (state: IReduxState) => state.settings.blockWhenDisconnected,
+ );
-type State = NotificationAreaPresentation & {
- visible: boolean;
-};
+ const notificationProviders: InAppNotificationProvider[] = [
+ new ConnectingNotificationProvider({ tunnelState }),
+ new ReconnectingNotificationProvider(tunnelState),
+ new BlockWhenDisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }),
+ new ErrorNotificationProvider(tunnelState),
+ new InconsistentVersionNotificationProvider({ consistent: version.consistent }),
+ new UnsupportedVersionNotificationProvider(version),
+ new UpdateAvailableNotificationProvider(version),
+ ];
-function getTunnelParameterMessage(err: TunnelParameterError): string {
- switch (err) {
- /// TODO: once bridge constraints can be set, add a more descriptive error message
- case 'no_matching_bridge_relay':
- case 'no_matching_relay':
- return messages.pgettext(
- 'in-app-notifications',
- 'No relay server matches the current settings. You can try changing the location or the relay settings.',
- );
- case 'no_wireguard_key':
- return messages.pgettext(
- 'in-app-notifications',
- 'Valid WireGuard key is missing. Manage keys under Advanced settings.',
- );
- case 'custom_tunnel_host_resultion_error':
- return messages.pgettext(
- 'in-app-notifications',
- 'Failed to resolve host of custom tunnel. Consider changing the settings',
- );
+ if (accountExpiry) {
+ notificationProviders.push(new AccountExpiryNotificationProvider({ accountExpiry }));
}
-}
-function getErrorCauseMessage(blockReason: ErrorStateCause): string {
- switch (blockReason.reason) {
- case 'auth_failed':
- return parseAuthFailure(blockReason.details).message;
- case 'ipv6_unavailable':
- return messages.pgettext(
- 'in-app-notifications',
- 'Could not configure IPv6, please enable it on your system or disable it in the app',
- );
- case 'set_firewall_policy_error': {
- let extraMessage = null;
- switch (process.platform) {
- case 'linux':
- extraMessage = messages.pgettext('in-app-notifications', 'Your kernel may be outdated');
- break;
- case 'win32':
- extraMessage = messages.pgettext(
- 'in-app-notifications',
- 'This might be caused by third party security software',
- );
- break;
- }
- return `${messages.pgettext(
- 'in-app-notifications',
- 'Failed to apply firewall rules. The device might currently be unsecured',
- )}${extraMessage ? '. ' + extraMessage : ''}`;
- }
- case 'set_dns_error':
- return messages.pgettext('in-app-notifications', 'Failed to set system DNS server');
- case 'start_tunnel_error':
- return messages.pgettext('in-app-notifications', 'Failed to start tunnel connection');
- case 'tunnel_parameter_error':
- return getTunnelParameterMessage(blockReason.details);
- case 'is_offline':
- return messages.pgettext(
- 'in-app-notifications',
- 'This device is offline, no tunnels can be established',
+ const notificationProvider = notificationProviders.find((notification) =>
+ notification.mayDisplay(),
+ );
+
+ if (notificationProvider) {
+ const notification = notificationProvider.getInAppNotification();
+
+ if (notification) {
+ return (
+ <NotificationBanner style={props.style} visible>
+ <NotificationIndicator type={notification.indicator} />
+ <NotificationContent>
+ <NotificationTitle>{notification.title}</NotificationTitle>
+ <NotificationSubtitle>{notification.subtitle}</NotificationSubtitle>
+ </NotificationContent>
+ {notification.action && <NotificationActionWrapper action={notification.action} />}
+ </NotificationBanner>
);
- case 'tap_adapter_problem':
- return messages.pgettext(
- 'in-app-notifications',
- "Unable to detect a working TAP adapter on this device. If you've disabled it, enable it again. Otherwise, please reinstall the app",
+ } else {
+ log.error(
+ `Notification providers mayDisplay() returned true but getInAppNotification() returned undefined for ${notificationProvider.constructor.name}`,
);
+ }
}
-}
-function capitalizeFirstLetter(inputString: string): string {
- return inputString.charAt(0).toUpperCase() + inputString.slice(1);
+ return <NotificationBanner style={props.style} visible={false} />;
}
-export default class NotificationArea extends Component<IProps, State> {
- public static getDerivedStateFromProps(props: IProps, state: State) {
- const { accountExpiry, blockWhenDisconnected, tunnelState, version } = props;
-
- switch (tunnelState.state) {
- case 'connecting':
- return {
- visible: true,
- type: 'blocking',
- reason: '',
- };
-
- case 'error':
- if (tunnelState.details.isBlocking) {
- return {
- visible: true,
- type: 'blocking',
- reason: getErrorCauseMessage(tunnelState.details.cause),
- };
- } else {
- return {
- visible: true,
- type: 'failure-unsecured',
- reason: getErrorCauseMessage(tunnelState.details.cause),
- };
- }
-
- case 'disconnecting':
- if (tunnelState.details === 'reconnect') {
- return {
- visible: true,
- type: 'blocking',
- reason: '',
- };
- }
- // fallthrough
-
- case 'disconnected':
- if (blockWhenDisconnected) {
- return {
- visible: true,
- type: 'blocking',
- reason: messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'),
- };
- }
- // fallthrough
-
- default:
- if (!version.consistent) {
- return {
- visible: true,
- type: 'inconsistent-version',
- };
- }
-
- if (!version.supported && version.nextUpgrade) {
- return {
- visible: true,
- type: 'unsupported-version',
- upgradeVersion: version.nextUpgrade,
- };
- }
-
- if (version.nextUpgrade && version.nextUpgrade !== version.current) {
- return {
- visible: true,
- type: 'update-available',
- upgradeVersion: version.nextUpgrade,
- };
- }
+interface INotificationActionWrapperProps {
+ action: NotificationAction;
+}
- if (accountExpiry && accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate())) {
- return {
- visible: true,
- type: 'expires-soon',
- timeLeft: capitalizeFirstLetter(accountExpiry.remainingTime()),
- };
- }
+function NotificationActionWrapper(props: INotificationActionWrapperProps) {
+ const { openLinkWithAuth } = useAppContext();
- return {
- ...state,
- visible: false,
- };
+ const handlePress = useCallback(() => {
+ if (props.action.withAuth) {
+ return openLinkWithAuth(props.action.url);
+ } else {
+ return shell.openExternal(props.action.url);
}
- }
+ }, []);
- public state: State = {
- type: 'blocking',
- reason: '',
- visible: false,
- };
-
- public render() {
- return (
- <NotificationBanner style={this.props.style} visible={this.state.visible}>
- {this.state.type === 'failure-unsecured' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'YOU MIGHT BE LEAKING NETWORK TRAFFIC')}
- </NotificationTitle>
- <NotificationSubtitle>
- {messages.pgettext(
- 'in-app-notifications',
- 'Failed to block all network traffic. Please troubleshoot or report the problem to us.',
- )}
- </NotificationSubtitle>
- </NotificationContent>
- </React.Fragment>
- )}
-
- {this.state.type === 'blocking' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'BLOCKING INTERNET')}
- </NotificationTitle>
- <NotificationSubtitle>{this.state.reason}</NotificationSubtitle>
- </NotificationContent>
- </React.Fragment>
- )}
-
- {this.state.type === 'inconsistent-version' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'INCONSISTENT VERSION')}
- </NotificationTitle>
- <NotificationSubtitle>
- {messages.pgettext(
- 'in-app-notifications',
- 'Inconsistent internal version information, please restart the app',
- )}
- </NotificationSubtitle>
- </NotificationContent>
- </React.Fragment>
- )}
-
- {this.state.type === 'unsupported-version' && (
- <React.Fragment>
- <NotificationIndicator type={'error'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION')}
- </NotificationTitle>
- <NotificationSubtitle>
- {sprintf(
- // TRANSLATORS: The in-app banner displayed to the user when the running app becomes unsupported.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(version)s - the newest available version of the app
- messages.pgettext(
- 'in-app-notifications',
- 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security',
- ),
- { version: this.state.upgradeVersion },
- )}
- </NotificationSubtitle>
- </NotificationContent>
- <NotificationActions>
- <NotificationOpenLinkAction onPress={this.props.onOpenDownloadLink} />
- </NotificationActions>
- </React.Fragment>
- )}
-
- {this.state.type === 'update-available' && (
- <React.Fragment>
- <NotificationIndicator type={'warning'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE')}
- </NotificationTitle>
- <NotificationSubtitle>
- {sprintf(
- // TRANSLATORS: The in-app banner displayed to the user when the app update is available.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(version)s - the newest available version of the app
- messages.pgettext(
- 'in-app-notifications',
- 'Install Mullvad VPN (%(version)s) to stay up to date',
- ),
- { version: this.state.upgradeVersion },
- )}
- </NotificationSubtitle>
- </NotificationContent>
- <NotificationActions>
- <NotificationOpenLinkAction onPress={this.props.onOpenDownloadLink} />
- </NotificationActions>
- </React.Fragment>
- )}
-
- {this.state.type === 'expires-soon' && (
- <React.Fragment>
- <NotificationIndicator type={'warning'} />
- <NotificationContent>
- <NotificationTitle>
- {messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON')}
- </NotificationTitle>
- <NotificationSubtitle>{this.state.timeLeft}</NotificationSubtitle>
- </NotificationContent>
- <NotificationActions>
- <NotificationOpenLinkAction onPress={this.props.onOpenBuyMoreLink} />
- </NotificationActions>
- </React.Fragment>
- )}
- </NotificationBanner>
- );
- }
+ return (
+ <NotificationActions>
+ <NotificationOpenLinkAction onPress={handlePress} />
+ </NotificationActions>
+ );
}
diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx
index 5831676fcf..207c3f6081 100644
--- a/gui/src/renderer/components/NotificationBanner.tsx
+++ b/gui/src/renderer/components/NotificationBanner.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { Animated, Button, Component, Styles, Text, Types, UserInterface, View } from 'reactxp';
import { colors } from '../../config.json';
+import { InAppNotificationIndicatorType } from '../../shared/notifications/notification';
import consumePromise from '../../shared/promise';
import { BlockingButton } from './AppButton';
import ImageView from './ImageView';
@@ -151,7 +152,7 @@ export class NotificationActions extends Component<INotificationActionsProps> {
}
interface INotificationIndicatorProps {
- type: 'success' | 'warning' | 'error';
+ type: InAppNotificationIndicatorType;
children?: React.ReactNode;
}
@@ -162,7 +163,7 @@ export class NotificationIndicator extends Component<INotificationIndicatorProps
}
interface INotificationBannerProps {
- children: React.ReactNode; // Array<NotificationContent | NotificationActions>,
+ children?: React.ReactNode; // Array<NotificationContent | NotificationActions>,
style?: Types.ViewStyleRuleSet;
visible: boolean;
animationDuration: number;
diff --git a/gui/src/renderer/containers/NotificationAreaContainer.tsx b/gui/src/renderer/containers/NotificationAreaContainer.tsx
deleted file mode 100644
index 1eed9f4a17..0000000000
--- a/gui/src/renderer/containers/NotificationAreaContainer.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { connect } from 'react-redux';
-
-import { shell } from 'electron';
-import { links } from '../../config.json';
-import AccountExpiry from '../../shared/account-expiry';
-import NotificationArea from '../components/NotificationArea';
-import withAppContext, { IAppContext } from '../context';
-import { IReduxState, ReduxDispatch } from '../redux/store';
-
-const mapStateToProps = (state: IReduxState, _props: IAppContext) => ({
- accountExpiry: state.account.expiry
- ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
- : undefined,
- tunnelState: state.connection.status,
- version: state.version,
- blockWhenDisconnected: state.settings.blockWhenDisconnected,
-});
-
-const mapDispatchToProps = (_dispatch: ReduxDispatch, props: IAppContext) => {
- return {
- onOpenDownloadLink(): Promise<void> {
- return shell.openExternal(links.download);
- },
- onOpenBuyMoreLink(): Promise<void> {
- return props.app.openLinkWithAuth(links.purchase);
- },
- };
-};
-
-export default withAppContext(connect(mapStateToProps, mapDispatchToProps)(NotificationArea));
diff --git a/gui/src/renderer/lib/auth-failure.ts b/gui/src/renderer/lib/auth-failure.ts
deleted file mode 100644
index ead0b08a81..0000000000
--- a/gui/src/renderer/lib/auth-failure.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { messages } from '../../shared/gettext';
-
-export enum AuthFailureKind {
- invalidAccount,
- expiredAccount,
- tooManyConnections,
- unknown,
-}
-
-interface IAuthFailure {
- kind: AuthFailureKind;
- message: string;
-}
-
-export function parseAuthFailure(rawFailureMessage?: string): IAuthFailure {
- if (rawFailureMessage) {
- const results = /^\[(\w+)\]\s*(.*)$/.exec(rawFailureMessage);
-
- if (results && results.length === 3) {
- const kind = parseRawFailureKind(results[1]);
- const message = kind === AuthFailureKind.unknown ? results[2] : messageForFailureKind(kind);
-
- return {
- kind,
- message,
- };
- } else {
- return {
- kind: AuthFailureKind.unknown,
- message: rawFailureMessage,
- };
- }
- } else {
- return {
- kind: AuthFailureKind.unknown,
- message: messageForFailureKind(AuthFailureKind.unknown),
- };
- }
-}
-
-function parseRawFailureKind(failureId: string): AuthFailureKind {
- // These strings should match up with mullvad-types/src/auth_failed.rs
- switch (failureId) {
- case 'INVALID_ACCOUNT':
- return AuthFailureKind.invalidAccount;
-
- case 'EXPIRED_ACCOUNT':
- return AuthFailureKind.expiredAccount;
-
- case 'TOO_MANY_CONNECTIONS':
- return AuthFailureKind.tooManyConnections;
-
- default:
- return AuthFailureKind.unknown;
- }
-}
-
-function messageForFailureKind(kind: AuthFailureKind): string {
- switch (kind) {
- case AuthFailureKind.invalidAccount:
- return messages.pgettext(
- 'auth-failure',
- "You've logged in with an account number that is not valid. Please log out and try another one.",
- );
-
- case AuthFailureKind.expiredAccount:
- return messages.pgettext(
- 'auth-failure',
- 'You have no more VPN time left on this account. Please log in on our website to buy more credit.',
- );
-
- case AuthFailureKind.tooManyConnections:
- return messages.pgettext(
- 'auth-failure',
- 'This account has too many simultaneous connections. Disconnect another device or try connecting again shortly.',
- );
-
- case AuthFailureKind.unknown:
- return messages.pgettext('auth-failure', 'Account authentication failed.');
- }
-}