diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2023-06-13 11:01:29 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2023-06-14 10:25:18 +0200 |
| commit | 0aadb762da306adc7e305388496a384fdb2c4181 (patch) | |
| tree | 95d4f7b21a659b93f34c45ef1718f81a7ab8a8e5 | |
| parent | cecccd43d8e3c05e281be19c58f8e08fb2b651c6 (diff) | |
| download | mullvadvpn-0aadb762da306adc7e305388496a384fdb2c4181.tar.xz mullvadvpn-0aadb762da306adc7e305388496a384fdb2c4181.zip | |
Add new device in-app notification
| -rw-r--r-- | gui/locales/messages.pot | 12 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationArea.tsx | 29 | ||||
| -rw-r--r-- | gui/src/renderer/components/NotificationBanner.tsx | 37 | ||||
| -rw-r--r-- | gui/src/renderer/redux/account/actions.ts | 10 | ||||
| -rw-r--r-- | gui/src/renderer/redux/account/reducers.ts | 18 | ||||
| -rw-r--r-- | gui/src/shared/notifications/new-device.ts | 32 | ||||
| -rw-r--r-- | gui/src/shared/notifications/notification.ts | 4 |
7 files changed, 114 insertions, 28 deletions
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 1a19caf452..cfa73b0d27 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -234,6 +234,10 @@ msgid "%(title)s, View loaded" msgstr "" msgctxt "accessibility" +msgid "Close notification" +msgstr "" + +msgctxt "accessibility" msgid "Collapse %(location)s" msgstr "" @@ -628,6 +632,10 @@ msgid "NETWORK TRAFFIC MIGHT BE LEAKING" msgstr "" msgctxt "in-app-notifications" +msgid "NEW DEVICE CREATED" +msgstr "" + +msgctxt "in-app-notifications" msgid "Please quit and restart the app." msgstr "" @@ -651,6 +659,10 @@ msgctxt "in-app-notifications" msgid "UPDATE AVAILABLE" msgstr "" +msgctxt "in-app-notifications" +msgid "Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account." +msgstr "" + msgctxt "launch-view" msgid "Connecting to Mullvad system service..." msgstr "" diff --git a/gui/src/renderer/components/NotificationArea.tsx b/gui/src/renderer/components/NotificationArea.tsx index 4923bacc7c..79fb3ef17c 100644 --- a/gui/src/renderer/components/NotificationArea.tsx +++ b/gui/src/renderer/components/NotificationArea.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import { colors } from '../../config.json'; import { messages } from '../../shared/gettext'; import log from '../../shared/logging'; +import { NewDeviceNotificationProvider } from '../../shared/notifications/new-device'; import { BlockWhenDisconnectedNotificationProvider, CloseToAccountExpiryNotificationProvider, @@ -19,14 +20,18 @@ import { UpdateAvailableNotificationProvider, } from '../../shared/notifications/notification'; import { useAppContext } from '../context'; +import useActions from '../lib/actionsHook'; import { transitions, useHistory } from '../lib/history'; +import { formatHtml } from '../lib/html-formatter'; import { RoutePath } from '../lib/routes'; +import accountActions from '../redux/account/actions'; import { IReduxState } from '../redux/store'; import * as AppButton from './AppButton'; import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; import { NotificationActions, NotificationBanner, + NotificationCloseAction, NotificationContent, NotificationIndicator, NotificationOpenLinkAction, @@ -40,7 +45,7 @@ interface IProps { } export default function NotificationArea(props: IProps) { - const accountExpiry = useSelector((state: IReduxState) => state.account.expiry); + const account = useSelector((state: IReduxState) => state.account); const locale = useSelector((state: IReduxState) => state.userInterface.locale); const tunnelState = useSelector((state: IReduxState) => state.connection.status); const version = useSelector((state: IReduxState) => state.version); @@ -52,6 +57,8 @@ export default function NotificationArea(props: IProps) { state.settings.splitTunneling && state.settings.splitTunnelingApplications.length > 0, ); + const { hideNewDeviceBanner } = useActions(accountActions); + const notificationProviders: InAppNotificationProvider[] = [ new ConnectingNotificationProvider({ tunnelState }), new ReconnectingNotificationProvider(tunnelState), @@ -65,13 +72,20 @@ export default function NotificationArea(props: IProps) { new UnsupportedVersionNotificationProvider(version), ]; - if (accountExpiry) { + if (account.expiry) { notificationProviders.push( - new CloseToAccountExpiryNotificationProvider({ accountExpiry, locale }), + new CloseToAccountExpiryNotificationProvider({ accountExpiry: account.expiry, locale }), ); } - notificationProviders.push(new UpdateAvailableNotificationProvider(version)); + notificationProviders.push( + new NewDeviceNotificationProvider({ + shouldDisplay: account.status.type === 'ok' && account.status.newDeviceBanner, + deviceName: account.deviceName ?? '', + close: hideNewDeviceBanner, + }), + new UpdateAvailableNotificationProvider(version), + ); const notificationProvider = notificationProviders.find((notification) => notification.mayDisplay(), @@ -86,7 +100,7 @@ export default function NotificationArea(props: IProps) { <NotificationIndicator type={notification.indicator} /> <NotificationContent role="status" aria-live="polite"> <NotificationTitle>{notification.title}</NotificationTitle> - <NotificationSubtitle>{notification.subtitle}</NotificationSubtitle> + <NotificationSubtitle>{formatHtml(notification.subtitle ?? '')}</NotificationSubtitle> </NotificationContent> {notification.action && <NotificationActionWrapper action={notification.action} />} </NotificationBanner> @@ -128,6 +142,9 @@ function NotificationActionWrapper(props: INotificationActionWrapperProps) { case 'troubleshoot-dialog': setTroubleshootInfo(props.action.troubleshoot); break; + case 'close': + props.action.close(); + break; } } @@ -154,6 +171,8 @@ function NotificationActionWrapper(props: INotificationActionWrapperProps) { </> ); break; + case 'close': + actionComponent = <NotificationCloseAction onClick={handleClick} />; } } diff --git a/gui/src/renderer/components/NotificationBanner.tsx b/gui/src/renderer/components/NotificationBanner.tsx index 4f20d315c8..18e18ce4f5 100644 --- a/gui/src/renderer/components/NotificationBanner.tsx +++ b/gui/src/renderer/components/NotificationBanner.tsx @@ -35,29 +35,23 @@ export const NotificationActionButton = styled(AppButton.SimpleButton)({ border: 'none', }); -export const NotificationOpenLinkActionIcon = styled(ImageView)({ - [NotificationActionButton + ':hover &']: { +export const NotificationActionButtonInner = styled(ImageView)({ + [NotificationActionButton + ':hover &&']: { backgroundColor: colors.white80, }, }); -export const NotificationTroubleshootDialogActionIcon = styled(ImageView)({ - [NotificationActionButton + ':hover &']: { - backgroundColor: colors.white80, - }, -}); - -interface INotifcationOpenLinkActionProps { +interface NotificationActionProps { onClick: () => Promise<void>; } -export function NotificationOpenLinkAction(props: INotifcationOpenLinkActionProps) { +export function NotificationOpenLinkAction(props: NotificationActionProps) { return ( <AppButton.BlockingButton onClick={props.onClick}> <NotificationActionButton aria-describedby={NOTIFICATION_AREA_ID} aria-label={messages.gettext('Open URL')}> - <NotificationOpenLinkActionIcon + <NotificationActionButtonInner height={12} width={12} tintColor={colors.white60} @@ -68,19 +62,13 @@ export function NotificationOpenLinkAction(props: INotifcationOpenLinkActionProp ); } -interface INotifcationTroubleshootDialogActionProps { - onClick: () => Promise<void>; -} - -export function NotificationTroubleshootDialogAction( - props: INotifcationTroubleshootDialogActionProps, -) { +export function NotificationTroubleshootDialogAction(props: NotificationActionProps) { return ( <NotificationActionButton aria-describedby={NOTIFICATION_AREA_ID} aria-label={messages.gettext('Troubleshoot')} onClick={props.onClick}> - <NotificationOpenLinkActionIcon + <NotificationActionButtonInner height={12} width={12} tintColor={colors.white60} @@ -90,6 +78,17 @@ export function NotificationTroubleshootDialogAction( ); } +export function NotificationCloseAction(props: NotificationActionProps) { + return ( + <NotificationActionButton + aria-describedby={NOTIFICATION_AREA_ID} + aria-label={messages.pgettext('accessibility', 'Close notification')} + onClick={props.onClick}> + <NotificationActionButtonInner source="icon-close" width={16} tintColor={colors.white60} /> + </NotificationActionButton> + ); +} + export const NotificationContent = styled.div.attrs({ id: NOTIFICATION_AREA_ID })({ display: 'flex', flexDirection: 'column', diff --git a/gui/src/renderer/redux/account/actions.ts b/gui/src/renderer/redux/account/actions.ts index 61fc09d981..ece9719952 100644 --- a/gui/src/renderer/redux/account/actions.ts +++ b/gui/src/renderer/redux/account/actions.ts @@ -60,6 +60,10 @@ interface IAccountSetupFinished { type: 'ACCOUNT_SETUP_FINISHED'; } +interface IHideNewDeviceBanner { + type: 'HIDE_NEW_DEVICE_BANNER'; +} + interface IUpdateAccountTokenAction { type: 'UPDATE_ACCOUNT_TOKEN'; accountToken: AccountToken; @@ -94,6 +98,7 @@ export type AccountAction = | ICreateAccountFailed | IAccountCreated | IAccountSetupFinished + | IHideNewDeviceBanner | IUpdateAccountTokenAction | IUpdateAccountHistoryAction | IUpdateAccountExpiryAction @@ -187,6 +192,10 @@ function accountSetupFinished(): IAccountSetupFinished { return { type: 'ACCOUNT_SETUP_FINISHED' }; } +function hideNewDeviceBanner(): IHideNewDeviceBanner { + return { type: 'HIDE_NEW_DEVICE_BANNER' }; +} + function updateAccountToken(accountToken: AccountToken): IUpdateAccountTokenAction { return { type: 'UPDATE_ACCOUNT_TOKEN', @@ -229,6 +238,7 @@ export default { createAccountFailed, accountCreated, accountSetupFinished, + hideNewDeviceBanner, updateAccountToken, updateAccountHistory, updateAccountExpiry, diff --git a/gui/src/renderer/redux/account/reducers.ts b/gui/src/renderer/redux/account/reducers.ts index a78a777c0a..6f9e558b03 100644 --- a/gui/src/renderer/redux/account/reducers.ts +++ b/gui/src/renderer/redux/account/reducers.ts @@ -4,7 +4,8 @@ import { ReduxAction } from '../store'; type LoginMethod = 'existing_account' | 'new_account'; export type LoginState = | { type: 'none'; deviceRevoked: boolean } - | { type: 'logging in' | 'ok'; method: LoginMethod } + | { type: 'logging in'; method: LoginMethod } + | { type: 'ok'; method: LoginMethod; newDeviceBanner: boolean } | { type: 'too many devices'; method: LoginMethod } | { type: 'failed'; method: 'existing_account'; error: AccountDataError['error'] } | { type: 'failed'; method: 'new_account'; error: Error }; @@ -42,7 +43,7 @@ export default function ( case 'LOGGED_IN': return { ...state, - status: { type: 'ok', method: 'existing_account' }, + status: { type: 'ok', method: 'existing_account', newDeviceBanner: false }, accountToken: action.accountToken, deviceName: action.deviceName, }; @@ -97,7 +98,7 @@ export default function ( case 'ACCOUNT_CREATED': return { ...state, - status: { type: 'ok', method: 'new_account' }, + status: { type: 'ok', method: 'new_account', newDeviceBanner: true }, accountToken: action.accountToken, deviceName: action.deviceName, expiry: action.expiry, @@ -105,7 +106,16 @@ export default function ( case 'ACCOUNT_SETUP_FINISHED': return { ...state, - status: { type: 'ok', method: 'existing_account' }, + status: { type: 'ok', method: 'existing_account', newDeviceBanner: true }, + }; + case 'HIDE_NEW_DEVICE_BANNER': + if (state.status.type !== 'ok') { + return state; + } + + return { + ...state, + status: { ...state.status, newDeviceBanner: false }, }; case 'UPDATE_ACCOUNT_TOKEN': return { diff --git a/gui/src/shared/notifications/new-device.ts b/gui/src/shared/notifications/new-device.ts new file mode 100644 index 0000000000..7d0fe9f299 --- /dev/null +++ b/gui/src/shared/notifications/new-device.ts @@ -0,0 +1,32 @@ +import { sprintf } from 'sprintf-js'; + +import { messages } from '../../shared/gettext'; +import { capitalizeEveryWord } from '../string-helpers'; +import { InAppNotification, InAppNotificationProvider } from './notification'; + +interface NewDeviceNotificationContext { + shouldDisplay: boolean; + deviceName: string; + close: () => void; +} + +export class NewDeviceNotificationProvider implements InAppNotificationProvider { + public constructor(private context: NewDeviceNotificationContext) {} + + public mayDisplay = () => this.context.shouldDisplay; + + public getInAppNotification(): InAppNotification { + return { + indicator: 'success', + title: messages.pgettext('in-app-notifications', 'NEW DEVICE CREATED'), + subtitle: sprintf( + messages.pgettext( + 'in-app-notifications', + 'Welcome, this device is now called <b>%(deviceName)s</b>. For more details see the info button in Account.', + ), + { deviceName: capitalizeEveryWord(this.context.deviceName) }, + ), + action: { type: 'close', close: this.context.close }, + }; + } +} diff --git a/gui/src/shared/notifications/notification.ts b/gui/src/shared/notifications/notification.ts index 88bddfa9f5..b8727739ef 100644 --- a/gui/src/shared/notifications/notification.ts +++ b/gui/src/shared/notifications/notification.ts @@ -15,6 +15,10 @@ export type InAppNotificationAction = | { type: 'troubleshoot-dialog'; troubleshoot: InAppNotificationTroubleshootInfo; + } + | { + type: 'close'; + close: () => void; }; export type InAppNotificationIndicatorType = 'success' | 'warning' | 'error'; |
