diff options
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; + }); }); |
