summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2020-06-24 13:39:22 +0200
committerOskar Nyberg <oskar@mullvad.net>2020-06-24 13:39:22 +0200
commite596b72cb902c4eed82c691f1d0170821635052b (patch)
tree2293e60bc60ced96e867d34ee2940e866ba9e595
parent63193938c1c5719e06eae51f8d213af55125a2de (diff)
parent3d7b5def877520e5af76969f8de57c688b34211b (diff)
downloadmullvadvpn-e596b72cb902c4eed82c691f1d0170821635052b.tar.xz
mullvadvpn-e596b72cb902c4eed82c691f1d0170821635052b.zip
Merge branch 'add-account-expired-notification'
-rw-r--r--CHANGELOG.md1
-rw-r--r--gui/src/main/account-data-cache.ts23
-rw-r--r--gui/src/main/index.ts68
-rw-r--r--gui/src/main/notification-controller.ts8
-rw-r--r--gui/src/renderer/components/Account.tsx8
-rw-r--r--gui/src/renderer/components/Connect.tsx6
-rw-r--r--gui/src/renderer/components/ExpiredAccountErrorView.tsx6
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx16
-rw-r--r--gui/src/renderer/components/Settings.tsx11
-rw-r--r--gui/src/renderer/containers/ConnectPage.tsx5
-rw-r--r--gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx5
-rw-r--r--gui/src/shared/account-expiry.ts81
-rw-r--r--gui/src/shared/notifications/account-expired.ts34
-rw-r--r--gui/src/shared/notifications/block-when-disconnected.ts (renamed from gui/src/shared/notifications/blockWhenDisconnected.ts)0
-rw-r--r--gui/src/shared/notifications/close-to-account-expiry.ts (renamed from gui/src/shared/notifications/accountExpiry.ts)24
-rw-r--r--gui/src/shared/notifications/error.ts46
-rw-r--r--gui/src/shared/notifications/inconsistent-version.ts (renamed from gui/src/shared/notifications/inconsistentVersion.ts)0
-rw-r--r--gui/src/shared/notifications/notification.ts11
-rw-r--r--gui/src/shared/notifications/unsupported-version.ts (renamed from gui/src/shared/notifications/unsupportedVersion.ts)0
-rw-r--r--gui/src/shared/notifications/update-available.ts (renamed from gui/src/shared/notifications/updateAvailable.ts)0
-rw-r--r--gui/src/shared/scheduler.ts13
-rw-r--r--gui/test/account-data-cache.spec.ts29
22 files changed, 254 insertions, 141 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae9d9d634d..1835a17b9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@ Line wrap the file at 100 chars. Th
## [Unreleased]
### Added
- Add Korean, Polish and Thai languages to the desktop app.
+- Show system notification when account has expired.
#### Android
- Show a system notification when the account time will soon run out.
diff --git a/gui/src/main/account-data-cache.ts b/gui/src/main/account-data-cache.ts
index ac829ca0f3..5c178e83a6 100644
--- a/gui/src/main/account-data-cache.ts
+++ b/gui/src/main/account-data-cache.ts
@@ -1,5 +1,6 @@
import log from 'electron-log';
import moment from 'moment';
+import { hasExpired } from '../shared/account-expiry';
import { AccountToken, IAccountData } from '../shared/daemon-rpc-types';
import consumePromise from '../shared/promise';
import { Scheduler } from '../shared/scheduler';
@@ -85,7 +86,12 @@ export default class AccountDataCache {
if (this.currentAccount === accountToken) {
this.setValue(accountData);
- this.scheduleRefetchIfExpired(accountToken, accountData);
+
+ const refetchDelay = this.calculateRefetchDelay(accountData.expiry);
+ if (refetchDelay) {
+ this.scheduleFetch(accountToken, refetchDelay);
+ }
+
this.waitStrategy.reset();
this.performingFetch = false;
}
@@ -97,10 +103,17 @@ export default class AccountDataCache {
}
}
- private scheduleRefetchIfExpired(accountToken: AccountToken, accountData: IAccountData) {
- const hasExpired = moment(accountData.expiry).isSameOrBefore(new Date());
- if (hasExpired) {
- this.scheduleFetch(accountToken, EXPIRED_ACCOUNT_REFRESH_PERIOD);
+ private calculateRefetchDelay(accountExpiry: string) {
+ const currentDate = new Date();
+ const oneMinuteBeforeExpiry = moment(accountExpiry).subtract(1, 'minute');
+ const closeToExpiry = moment(accountExpiry).isSameOrBefore(moment().add(3, 'days'));
+
+ if (hasExpired(accountExpiry)) {
+ return EXPIRED_ACCOUNT_REFRESH_PERIOD;
+ } else if (oneMinuteBeforeExpiry.isSameOrAfter(currentDate) && closeToExpiry) {
+ return oneMinuteBeforeExpiry.diff(currentDate);
+ } else {
+ return undefined;
}
}
diff --git a/gui/src/main/index.ts b/gui/src/main/index.ts
index b09d83e292..cef8eda51b 100644
--- a/gui/src/main/index.ts
+++ b/gui/src/main/index.ts
@@ -2,9 +2,10 @@ import { execFile } from 'child_process';
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';
+import { hasExpired } from '../shared/account-expiry';
import BridgeSettingsBuilder from '../shared/bridge-settings-builder';
import {
AccountToken,
@@ -27,17 +28,18 @@ 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,
getRendererLogFile,
setupLogging,
} from '../shared/logging';
+import {
+ AccountExpiredNotificationProvider,
+ CloseToAccountExpiryNotificationProvider,
+ InconsistentVersionNotificationProvider,
+ UnsupportedVersionNotificationProvider,
+} from '../shared/notifications/notification';
import consumePromise from '../shared/promise';
import { Scheduler } from '../shared/scheduler';
import AccountDataCache from './account-data-cache';
@@ -165,7 +167,7 @@ class ApplicationMain {
private wireguardPublicKey?: IWireguardPublicKey;
- private accountExpiryNotificationTimeout?: NodeJS.Timeout;
+ private accountExpiryNotificationScheduler = new Scheduler();
private accountDataCache = new AccountDataCache(
(accountToken) => {
@@ -178,7 +180,7 @@ class ApplicationMain {
IpcMainEventChannel.account.notify(this.windowController.webContents, accountData);
}
- this.notifyOfAccountExpiry();
+ this.handleAccountExpiry();
},
);
@@ -623,7 +625,11 @@ class ApplicationMain {
this.updateTrayIcon(newState, this.settings.blockWhenDisconnected);
consumePromise(this.updateLocation());
- this.notificationController.notifyTunnelState(newState, this.settings.blockWhenDisconnected);
+ this.notificationController.notifyTunnelState(
+ newState,
+ this.settings.blockWhenDisconnected,
+ this.accountData?.expiry,
+ );
if (this.windowController) {
IpcMainEventChannel.tunnel.notify(this.windowController.webContents, newState);
@@ -1147,10 +1153,7 @@ class ApplicationMain {
}
private async autoConnect() {
- if (
- !this.accountData ||
- !new AccountExpiry(this.accountData.expiry, this.locale).hasExpired()
- ) {
+ if (!this.accountData || !hasExpired(this.accountData.expiry)) {
try {
log.info('Auto-connecting the tunnel');
await this.daemonRpc.connectTunnel();
@@ -1164,12 +1167,8 @@ class ApplicationMain {
try {
await this.daemonRpc.setAccount();
- if (this.accountExpiryNotificationTimeout) {
- global.clearTimeout(this.accountExpiryNotificationTimeout);
- this.accountExpiryNotificationTimeout = undefined;
- }
-
this.autoConnectFallbackScheduler.cancel();
+ this.accountExpiryNotificationScheduler.cancel();
} catch (error) {
log.info(`Failed to logout: ${error.message}`);
@@ -1219,19 +1218,30 @@ class ApplicationMain {
}
}
- private notifyOfAccountExpiry() {
+ private handleAccountExpiry() {
if (this.accountData) {
- const accountExpiry = new AccountExpiry(this.accountData.expiry, this.locale);
- const notificationProvider = new AccountExpiryNotificationProvider({
- accountExpiry,
- tooSoon: this.accountExpiryNotificationTimeout !== undefined,
+ const expiredNotification = new AccountExpiredNotificationProvider({
+ accountExpiry: this.accountData.expiry,
+ tunnelState: this.tunnelState,
});
- if (notificationProvider.mayDisplay()) {
- this.notificationController.notify(notificationProvider.getSystemNotification());
- this.accountExpiryNotificationTimeout = global.setTimeout(() => {
- this.accountExpiryNotificationTimeout = undefined;
- this.notifyOfAccountExpiry();
- }, 12 * 60 * 60 * 1000); // Every 12 hours
+ const closeToExpiryNotification = new CloseToAccountExpiryNotificationProvider({
+ accountExpiry: this.accountData.expiry,
+ locale: this.locale,
+ });
+
+ if (expiredNotification.mayDisplay()) {
+ this.accountExpiryNotificationScheduler.cancel();
+ this.notificationController.notify(expiredNotification.getSystemNotification());
+ } else if (
+ !this.accountExpiryNotificationScheduler.isRunning &&
+ closeToExpiryNotification.mayDisplay()
+ ) {
+ this.notificationController.notify(closeToExpiryNotification.getSystemNotification());
+
+ const twelveHours = 12 * 60 * 60 * 1000;
+ const remainingMilliseconds = moment(this.accountData.expiry).diff(new Date());
+ const delay = Math.min(twelveHours, remainingMilliseconds);
+ this.accountExpiryNotificationScheduler.schedule(() => this.handleAccountExpiry(), delay);
}
}
}
diff --git a/gui/src/main/notification-controller.ts b/gui/src/main/notification-controller.ts
index 548c20e122..20c29d0a41 100644
--- a/gui/src/main/notification-controller.ts
+++ b/gui/src/main/notification-controller.ts
@@ -47,14 +47,18 @@ export default class NotificationController {
}
}
- public notifyTunnelState(tunnelState: TunnelState, blockWhenDisconnected: boolean) {
+ public notifyTunnelState(
+ tunnelState: TunnelState,
+ blockWhenDisconnected: boolean,
+ accountExpiry?: string,
+ ) {
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),
+ new ErrorNotificationProvider({ tunnelState, accountExpiry }),
];
const notificationProvider = notificationProviders.find((notification) =>
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx
index 352ed1dad3..3d344887bd 100644
--- a/gui/src/renderer/components/Account.tsx
+++ b/gui/src/renderer/components/Account.tsx
@@ -1,5 +1,5 @@
import * as React from 'react';
-import AccountExpiry from '../../shared/account-expiry';
+import { formatDate, hasExpired } from '../../shared/account-expiry';
import { messages } from '../../shared/gettext';
import {
AccountContainer,
@@ -97,14 +97,12 @@ export default class Account extends React.Component<IProps> {
function FormattedAccountExpiry(props: { expiry?: string; locale: string }) {
if (props.expiry) {
- const expiry = new AccountExpiry(props.expiry, props.locale);
-
- if (expiry.hasExpired()) {
+ if (hasExpired(props.expiry)) {
return (
<AccountOutOfTime>{messages.pgettext('account-view', 'OUT OF TIME')}</AccountOutOfTime>
);
} else {
- return <AccountRowValue>{expiry.formattedDate()}</AccountRowValue>;
+ return <AccountRowValue>{formatDate(props.expiry, props.locale)}</AccountRowValue>;
}
} else {
return (
diff --git a/gui/src/renderer/components/Connect.tsx b/gui/src/renderer/components/Connect.tsx
index b57c63a273..636f445bc1 100644
--- a/gui/src/renderer/components/Connect.tsx
+++ b/gui/src/renderer/components/Connect.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Component, Styles, View } from 'reactxp';
import styled from 'styled-components';
-import AccountExpiry from '../../shared/account-expiry';
+import { hasExpired } from '../../shared/account-expiry';
import ExpiredAccountErrorViewContainer from '../containers/ExpiredAccountErrorViewContainer';
import NotificationArea from '../components/NotificationArea';
import { AuthFailureKind, parseAuthFailure } from '../../shared/auth-failure';
@@ -17,7 +17,7 @@ import TunnelControl from './TunnelControl';
interface IProps {
connection: IConnectionReduxState;
loginState: LoginState;
- accountExpiry?: AccountExpiry;
+ accountExpiry?: string;
blockWhenDisconnected: boolean;
selectedRelayName: string;
onSettings: () => void;
@@ -135,7 +135,7 @@ export default class Connect extends Component<IProps, IState> {
// Use the account expiry to deduce the account state
if (this.props.accountExpiry) {
- return this.props.accountExpiry.hasExpired();
+ return hasExpired(this.props.accountExpiry);
}
// Do not assume that the account hasn't expired if the expiry is not available at the moment
diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
index 61140016ea..0854f0ace2 100644
--- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx
+++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx
@@ -2,7 +2,7 @@ import * as React from 'react';
import { Component, Text, View } from 'reactxp';
import { sprintf } from 'sprintf-js';
import { links } from '../../config.json';
-import AccountExpiry from '../../shared/account-expiry';
+import { hasExpired } from '../../shared/account-expiry';
import { AccountToken } from '../../shared/daemon-rpc-types';
import { messages } from '../../shared/gettext';
import { LoginState } from '../redux/account/reducers';
@@ -29,7 +29,7 @@ interface IExpiredAccountErrorViewProps {
isBlocked: boolean;
blockWhenDisconnected: boolean;
accountToken?: AccountToken;
- accountExpiry?: AccountExpiry;
+ accountExpiry?: string;
loginState: LoginState;
hideWelcomeView: () => void;
onExternalLinkWithAuth: (url: string) => Promise<void>;
@@ -52,7 +52,7 @@ export default class ExpiredAccountErrorView extends Component<
};
public componentDidUpdate() {
- if (this.props.accountExpiry && !this.props.accountExpiry.hasExpired()) {
+ if (this.props.accountExpiry && !hasExpired(this.props.accountExpiry)) {
this.props.hideWelcomeView();
}
}
diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx
index bc76ba0885..a6d4699e6a 100644
--- a/gui/src/renderer/components/NotificationArea.tsx
+++ b/gui/src/renderer/components/NotificationArea.tsx
@@ -3,9 +3,8 @@ 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,
+ CloseToAccountExpiryNotificationProvider,
BlockWhenDisconnectedNotificationProvider,
ConnectingNotificationProvider,
ErrorNotificationProvider,
@@ -33,11 +32,8 @@ interface IProps {
}
export default function NotificationArea(props: IProps) {
- const accountExpiry = useSelector((state: IReduxState) =>
- state.account.expiry
- ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
- : undefined,
- );
+ const accountExpiry = useSelector((state: IReduxState) => state.account.expiry);
+ const locale = useSelector((state: IReduxState) => state.userInterface.locale);
const tunnelState = useSelector((state: IReduxState) => state.connection.status);
const version = useSelector((state: IReduxState) => state.version);
const blockWhenDisconnected = useSelector(
@@ -48,14 +44,16 @@ export default function NotificationArea(props: IProps) {
new ConnectingNotificationProvider({ tunnelState }),
new ReconnectingNotificationProvider(tunnelState),
new BlockWhenDisconnectedNotificationProvider({ tunnelState, blockWhenDisconnected }),
- new ErrorNotificationProvider(tunnelState),
+ new ErrorNotificationProvider({ tunnelState, accountExpiry }),
new InconsistentVersionNotificationProvider({ consistent: version.consistent }),
new UnsupportedVersionNotificationProvider(version),
new UpdateAvailableNotificationProvider(version),
];
if (accountExpiry) {
- notificationProviders.push(new AccountExpiryNotificationProvider({ accountExpiry }));
+ notificationProviders.push(
+ new CloseToAccountExpiryNotificationProvider({ accountExpiry, locale }),
+ );
}
const notificationProvider = notificationProviders.find((notification) =>
diff --git a/gui/src/renderer/components/Settings.tsx b/gui/src/renderer/components/Settings.tsx
index 4a7684f405..f01bfd7036 100644
--- a/gui/src/renderer/components/Settings.tsx
+++ b/gui/src/renderer/components/Settings.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { Component, Text, View } from 'reactxp';
import { colors, links } from '../../config.json';
-import AccountExpiry from '../../shared/account-expiry';
+import { hasExpired, formatRemainingTime } from '../../shared/account-expiry';
import { messages } from '../../shared/gettext';
import * as AppButton from './AppButton';
import * as Cell from './Cell';
@@ -102,11 +102,10 @@ export default class Settings extends Component<IProps> {
return null;
}
- const expiry = this.props.accountExpiry
- ? new AccountExpiry(this.props.accountExpiry, this.props.expiryLocale)
- : null;
- const isOutOfTime = expiry ? expiry.hasExpired() : false;
- const formattedExpiry = expiry ? expiry.remainingTime().toUpperCase() : '';
+ const isOutOfTime = this.props.accountExpiry ? hasExpired(this.props.accountExpiry) : false;
+ const formattedExpiry = this.props.accountExpiry
+ ? formatRemainingTime(this.props.accountExpiry, this.props.expiryLocale).toUpperCase()
+ : '';
const outOfTimeMessage = messages.pgettext('settings-view', 'OUT OF TIME');
diff --git a/gui/src/renderer/containers/ConnectPage.tsx b/gui/src/renderer/containers/ConnectPage.tsx
index 4ba47f7511..57f550d988 100644
--- a/gui/src/renderer/containers/ConnectPage.tsx
+++ b/gui/src/renderer/containers/ConnectPage.tsx
@@ -3,7 +3,6 @@ import log from 'electron-log';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { sprintf } from 'sprintf-js';
-import AccountExpiry from '../../shared/account-expiry';
import { messages } from '../../shared/gettext';
import Connect from '../components/Connect';
import withAppContext, { IAppContext } from '../context';
@@ -65,9 +64,7 @@ function getRelayName(
const mapStateToProps = (state: IReduxState) => {
return {
- accountExpiry: state.account.expiry
- ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
- : undefined,
+ accountExpiry: state.account.expiry,
loginState: state.account.status,
blockWhenDisconnected: state.settings.blockWhenDisconnected,
selectedRelayName: getRelayName(state.settings.relaySettings, state.settings.relayLocations),
diff --git a/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx b/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx
index 2c4ca2e6dd..dcdc862355 100644
--- a/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx
+++ b/gui/src/renderer/containers/ExpiredAccountErrorViewContainer.tsx
@@ -1,7 +1,6 @@
import log from 'electron-log';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
-import AccountExpiry from '../../shared/account-expiry';
import ExpiredAccountErrorView from '../components/ExpiredAccountErrorView';
import accountActions from '../redux/account/actions';
@@ -10,9 +9,7 @@ import { IReduxState, ReduxDispatch } from '../redux/store';
const mapStateToProps = (state: IReduxState) => ({
accountToken: state.account.accountToken,
- accountExpiry: state.account.expiry
- ? new AccountExpiry(state.account.expiry, state.userInterface.locale)
- : undefined,
+ accountExpiry: state.account.expiry,
loginState: state.account.status,
isBlocked: state.connection.isBlocked,
blockWhenDisconnected: state.settings.blockWhenDisconnected,
diff --git a/gui/src/shared/account-expiry.ts b/gui/src/shared/account-expiry.ts
index d6acb0de91..4511797f3c 100644
--- a/gui/src/shared/account-expiry.ts
+++ b/gui/src/shared/account-expiry.ts
@@ -2,57 +2,52 @@ import moment from 'moment';
import { sprintf } from 'sprintf-js';
import { messages } from './gettext';
-export default class AccountExpiry {
- private expiry: moment.Moment;
+type DateArgument = string | Date | moment.Moment;
- constructor(isoString: string, locale: string) {
- this.expiry = moment(isoString).locale(locale);
- }
-
- public hasExpired(): boolean {
- return this.willHaveExpiredAt(new Date());
- }
-
- public willHaveExpiredAt(date: Date): boolean {
- return this.expiry.isSameOrBefore(date);
- }
+export function hasExpired(expiry: DateArgument): boolean {
+ return moment(expiry).isSameOrBefore(new Date());
+}
- public formattedDate(): string {
- return this.expiry.format('lll');
- }
+export function formatDate(date: DateArgument, locale: string): string {
+ return moment(date).locale(locale).format('lll');
+}
- public durationUntilExpiry(): string {
- const daysDiff = this.expiry.diff(new Date(), 'days');
+export function formatDurationUntilExpiry(expiry: DateArgument, locale: string): string {
+ const expiryMoment = moment(expiry).locale(locale);
+ const daysDiff = expiryMoment.diff(new Date(), 'days');
- // Below three months we want to show the duration in days. Moments fromNow() method starts
- // measuring duration in months from 26 days and up.
- // https://momentjs.com/docs/#/displaying/fromnow/
- if (daysDiff >= 26 && daysDiff <= 90) {
- return sprintf(
- // TRANSLATORS: The remaining time left on the account measured in days.
- // TRANSLATORS: Available placeholders:
- // TRANSLATORS: %(duration)s - The remaining time measured in days.
- messages.pgettext('account-expiry', '%(duration)s days'),
- { duration: daysDiff },
- );
- } else {
- return this.expiry.fromNow(true);
- }
+ // Below three months we want to show the duration in days. Moments fromNow() method starts
+ // measuring duration in months from 26 days and up.
+ // https://momentjs.com/docs/#/displaying/fromnow/
+ if (daysDiff >= 26 && daysDiff <= 90) {
+ return sprintf(
+ // TRANSLATORS: The remaining time left on the account measured in days.
+ // TRANSLATORS: Available placeholders:
+ // TRANSLATORS: %(duration)s - The remaining time measured in days.
+ messages.pgettext('account-expiry', '%(duration)s days'),
+ { duration: daysDiff },
+ );
+ } else {
+ return expiryMoment.fromNow(true);
}
+}
- public remainingTime(shouldCapitalizeFirstLetter?: boolean): string {
- const duration = this.durationUntilExpiry();
+export function formatRemainingTime(
+ expiry: DateArgument,
+ locale: string,
+ shouldCapitalizeFirstLetter?: boolean,
+): string {
+ const duration = formatDurationUntilExpiry(expiry, locale);
- 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 },
- );
+ 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;
- }
+ return shouldCapitalizeFirstLetter ? capitalizeFirstLetter(remaining) : remaining;
}
function capitalizeFirstLetter(inputString: string): string {
diff --git a/gui/src/shared/notifications/account-expired.ts b/gui/src/shared/notifications/account-expired.ts
new file mode 100644
index 0000000000..ea600aa577
--- /dev/null
+++ b/gui/src/shared/notifications/account-expired.ts
@@ -0,0 +1,34 @@
+import { links } from '../../config.json';
+import { hasExpired } from '../account-expiry';
+import { TunnelState } from '../daemon-rpc-types';
+import { messages } from '../gettext';
+import { SystemNotification, SystemNotificationProvider } from './notification';
+
+interface AccountExpiredNotificaitonContext {
+ accountExpiry: string;
+ tunnelState: TunnelState;
+}
+
+export class AccountExpiredNotificationProvider implements SystemNotificationProvider {
+ public constructor(private context: AccountExpiredNotificaitonContext) {}
+
+ public mayDisplay() {
+ // Only show when disconnected since the error state handles this if the connection is closed
+ // due to account expiry.
+ return (
+ this.context.tunnelState.state === 'disconnected' && hasExpired(this.context.accountExpiry)
+ );
+ }
+
+ public getSystemNotification(): SystemNotification {
+ return {
+ message: messages.pgettext(
+ 'notifications',
+ 'You have no more VPN time left on this account.',
+ ),
+ critical: true,
+ presentOnce: { value: true, name: this.constructor.name },
+ action: { type: 'open-url', url: links.purchase, withAuth: true },
+ };
+ }
+}
diff --git a/gui/src/shared/notifications/blockWhenDisconnected.ts b/gui/src/shared/notifications/block-when-disconnected.ts
index 16b65c0672..16b65c0672 100644
--- a/gui/src/shared/notifications/blockWhenDisconnected.ts
+++ b/gui/src/shared/notifications/block-when-disconnected.ts
diff --git a/gui/src/shared/notifications/accountExpiry.ts b/gui/src/shared/notifications/close-to-account-expiry.ts
index b749fb55c1..b30aff2542 100644
--- a/gui/src/shared/notifications/accountExpiry.ts
+++ b/gui/src/shared/notifications/close-to-account-expiry.ts
@@ -2,7 +2,7 @@ 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 { formatDurationUntilExpiry, formatRemainingTime, hasExpired } from '../account-expiry';
import {
InAppNotification,
InAppNotificationProvider,
@@ -10,21 +10,21 @@ import {
SystemNotificationProvider,
} from './notification';
-interface AccountExpiryContext {
- accountExpiry: AccountExpiry;
- tooSoon?: boolean;
+interface CloseToAccountExpiryNotificationContext {
+ accountExpiry: string;
+ locale: string;
}
-export class AccountExpiryNotificationProvider
+export class CloseToAccountExpiryNotificationProvider
implements InAppNotificationProvider, SystemNotificationProvider {
- public constructor(private context: AccountExpiryContext) {}
+ public constructor(private context: CloseToAccountExpiryNotificationContext) {}
public mayDisplay() {
- return (
- !this.context.accountExpiry.hasExpired() &&
- this.context.accountExpiry.willHaveExpiredAt(moment().add(3, 'days').toDate()) &&
- !this.context.tooSoon
+ const willHaveExpiredInThreeDays = moment(this.context.accountExpiry).isSameOrBefore(
+ moment().add(3, 'days'),
);
+
+ return !hasExpired(this.context.accountExpiry) && willHaveExpiredInThreeDays;
}
public getSystemNotification(): SystemNotification {
@@ -34,7 +34,7 @@ export class AccountExpiryNotificationProvider
// TRANSLATORS: %(duration)s - remaining time, e.g. "2 days"
messages.pgettext('notifications', 'Account credit expires in %(duration)s'),
{
- duration: this.context.accountExpiry.remainingTime(),
+ duration: formatDurationUntilExpiry(this.context.accountExpiry, this.context.locale),
},
);
@@ -49,7 +49,7 @@ export class AccountExpiryNotificationProvider
return {
indicator: 'warning',
title: messages.pgettext('in-app-notifications', 'ACCOUNT CREDIT EXPIRES SOON'),
- subtitle: this.context.accountExpiry.remainingTime(true),
+ subtitle: formatRemainingTime(this.context.accountExpiry, this.context.locale, true),
action: { type: 'open-url', url: links.purchase, withAuth: true },
};
}
diff --git a/gui/src/shared/notifications/error.ts b/gui/src/shared/notifications/error.ts
index a4ba97c81d..1a8499739e 100644
--- a/gui/src/shared/notifications/error.ts
+++ b/gui/src/shared/notifications/error.ts
@@ -1,4 +1,6 @@
-import { parseAuthFailure } from '../auth-failure';
+import { sprintf } from 'sprintf-js';
+import { hasExpired } from '../account-expiry';
+import { AuthFailureKind, parseAuthFailure } from '../auth-failure';
import { IErrorState, TunnelState, TunnelParameterError } from '../daemon-rpc-types';
import { messages } from '../gettext';
import {
@@ -7,38 +9,62 @@ import {
SystemNotificationProvider,
} from './notification';
+interface ErrorNotificationContext {
+ tunnelState: TunnelState;
+ accountExpiry?: string;
+}
+
export class ErrorNotificationProvider
implements SystemNotificationProvider, InAppNotificationProvider {
- public constructor(private context: TunnelState) {}
+ public constructor(private context: ErrorNotificationContext) {}
- public mayDisplay = () => this.context.state === 'error';
+ public mayDisplay = () => this.context.tunnelState.state === 'error';
public getSystemNotification() {
- return this.context.state === 'error'
+ return this.context.tunnelState.state === 'error'
? {
- message: getSystemNotificationMessage(this.context),
- critical: !this.context.details.isBlocking,
+ message: getSystemNotificationMessage(
+ this.context.tunnelState,
+ this.context.accountExpiry,
+ ),
+ critical: !this.context.tunnelState.details.isBlocking,
}
: undefined;
}
public getInAppNotification(): InAppNotification | undefined {
- return this.context.state === 'error'
+ return this.context.tunnelState.state === 'error'
? {
indicator: 'error',
- title: this.context.details.isBlocking
+ title: this.context.tunnelState.details.isBlocking
? messages.pgettext('in-app-notifications', 'BLOCKING INTERNET')
: messages.pgettext('in-app-notifications', 'YOU MIGHT BE LEAKING NETWORK TRAFFIC'),
- subtitle: getInAppNotificationSubtitle(this.context),
+ subtitle: getInAppNotificationSubtitle(this.context.tunnelState),
}
: undefined;
}
}
-function getSystemNotificationMessage(tunnelState: { state: 'error'; details: IErrorState }) {
+function getSystemNotificationMessage(
+ tunnelState: { state: 'error'; details: IErrorState },
+ accountExpiry?: string,
+) {
if (!tunnelState.details.isBlocking) {
return messages.pgettext('notifications', 'Critical error (your attention is required)');
} else if (
+ (tunnelState.details.cause.reason === 'auth_failed' &&
+ parseAuthFailure(tunnelState.details.cause.details).kind ===
+ AuthFailureKind.expiredAccount) ||
+ (accountExpiry && hasExpired(accountExpiry))
+ ) {
+ return sprintf('%(blocking)s %(message)s', {
+ blocking: messages.pgettext('notifications', 'Blocking internet:'),
+ message: messages.pgettext(
+ 'notifications',
+ 'You have no more VPN time left on this account.',
+ ),
+ });
+ } else if (
tunnelState.details.cause.reason === 'tunnel_parameter_error' &&
tunnelState.details.cause.details === 'no_wireguard_key'
) {
diff --git a/gui/src/shared/notifications/inconsistentVersion.ts b/gui/src/shared/notifications/inconsistent-version.ts
index 94c33bd925..94c33bd925 100644
--- a/gui/src/shared/notifications/inconsistentVersion.ts
+++ b/gui/src/shared/notifications/inconsistent-version.ts
diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts
index e7d89e65bb..98b50d4d24 100644
--- a/gui/src/shared/notifications/notification.ts
+++ b/gui/src/shared/notifications/notification.ts
@@ -29,13 +29,14 @@ export interface InAppNotificationProvider extends NotificationProvider {
getInAppNotification(): InAppNotification | undefined;
}
-export * from './accountExpiry';
-export * from './blockWhenDisconnected';
+export * from './account-expired';
+export * from './close-to-account-expiry';
+export * from './block-when-disconnected';
export * from './connected';
export * from './connecting';
export * from './disconnected';
export * from './error';
-export * from './inconsistentVersion';
+export * from './inconsistent-version';
export * from './reconnecting';
-export * from './unsupportedVersion';
-export * from './updateAvailable';
+export * from './unsupported-version';
+export * from './update-available';
diff --git a/gui/src/shared/notifications/unsupportedVersion.ts b/gui/src/shared/notifications/unsupported-version.ts
index ed471bc586..ed471bc586 100644
--- a/gui/src/shared/notifications/unsupportedVersion.ts
+++ b/gui/src/shared/notifications/unsupported-version.ts
diff --git a/gui/src/shared/notifications/updateAvailable.ts b/gui/src/shared/notifications/update-available.ts
index 4d449bff28..4d449bff28 100644
--- a/gui/src/shared/notifications/updateAvailable.ts
+++ b/gui/src/shared/notifications/update-available.ts
diff --git a/gui/src/shared/scheduler.ts b/gui/src/shared/scheduler.ts
index b31f0cda7c..8ae5a2cbf0 100644
--- a/gui/src/shared/scheduler.ts
+++ b/gui/src/shared/scheduler.ts
@@ -2,17 +2,28 @@ import { useEffect, useMemo } from 'react';
export class Scheduler {
private timer?: NodeJS.Timeout;
+ private running = false;
public schedule(action: () => void, delay = 0) {
this.cancel();
- this.timer = global.setTimeout(action, delay);
+
+ this.running = true;
+ this.timer = global.setTimeout(() => {
+ this.running = false;
+ action();
+ }, delay);
}
public cancel() {
if (this.timer) {
clearTimeout(this.timer);
+ this.running = false;
}
}
+
+ public get isRunning() {
+ return this.running;
+ }
}
export function useScheduler() {
diff --git a/gui/test/account-data-cache.spec.ts b/gui/test/account-data-cache.spec.ts
index bec1c6ef91..770b3ca1f4 100644
--- a/gui/test/account-data-cache.spec.ts
+++ b/gui/test/account-data-cache.spec.ts
@@ -232,4 +232,33 @@ describe('IAccountData cache', () => {
expect(fetchSpy).to.have.been.called.once;
});
});
+
+ it('should refetch one minute before expiry', async () => {
+ const date = new Date();
+ date.setMinutes(date.getMinutes() + 3);
+ const expiry = date.toISOString();
+
+ const update = new Promise((resolve, reject) => {
+ let firstAttempt = true;
+ const fetch = () => {
+ if (firstAttempt) {
+ firstAttempt = false;
+ setTimeout(() => clock.tick(120_000), 0);
+ return Promise.resolve({ expiry });
+ } else {
+ resolve();
+ return Promise.resolve({ expiry });
+ }
+ };
+
+ const cache = new AccountDataCache(fetch, () => {});
+
+ cache.fetch(dummyAccountToken, {
+ onFinish: () => {},
+ onError: (_error: Error) => reject(),
+ });
+ });
+
+ return expect(update).to.eventually.be.fulfilled;
+ });
});