diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2020-06-04 13:45:06 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2020-06-10 13:46:17 +0200 |
| commit | bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc (patch) | |
| tree | 3ab241e849f8f22f5bc0a8e5af226c8b30e4552e /gui/src/renderer | |
| parent | d7fdccb58e8205ae26023629873922979644bfdd (diff) | |
| download | mullvadvpn-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.tsx | 6 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationArea.tsx | 376 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationBanner.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/containers/NotificationAreaContainer.tsx | 30 | ||||
| -rw-r--r-- | gui/src/renderer/lib/auth-failure.ts | 81 |
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.'); - } -} |
