summaryrefslogtreecommitdiffhomepage
path: root/gui/src/shared
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/shared
parentd7fdccb58e8205ae26023629873922979644bfdd (diff)
downloadmullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.tar.xz
mullvadvpn-bfd3b02a3936451ae43867f8f3e2f986a2a2f9bc.zip
Categorize notifications and their logic into notification definition
Diffstat (limited to 'gui/src/shared')
-rw-r--r--gui/src/shared/account-expiry.ts10
-rw-r--r--gui/src/shared/auth-failure.ts81
-rw-r--r--gui/src/shared/notifications/accountExpiry.ts56
-rw-r--r--gui/src/shared/notifications/blockWhenDisconnected.ts40
-rw-r--r--gui/src/shared/notifications/connected.ts32
-rw-r--r--gui/src/shared/notifications/connecting.ts51
-rw-r--r--gui/src/shared/notifications/disconnected.ts16
-rw-r--r--gui/src/shared/notifications/error.ts125
-rw-r--r--gui/src/shared/notifications/inconsistentVersion.ts41
-rw-r--r--gui/src/shared/notifications/notification.ts41
-rw-r--r--gui/src/shared/notifications/reconnecting.ts30
-rw-r--r--gui/src/shared/notifications/unsupportedVersion.ts65
-rw-r--r--gui/src/shared/notifications/updateAvailable.ts37
13 files changed, 623 insertions, 2 deletions
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/shared/auth-failure.ts b/gui/src/shared/auth-failure.ts
new file mode 100644
index 0000000000..bbd990bf89
--- /dev/null
+++ b/gui/src/shared/auth-failure.ts
@@ -0,0 +1,81 @@
+import { messages } from './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.');
+ }
+}
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 },
+ };
+ }
+}