summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorOskar Nyberg <oskar@mullvad.net>2023-06-13 11:01:29 +0200
committerOskar Nyberg <oskar@mullvad.net>2023-06-14 10:25:18 +0200
commit0aadb762da306adc7e305388496a384fdb2c4181 (patch)
tree95d4f7b21a659b93f34c45ef1718f81a7ab8a8e5
parentcecccd43d8e3c05e281be19c58f8e08fb2b651c6 (diff)
downloadmullvadvpn-0aadb762da306adc7e305388496a384fdb2c4181.tar.xz
mullvadvpn-0aadb762da306adc7e305388496a384fdb2c4181.zip
Add new device in-app notification
-rw-r--r--gui/locales/messages.pot12
-rw-r--r--gui/src/renderer/components/NotificationArea.tsx29
-rw-r--r--gui/src/renderer/components/NotificationBanner.tsx37
-rw-r--r--gui/src/renderer/redux/account/actions.ts10
-rw-r--r--gui/src/renderer/redux/account/reducers.ts18
-rw-r--r--gui/src/shared/notifications/new-device.ts32
-rw-r--r--gui/src/shared/notifications/notification.ts4
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';