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 | |
| parent | d7fdccb58e8205ae26023629873922979644bfdd (diff) | |
| download | mullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.tar.xz mullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.zip | |
Categorize notifications and their logic into notification definition
Diffstat (limited to 'gui/src')
19 files changed, 782 insertions, 538 deletions
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts index ba9f8a9baa..b911a19c6a 100644 --- a/gui/src/main/index.ts +++ b/gui/src/main/index.ts @@ -1,8 +1,7 @@ import { execFile } from 'child_process'; -import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, Tray } from 'electron'; +import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from 'electron'; import log from 'electron-log'; import mkdirp from 'mkdirp'; -import moment from 'moment'; import * as path from 'path'; import * as uuid from 'uuid'; import AccountExpiry from '../shared/account-expiry'; @@ -28,6 +27,11 @@ import { loadTranslations, messages } from '../shared/gettext'; import { SYSTEM_PREFERRED_LOCALE_KEY } from '../shared/gui-settings-state'; import { IpcMainEventChannel } from '../shared/ipc-event-channel'; import { + AccountExpiryNotificationProvider, + InconsistentVersionNotificationProvider, + UnsupportedVersionNotificationProvider, +} from '../shared/notifications/notification'; +import { backupLogFile, getLogsDirectory, getMainLogFile, @@ -76,7 +80,11 @@ export interface IAppUpgradeInfo extends IAppVersionInfo { type AccountVerification = { status: 'verified' } | { status: 'deferred'; error: Error }; class ApplicationMain { - private notificationController = new NotificationController(); + private notificationController = new NotificationController({ + openLink: (url: string, withAuth?: boolean) => this.openLink(url, withAuth), + isWindowVisible: () => this.windowController?.isVisible() ?? false, + areSystemNotificationsEnabled: () => this.guiSettings.enableSystemNotifications, + }); private windowController?: WindowController; private trayIconController?: TrayIconController; @@ -463,12 +471,11 @@ class ApplicationMain { consumePromise(this.fetchLatestVersion()); // notify user about inconsistent version - if ( - process.env.NODE_ENV !== 'development' && - !this.shouldSuppressNotifications(true) && - !this.currentVersion.isConsistent - ) { - this.notificationController.notifyInconsistentVersion(); + const notificationProvider = new InconsistentVersionNotificationProvider({ + consistent: this.currentVersion.isConsistent, + }); + if (notificationProvider.mayDisplay()) { + this.notificationController.notify(notificationProvider.getSystemNotification()); } // reset the reconnect backoff when connection established. @@ -602,9 +609,7 @@ class ApplicationMain { this.updateTrayIcon(newState, this.settings.blockWhenDisconnected); consumePromise(this.updateLocation()); - if (!this.shouldSuppressNotifications(false)) { - this.notificationController.notifyTunnelState(newState); - } + this.notificationController.notifyTunnelState(newState, this.settings.blockWhenDisconnected); if (this.windowController) { IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState); @@ -784,14 +789,13 @@ class ApplicationMain { this.upgradeVersion = upgradeInfo; // notify user to update the app if it became unsupported - if ( - process.env.NODE_ENV !== 'development' && - !this.shouldSuppressNotifications(true) && - currentVersionInfo.isConsistent && - !latestVersionInfo.supported && - upgradeVersion - ) { - this.notificationController.notifyUnsupportedVersion(upgradeVersion); + const notificationProvider = new UnsupportedVersionNotificationProvider({ + supported: latestVersionInfo.supported, + consistent: currentVersionInfo.isConsistent, + nextUpgrade: upgradeVersion, + }); + if (notificationProvider.mayDisplay()) { + this.notificationController.notify(notificationProvider.getSystemNotification()); } if (this.windowController) { @@ -807,16 +811,6 @@ class ApplicationMain { } } - private shouldSuppressNotifications(isCriticalNotification: boolean): boolean { - const isVisible = this.windowController ? this.windowController.isVisible() : false; - - if (isCriticalNotification) { - return isVisible; - } else { - return isVisible || !this.guiSettings.enableSystemNotifications; - } - } - private async updateLocation() { const tunnelState = this.tunnelState; @@ -1194,13 +1188,12 @@ class ApplicationMain { private notifyOfAccountExpiry() { if (this.accountData) { const accountExpiry = new AccountExpiry(this.accountData.expiry, this.locale); - if ( - accountExpiry && - !accountExpiry.hasExpired() && - !this.accountExpiryNotificationTimeout && - accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate()) - ) { - this.notificationController.closeToExpiryNotification(accountExpiry); + const notificationProvider = new AccountExpiryNotificationProvider({ + accountExpiry, + tooSoon: this.accountExpiryNotificationTimeout !== undefined, + }); + if (notificationProvider.mayDisplay()) { + this.notificationController.notify(notificationProvider.getSystemNotification()); this.accountExpiryNotificationTimeout = global.setTimeout(() => { this.accountExpiryNotificationTimeout = undefined; this.notifyOfAccountExpiry(); @@ -1497,6 +1490,20 @@ class ApplicationMain { return true; } } + + private async openLink(url: string, withAuth?: boolean) { + if (withAuth) { + let token = ''; + try { + token = await this.daemonRpc.getWwwAuthToken(); + } catch (e) { + log.error(`Failed to get the WWW auth token: ${e.message}`); + } + return shell.openExternal(`${url}?token=${token}`); + } else { + return shell.openExternal(url); + } + } } const applicationMain = new ApplicationMain(); diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts index 9c3cf2193c..548c20e122 100644 --- a/gui/src/main/notification-controller.ts +++ b/gui/src/main/notification-controller.ts @@ -1,13 +1,26 @@ -import { app, nativeImage, NativeImage, Notification, shell } from 'electron'; +import { app, nativeImage, NativeImage, Notification } from 'electron'; +import log from 'electron-log'; import os from 'os'; import path from 'path'; -import { sprintf } from 'sprintf-js'; -import config from '../config.json'; -import AccountExpiry from '../shared/account-expiry'; import { TunnelState } from '../shared/daemon-rpc-types'; -import { messages } from '../shared/gettext'; +import { + BlockWhenDisconnectedNotificationProvider, + ConnectedNotificationProvider, + ConnectingNotificationProvider, + DisconnectedNotificationProvider, + ErrorNotificationProvider, + ReconnectingNotificationProvider, + SystemNotification, + SystemNotificationProvider, +} from '../shared/notifications/notification'; import consumePromise from '../shared/promise'; +interface NotificationControllerDelegate { + openLink(url: string, withAuth?: boolean): Promise<void>; + isWindowVisible(): boolean; + areSystemNotificationsEnabled(): boolean; +} + export default class NotificationController { private lastTunnelStateAnnouncement?: { body: string; notification: Notification }; private reconnecting = false; @@ -16,7 +29,7 @@ export default class NotificationController { private notificationTitle = process.platform === 'linux' ? app.name : ''; private notificationIcon?: NativeImage; - constructor() { + constructor(private notificationControllerDelegate: NotificationControllerDelegate) { let usePngIcon; if (process.platform === 'linux') { usePngIcon = true; @@ -34,147 +47,34 @@ export default class NotificationController { } } - public notifyTunnelState(tunnelState: TunnelState) { - switch (tunnelState.state) { - case 'connecting': - if (!this.reconnecting) { - const details = tunnelState.details; - if (details && details.location && details.location.hostname) { - const msg = sprintf( - // TRANSLATORS: The message showed when a server is being connected to. - // TRANSLATORS: Available placeholder: - // TRANSLATORS: %(location) - name of the server location we're connecting to (e.g. "se-got-003") - messages.pgettext('notifications', 'Connecting to %(location)s'), - { - location: details.location.hostname, - }, - ); - this.showTunnelStateNotification(msg); - } else { - this.showTunnelStateNotification(messages.pgettext('notifications', 'Connecting')); - } - } - break; - case 'connected': - { - const details = tunnelState.details; - if (details.location && details.location.hostname) { - const msg = sprintf( - // TRANSLATORS: The message showed when a server has been connected to. - // TRANSLATORS: Available placeholder: - // TRANSLATORS: %(location) - name of the server location we're connected to (e.g. "se-got-003") - messages.pgettext('notifications', 'Connected to %(location)s'), - { - location: details.location.hostname, - }, - ); - this.showTunnelStateNotification(msg); - } else { - this.showTunnelStateNotification(messages.pgettext('notifications', 'Secured')); - } - } - break; - case 'disconnected': - this.showTunnelStateNotification(messages.pgettext('notifications', 'Unsecured')); - break; - case 'error': - if (tunnelState.details.isBlocking) { - if ( - tunnelState.details.cause.reason === 'tunnel_parameter_error' && - tunnelState.details.cause.details === 'no_wireguard_key' - ) { - this.showTunnelStateNotification( - messages.pgettext( - 'notifications', - 'Blocking internet: Valid WireGuard key is missing', - ), - ); - } else { - this.showTunnelStateNotification( - messages.pgettext('notifications', 'Blocking internet'), - ); - } - } else { - this.showTunnelStateNotification( - messages.pgettext('notifications', 'Critical error (your attention is required)'), - ); - } - break; - case 'disconnecting': - switch (tunnelState.details) { - case 'nothing': - case 'block': - // no-op - break; - case 'reconnect': - this.showTunnelStateNotification(messages.pgettext('notifications', 'Reconnecting')); - this.reconnecting = true; - return; - } - break; - } + public notifyTunnelState(tunnelState: TunnelState, blockWhenDisconnected: boolean) { + const notificationProviders: SystemNotificationProvider[] = [ + new ConnectingNotificationProvider({ tunnelState, reconnecting: this.reconnecting }), + new ConnectedNotificationProvider(tunnelState), + new ReconnectingNotificationProvider(tunnelState), + new BlockWhenDisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }), + new DisconnectedNotificationProvider(tunnelState), + new ErrorNotificationProvider(tunnelState), + ]; - this.reconnecting = false; - } + const notificationProvider = notificationProviders.find((notification) => + notification.mayDisplay(), + ); - public notifyInconsistentVersion() { - this.presentNotificationOnce('inconsistent-version', () => { - const notification = new Notification({ - title: this.notificationTitle, - body: messages.pgettext( - 'notifications', - 'Inconsistent internal version information, please restart the app', - ), - silent: true, - icon: this.notificationIcon, - }); - this.scheduleNotification(notification); - }); - } + if (notificationProvider) { + const notification = notificationProvider.getSystemNotification(); - public notifyUnsupportedVersion(upgradeVersion: string) { - this.presentNotificationOnce('unsupported-version', () => { - const notification = new Notification({ - title: this.notificationTitle, - body: sprintf( - // TRANSLATORS: The system notification displayed to the user when the running app becomes unsupported. - // TRANSLATORS: Available placeholder: - // TRANSLATORS: %(version) - the newest available version of the app - messages.pgettext( - 'notifications', - 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security', - ), - { - version: upgradeVersion, - }, - ), - silent: true, - icon: this.notificationIcon, - }); - - notification.on('click', () => { - consumePromise(shell.openExternal(config.links.download)); - }); - - this.scheduleNotification(notification); - }); - } + if (notification) { + this.showTunnelStateNotification(notification); + } else { + log.error( + `Notification providers mayDisplay() returned true but getSystemNotification() returned undefined for ${notificationProvider.constructor.name}`, + ); + } + } - public closeToExpiryNotification(accountExpiry: AccountExpiry) { - const duration = accountExpiry.durationUntilExpiry(); - const notification = new Notification({ - title: this.notificationTitle, - body: sprintf( - // TRANSLATORS: The system notification displayed to the user when the account credit is close to expiry. - // TRANSLATORS: Available placeholder: - // TRANSLATORS: %(duration)s - remaining time, e.g. "2 days" - messages.pgettext('notifications', 'Account credit expires in %(duration)s'), - { duration }, - ), - silent: true, - icon: this.notificationIcon, - }); - this.scheduleNotification(notification); + this.reconnecting = + tunnelState.state === 'disconnecting' && tunnelState.details === 'reconnect'; } public cancelPendingNotifications() { @@ -187,47 +87,59 @@ export default class NotificationController { this.lastTunnelStateAnnouncement = undefined; } - private showTunnelStateNotification(message: string) { - const lastAnnouncement = this.lastTunnelStateAnnouncement; - const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message; + public notify(systemNotification: SystemNotification) { + if (this.evaluateNotification(systemNotification)) { + const notification = this.createNotification(systemNotification); + this.addPendingNotification(notification); + notification.show(); - if (sameAsLastNotification) { + setTimeout(() => notification.close(), 4000); + + return notification; + } else { return; } + } - const newNotification = new Notification({ + private createNotification(systemNotification: SystemNotification) { + const notification = new Notification({ title: this.notificationTitle, - body: message, + body: systemNotification.message, silent: true, icon: this.notificationIcon, }); - if (lastAnnouncement) { - lastAnnouncement.notification.close(); + if (systemNotification.action) { + const { withAuth, url } = systemNotification.action; + notification.on('click', () => { + consumePromise(this.notificationControllerDelegate.openLink(url, withAuth)); + }); } - this.lastTunnelStateAnnouncement = { - body: message, - notification: newNotification, - }; - - this.scheduleNotification(newNotification); + return notification; } - private presentNotificationOnce(notificationName: string, presentNotification: () => void) { - const presented = this.presentedNotifications; - if (!presented[notificationName]) { - presented[notificationName] = true; - presentNotification(); + private showTunnelStateNotification(systemNotification: SystemNotification) { + const message = systemNotification.message; + const lastAnnouncement = this.lastTunnelStateAnnouncement; + const sameAsLastNotification = lastAnnouncement && lastAnnouncement.body === message; + + if (sameAsLastNotification) { + return; } - } - private scheduleNotification(notification: Notification) { - this.addPendingNotification(notification); + if (lastAnnouncement) { + lastAnnouncement.notification.close(); + } - notification.show(); + const newNotification = this.notify(systemNotification); - setTimeout(() => notification.close(), 4000); + if (newNotification) { + this.lastTunnelStateAnnouncement = { + body: message, + notification: newNotification, + }; + } } private addPendingNotification(notification: Notification) { @@ -244,4 +156,34 @@ export default class NotificationController { this.pendingNotifications.splice(index, 1); } } + + private evaluateNotification(notification: SystemNotification) { + const suppressDueToDevelopment = + notification.suppressInDevelopment && process.env.NODE_ENV === 'development'; + const suppressDueToVisibleWindow = this.notificationControllerDelegate.isWindowVisible(); + const suppressDueToPreference = + !this.notificationControllerDelegate.areSystemNotificationsEnabled() && + !notification.critical; + + return ( + !suppressDueToDevelopment && + !suppressDueToVisibleWindow && + !suppressDueToPreference && + !this.suppressDueToAlreadyPresented(notification) + ); + } + + private suppressDueToAlreadyPresented(notification: SystemNotification) { + const presented = this.presentedNotifications; + if (notification.presentOnce?.value) { + if (presented[notification.presentOnce.name]) { + return true; + } else { + presented[notification.presentOnce.name] = true; + return false; + } + } else { + return false; + } + } } 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/shared/account-expiry.ts b/gui/src/shared/account-expiry.ts index 44c62a613e..d6acb0de91 100644 --- a/gui/src/shared/account-expiry.ts +++ b/gui/src/shared/account-expiry.ts @@ -40,15 +40,21 @@ export default class AccountExpiry { } } - public remainingTime(): string { + public remainingTime(shouldCapitalizeFirstLetter?: boolean): string { const duration = this.durationUntilExpiry(); - return sprintf( + const remaining = sprintf( // TRANSLATORS: The remaining time left on the account displayed across the app. // TRANSLATORS: Available placeholders: // TRANSLATORS: %(duration)s - a localized remaining time (in minutes, hours, or days) until the account expiry messages.pgettext('account-expiry', '%(duration)s left'), { duration }, ); + + return shouldCapitalizeFirstLetter ? capitalizeFirstLetter(remaining) : remaining; } } + +function capitalizeFirstLetter(inputString: string): string { + return inputString.charAt(0).toUpperCase() + inputString.slice(1); +} diff --git a/gui/src/renderer/lib/auth-failure.ts b/gui/src/shared/auth-failure.ts index ead0b08a81..bbd990bf89 100644 --- a/gui/src/renderer/lib/auth-failure.ts +++ b/gui/src/shared/auth-failure.ts @@ -1,4 +1,4 @@ -import { messages } from '../../shared/gettext'; +import { messages } from './gettext'; export enum AuthFailureKind { invalidAccount, diff --git a/gui/src/shared/notifications/accountExpiry.ts b/gui/src/shared/notifications/accountExpiry.ts new file mode 100644 index 0000000000..b749fb55c1 --- /dev/null +++ b/gui/src/shared/notifications/accountExpiry.ts @@ -0,0 +1,56 @@ +import moment from 'moment'; +import { sprintf } from 'sprintf-js'; +import { links } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import AccountExpiry from '../account-expiry'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotification, + SystemNotificationProvider, +} from './notification'; + +interface AccountExpiryContext { + accountExpiry: AccountExpiry; + tooSoon?: boolean; +} + +export class AccountExpiryNotificationProvider + implements InAppNotificationProvider, SystemNotificationProvider { + public constructor(private context: AccountExpiryContext) {} + + public mayDisplay() { + return ( + !this.context.accountExpiry.hasExpired() && + this.context.accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate()) && + !this.context.tooSoon + ); + } + + public getSystemNotification(): SystemNotification { + const message = sprintf( + // TRANSLATORS: The system notification displayed to the user when the account credit is close to expiry. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(duration)s - remaining time, e.g. "2 days" + messages.pgettext('notifications', 'Account credit expires in %(duration)s'), + { + duration: this.context.accountExpiry.remainingTime(), + }, + ); + + return { + message, + critical: true, + action: { type: 'open-url', url: links.purchase, withAuth: true }, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'warning', + title: messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON'), + subtitle: this.context.accountExpiry.remainingTime(true), + action: { type: 'open-url', url: links.purchase, withAuth: true }, + }; + } +} diff --git a/gui/src/shared/notifications/blockWhenDisconnected.ts b/gui/src/shared/notifications/blockWhenDisconnected.ts new file mode 100644 index 0000000000..16b65c0672 --- /dev/null +++ b/gui/src/shared/notifications/blockWhenDisconnected.ts @@ -0,0 +1,40 @@ +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +interface BlockWhenDisconnectedNotificationContext { + tunnelState: TunnelState; + blockWhenDisconnected: boolean; +} + +export class BlockWhenDisconnectedNotificationProvider + implements InAppNotificationProvider, SystemNotificationProvider { + public constructor(private context: BlockWhenDisconnectedNotificationContext) {} + + public mayDisplay() { + return ( + (this.context.tunnelState.state === 'disconnecting' || + this.context.tunnelState.state === 'disconnected') && + this.context.blockWhenDisconnected + ); + } + + public getSystemNotification() { + return { + message: messages.pgettext('notifications', 'Blocking internet'), + critical: false, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), + subtitle: messages.pgettext('in-app-notifications', '"Always require VPN" is enabled.'), + }; + } +} diff --git a/gui/src/shared/notifications/connected.ts b/gui/src/shared/notifications/connected.ts new file mode 100644 index 0000000000..07f7f26ee9 --- /dev/null +++ b/gui/src/shared/notifications/connected.ts @@ -0,0 +1,32 @@ +import { sprintf } from 'sprintf-js'; +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { SystemNotificationProvider } from './notification'; + +export class ConnectedNotificationProvider implements SystemNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay = () => this.context.state === 'connected'; + + public getSystemNotification() { + if (this.context.state === 'connected') { + let message = messages.pgettext('notifications', 'Secured'); + const location = this.context.details.location?.hostname; + if (location) { + // TRANSLATORS: The message showed when a server has been connected to. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(location) - name of the server location we're connected to (e.g. "se-got-003") + message = sprintf(messages.pgettext('notifications', 'Connected to %(location)s'), { + location, + }); + } + + return { + message, + critical: false, + }; + } else { + return undefined; + } + } +} diff --git a/gui/src/shared/notifications/connecting.ts b/gui/src/shared/notifications/connecting.ts new file mode 100644 index 0000000000..ac8c049c67 --- /dev/null +++ b/gui/src/shared/notifications/connecting.ts @@ -0,0 +1,51 @@ +import { sprintf } from 'sprintf-js'; +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +interface ConnectingNotificationContext { + tunnelState: TunnelState; + reconnecting?: boolean; +} + +export class ConnectingNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: ConnectingNotificationContext) {} + + public mayDisplay() { + return this.context.tunnelState.state === 'connecting' && !this.context.reconnecting; + } + + public getSystemNotification() { + if (this.context.tunnelState.state === 'connecting') { + let message = messages.pgettext('notifications', 'Connecting'); + const location = this.context.tunnelState.details?.location?.hostname; + if (location) { + // TRANSLATORS: The message showed when a server is being connected to. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(location) - name of the server location we're connecting to (e.g. "se-got-003") + message = sprintf(messages.pgettext('notifications', 'Connecting to %(location)s'), { + location, + }); + } + + return { + message, + critical: false, + }; + } else { + return undefined; + } + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), + }; + } +} diff --git a/gui/src/shared/notifications/disconnected.ts b/gui/src/shared/notifications/disconnected.ts new file mode 100644 index 0000000000..52d6ba96db --- /dev/null +++ b/gui/src/shared/notifications/disconnected.ts @@ -0,0 +1,16 @@ +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { SystemNotificationProvider } from './notification'; + +export class DisconnectedNotificationProvider implements SystemNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay = () => this.context.state === 'disconnected'; + + public getSystemNotification() { + return { + message: messages.pgettext('notifications', 'Unsecured'), + critical: false, + }; + } +} diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts new file mode 100644 index 0000000000..a4ba97c81d --- /dev/null +++ b/gui/src/shared/notifications/error.ts @@ -0,0 +1,125 @@ +import { parseAuthFailure } from '../auth-failure'; +import { IErrorState, TunnelState, TunnelParameterError } from '../daemon-rpc-types'; +import { messages } from '../gettext'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +export class ErrorNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay = () => this.context.state === 'error'; + + public getSystemNotification() { + return this.context.state === 'error' + ? { + message: getSystemNotificationMessage(this.context), + critical: !this.context.details.isBlocking, + } + : undefined; + } + + public getInAppNotification(): InAppNotification | undefined { + return this.context.state === 'error' + ? { + indicator: 'error', + title: this.context.details.isBlocking + ? messages.pgettext('in-app-notifications', 'BLOCKING INTERNET') + : messages.pgettext('in-app-notifications', 'YOU MIGHT BE LEAKING NETWORK TRAFFIC'), + subtitle: getInAppNotificationSubtitle(this.context), + } + : undefined; + } +} + +function getSystemNotificationMessage(tunnelState: { state: 'error'; details: IErrorState }) { + if (!tunnelState.details.isBlocking) { + return messages.pgettext('notifications', 'Critical error (your attention is required)'); + } else if ( + tunnelState.details.cause.reason === 'tunnel_parameter_error' && + tunnelState.details.cause.details === 'no_wireguard_key' + ) { + return messages.pgettext('notifications', 'Blocking internet: Valid WireGuard key is missing'); + } else { + return messages.pgettext('notifications', 'Blocking internet'); + } +} + +function getInAppNotificationSubtitle(tunnelState: { state: 'error'; details: IErrorState }) { + if (!tunnelState.details.isBlocking) { + return messages.pgettext( + 'in-app-notifications', + 'Failed to block all network traffic. Please troubleshoot or report the problem to us.', + ); + } else { + const blockReason = tunnelState.details.cause; + 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', + ); + 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", + ); + } + } +} + +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', + ); + } +} diff --git a/gui/src/shared/notifications/inconsistentVersion.ts b/gui/src/shared/notifications/inconsistentVersion.ts new file mode 100644 index 0000000000..94c33bd925 --- /dev/null +++ b/gui/src/shared/notifications/inconsistentVersion.ts @@ -0,0 +1,41 @@ +import { messages } from '../../shared/gettext'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotification, + SystemNotificationProvider, +} from './notification'; + +interface InconsistentVersionNotificationContext { + consistent: boolean; +} + +export class InconsistentVersionNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: InconsistentVersionNotificationContext) {} + + public mayDisplay = () => !this.context.consistent; + + public getSystemNotification(): SystemNotification { + return { + message: messages.pgettext( + 'notifications', + 'Inconsistent internal version information, please restart the app', + ), + critical: true, + presentOnce: { value: true, name: this.constructor.name }, + suppressInDevelopment: true, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'INCONSISTENT VERSION'), + subtitle: messages.pgettext( + 'in-app-notifications', + 'Inconsistent internal version information, please restart the app', + ), + }; + } +} diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts new file mode 100644 index 0000000000..e7d89e65bb --- /dev/null +++ b/gui/src/shared/notifications/notification.ts @@ -0,0 +1,41 @@ +export type NotificationAction = { type: 'open-url'; url: string; withAuth?: boolean }; + +export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error'; + +interface NotificationProvider { + mayDisplay(): boolean; +} + +export interface SystemNotification { + message: string; + critical: boolean; + presentOnce?: { value: boolean; name: string }; + suppressInDevelopment?: boolean; + action?: NotificationAction; +} + +export interface InAppNotification { + indicator: InAppNotificationIndicatorType; + title: string; + subtitle?: string; + action?: NotificationAction; +} + +export interface SystemNotificationProvider extends NotificationProvider { + getSystemNotification(): SystemNotification | undefined; +} + +export interface InAppNotificationProvider extends NotificationProvider { + getInAppNotification(): InAppNotification | undefined; +} + +export * from './accountExpiry'; +export * from './blockWhenDisconnected'; +export * from './connected'; +export * from './connecting'; +export * from './disconnected'; +export * from './error'; +export * from './inconsistentVersion'; +export * from './reconnecting'; +export * from './unsupportedVersion'; +export * from './updateAvailable'; diff --git a/gui/src/shared/notifications/reconnecting.ts b/gui/src/shared/notifications/reconnecting.ts new file mode 100644 index 0000000000..2b328aeab2 --- /dev/null +++ b/gui/src/shared/notifications/reconnecting.ts @@ -0,0 +1,30 @@ +import { messages } from '../../shared/gettext'; +import { TunnelState } from '../daemon-rpc-types'; +import { + InAppNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +export class ReconnectingNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: TunnelState) {} + + public mayDisplay() { + return this.context.state === 'disconnecting' && this.context.details === 'reconnect'; + } + + public getSystemNotification() { + return { + message: messages.pgettext('notifications', 'Reconnecting'), + critical: false, + }; + } + + public getInAppNotification(): InAppNotification { + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'BLOCKING INTERNET'), + }; + } +} diff --git a/gui/src/shared/notifications/unsupportedVersion.ts b/gui/src/shared/notifications/unsupportedVersion.ts new file mode 100644 index 0000000000..ed471bc586 --- /dev/null +++ b/gui/src/shared/notifications/unsupportedVersion.ts @@ -0,0 +1,65 @@ +import { sprintf } from 'sprintf-js'; +import { links } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { + InAppNotification, + SystemNotification, + InAppNotificationProvider, + SystemNotificationProvider, +} from './notification'; + +interface UnsupportedVersionNotificationContext { + supported: boolean; + consistent: boolean; + nextUpgrade: string | null; +} + +export class UnsupportedVersionNotificationProvider + implements SystemNotificationProvider, InAppNotificationProvider { + public constructor(private context: UnsupportedVersionNotificationContext) {} + + public mayDisplay() { + return this.context.consistent && !this.context.supported && this.context.nextUpgrade !== null; + } + + public getSystemNotification(): SystemNotification { + const message = sprintf( + // TRANSLATORS: The system notification displayed to the user when the running app becomes unsupported. + // TRANSLATORS: Available placeholder: + // TRANSLATORS: %(version) - the newest available version of the app + messages.pgettext( + 'notifications', + 'You are running an unsupported app version. Please upgrade to %(version)s now to ensure your security', + ), + { version: this.context.nextUpgrade }, + ); + + return { + message, + critical: true, + action: { type: 'open-url', url: links.download }, + presentOnce: { value: true, name: this.constructor.name }, + suppressInDevelopment: true, + }; + } + + public getInAppNotification(): InAppNotification { + const subtitle = 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.context.nextUpgrade }, + ); + + return { + indicator: 'error', + title: messages.pgettext('in-app-notifications', 'UNSUPPORTED VERSION'), + subtitle, + action: { type: 'open-url', url: links.download }, + }; + } +} diff --git a/gui/src/shared/notifications/updateAvailable.ts b/gui/src/shared/notifications/updateAvailable.ts new file mode 100644 index 0000000000..4d449bff28 --- /dev/null +++ b/gui/src/shared/notifications/updateAvailable.ts @@ -0,0 +1,37 @@ +import { sprintf } from 'sprintf-js'; +import { links } from '../../config.json'; +import { messages } from '../../shared/gettext'; +import { InAppNotification, InAppNotificationProvider } from './notification'; + +interface UpdateAvailableNotificationContext { + current: string; + nextUpgrade: string | null; +} + +export class UpdateAvailableNotificationProvider implements InAppNotificationProvider { + public constructor(private context: UpdateAvailableNotificationContext) {} + + public mayDisplay() { + return this.context.nextUpgrade !== null && this.context.nextUpgrade !== this.context.current; + } + + public getInAppNotification(): InAppNotification { + const subtitle = 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.context.nextUpgrade }, + ); + + return { + indicator: 'warning', + title: messages.pgettext('in-app-notifications', 'UPDATE AVAILABLE'), + subtitle, + action: { type: 'open-url', url: links.download }, + }; + } +} |
