diff options
| author | Oskar Nyberg <oskar@mullvad.net> | 2023-06-14 10:25:32 +0200 |
|---|---|---|
| committer | Oskar Nyberg <oskar@mullvad.net> | 2023-06-14 10:25:32 +0200 |
| commit | 28069e86a364aa7f8e7fad51924ad3e0345e13d3 (patch) | |
| tree | 95d4f7b21a659b93f34c45ef1718f81a7ab8a8e5 /gui/src | |
| parent | 499e7516c4537c4e8fa6983c1e6698df32f1e9de (diff) | |
| parent | 0aadb762da306adc7e305388496a384fdb2c4181 (diff) | |
| download | mullvadvpn-28069e86a364aa7f8e7fad51924ad3e0345e13d3.tar.xz mullvadvpn-28069e86a364aa7f8e7fad51924ad3e0345e13d3.zip | |
Merge branch 'device-improvements'
Diffstat (limited to 'gui/src')
| -rw-r--r-- | gui/src/renderer/components/Account.tsx | 9 | ||||
| -rw-r--r-- | gui/src/renderer/components/AccountStyles.tsx | 5 | ||||
| -rw-r--r-- | gui/src/renderer/components/DeviceInfoButton.tsx | 57 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorView.tsx | 19 | ||||
| -rw-r--r-- | gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx | 8 | ||||
| -rw-r--r-- | gui/src/renderer/components/InfoButton.tsx | 3 | ||||
| -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 |
12 files changed, 201 insertions, 30 deletions
diff --git a/gui/src/renderer/components/Account.tsx b/gui/src/renderer/components/Account.tsx index 2841e4811c..bd3bafbafc 100644 --- a/gui/src/renderer/components/Account.tsx +++ b/gui/src/renderer/components/Account.tsx @@ -25,11 +25,13 @@ import { AccountRows, AccountRowValue, DeviceRowValue, + StyledDeviceNameRow, StyledSpinnerContainer, } from './AccountStyles'; import AccountTokenLabel from './AccountTokenLabel'; import * as AppButton from './AppButton'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; +import DeviceInfoButton from './DeviceInfoButton'; import ImageView from './ImageView'; import { BackAction } from './KeyboardNavigation'; import { Footer, Layout, SettingsContainer } from './Layout'; @@ -185,7 +187,12 @@ function LogoutDialog() { function DeviceNameRow() { const deviceName = useSelector((state) => state.account.deviceName); - return <DeviceRowValue>{deviceName}</DeviceRowValue>; + return ( + <StyledDeviceNameRow> + <DeviceRowValue>{deviceName}</DeviceRowValue> + <DeviceInfoButton /> + </StyledDeviceNameRow> + ); } function AccountNumberRow() { diff --git a/gui/src/renderer/components/AccountStyles.tsx b/gui/src/renderer/components/AccountStyles.tsx index 5cb934134a..706abb1bdf 100644 --- a/gui/src/renderer/components/AccountStyles.tsx +++ b/gui/src/renderer/components/AccountStyles.tsx @@ -50,3 +50,8 @@ export const StyledSpinnerContainer = styled.div({ justifyContent: 'center', padding: '8px 0', }); + +export const StyledDeviceNameRow = styled.div({ + display: 'flex', + flexDirection: 'row', +}); diff --git a/gui/src/renderer/components/DeviceInfoButton.tsx b/gui/src/renderer/components/DeviceInfoButton.tsx new file mode 100644 index 0000000000..4839866972 --- /dev/null +++ b/gui/src/renderer/components/DeviceInfoButton.tsx @@ -0,0 +1,57 @@ +import styled from 'styled-components'; + +import { messages } from '../../shared/gettext'; +import { useBoolean } from '../lib/utilityHooks'; +import * as AppButton from './AppButton'; +import { InfoIcon } from './InfoButton'; +import { ModalAlert, ModalAlertType, ModalMessage } from './Modal'; + +const StyledInfoButton = styled.button({ + margin: '0 0 0 10px', + borderWidth: 0, + padding: 0, + cursor: 'default', + backgroundColor: 'transparent', +}); + +export default function DeviceInfoButton() { + const [deviceHelpVisible, showDeviceHelp, hideDeviceHelp] = useBoolean(); + + return ( + <> + <StyledInfoButton + onClick={showDeviceHelp} + aria-label={messages.pgettext('accessibility', 'More information')}> + <InfoIcon size={16} /> + </StyledInfoButton> + <ModalAlert + isOpen={deviceHelpVisible} + type={ModalAlertType.info} + buttons={[ + <AppButton.BlueButton key="back" onClick={hideDeviceHelp}> + {messages.gettext('Got it!')} + </AppButton.BlueButton>, + ]} + close={hideDeviceHelp}> + <ModalMessage> + {messages.pgettext( + 'device-management', + 'This is the name assigned to the device. Each device logged in on a Mullvad account gets a unique name that helps you identify it when you manage your devices in the app or on the website.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'device-management', + 'You can have up to 5 devices logged in on one Mullvad account.', + )} + </ModalMessage> + <ModalMessage> + {messages.pgettext( + 'device-management', + 'If you log out, the device and the device name is removed. When you log back in again, the device will get a new name.', + )} + </ModalMessage> + </ModalAlert> + </> + ); +} diff --git a/gui/src/renderer/components/ExpiredAccountErrorView.tsx b/gui/src/renderer/components/ExpiredAccountErrorView.tsx index d5af378cca..3049ba8075 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorView.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorView.tsx @@ -4,6 +4,7 @@ import { sprintf } from 'sprintf-js'; import { links } from '../../config.json'; import { messages } from '../../shared/gettext'; import log from '../../shared/logging'; +import { capitalizeEveryWord } from '../../shared/string-helpers'; import { useAppContext } from '../context'; import { useHistory } from '../lib/history'; import { RoutePath } from '../lib/routes'; @@ -11,6 +12,7 @@ import { useSelector } from '../redux/store'; import * as AppButton from './AppButton'; import { AriaDescribed, AriaDescription, AriaDescriptionGroup } from './AriaGroup'; import * as Cell from './cell'; +import DeviceInfoButton from './DeviceInfoButton'; import { StyledAccountTokenContainer, StyledAccountTokenLabel, @@ -18,6 +20,7 @@ import { StyledBody, StyledContainer, StyledCustomScrollbars, + StyledDeviceLabel, StyledHeader, StyledMessage, StyledModalCellContainer, @@ -117,6 +120,22 @@ function WelcomeView() { </StyledAccountTokenContainer> </StyledAccountTokenMessage> + <StyledDeviceLabel> + <span> + {sprintf( + // TRANSLATORS: A label that will display the newly created device name to inform the user + // TRANSLATORS: about it. + // TRANSLATORS: Available placeholders: + // TRANSLATORS: %(deviceName)s - The name of the current device + messages.pgettext('device-management', 'Device name: %(deviceName)s'), + { + deviceName: capitalizeEveryWord(account.deviceName ?? ''), + }, + )} + </span> + <DeviceInfoButton /> + </StyledDeviceLabel> + <StyledMessage> {sprintf('%(introduction)s %(recoveryMessage)s', { introduction: messages.pgettext( diff --git a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx index 17c02cfc3c..753ecdacef 100644 --- a/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx +++ b/gui/src/renderer/components/ExpiredAccountErrorViewStyles.tsx @@ -69,3 +69,11 @@ export const StyledAccountTokenContainer = styled.div({ height: '50px', alignItems: 'center', }); + +export const StyledDeviceLabel = styled.span(tinyText, { + display: 'flex', + alignItems: 'middle', + lineHeight: '20px', + marginBottom: '18px', + color: colors.white, +}); diff --git a/gui/src/renderer/components/InfoButton.tsx b/gui/src/renderer/components/InfoButton.tsx index 52d92e18d6..302b043ef4 100644 --- a/gui/src/renderer/components/InfoButton.tsx +++ b/gui/src/renderer/components/InfoButton.tsx @@ -17,13 +17,14 @@ const StyledInfoButton = styled.button({ interface IInfoIconProps { className?: string; + size?: number; } export function InfoIcon(props: IInfoIconProps) { return ( <ImageView source="icon-info" - width={18} + width={props.size ?? 18} tintColor={colors.white} tintHoverColor={colors.white80} className={props.className} 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'; |
