summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-16 13:12:46 +0200
committerTobias Järvelöv <tobias.jarvelov@mullvad.net>2025-10-20 14:12:48 +0200
commitf594762360906b08eafaa8534a5d942cf19652ef (patch)
tree9294ae19d5ee450d379ac70e044432ccaf98d334
parent860885af59cb87bb44ec1e1c87af8d6af0b0f35f (diff)
downloadmullvadvpn-f594762360906b08eafaa8534a5d942cf19652ef.tar.xz
mullvadvpn-f594762360906b08eafaa8534a5d942cf19652ef.zip
Use motion for in-app notification transition
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx38
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/components/NotificationBanner.tsx57
2 files changed, 44 insertions, 51 deletions
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx
index bf61ff8003..61080939a6 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationArea.tsx
@@ -1,3 +1,4 @@
+import { AnimatePresence } from 'motion/react';
import { useCallback, useState } from 'react';
import { messages } from '../../shared/gettext';
@@ -35,6 +36,7 @@ import {
} from '../lib/notifications';
import { AppUpgradeAvailableNotificationProvider } from '../lib/notifications/app-upgrade-available';
import { useTunnelProtocol } from '../lib/relay-settings-hooks';
+import { useMounted } from '../lib/utility-hooks';
import accountActions from '../redux/account/actions';
import { convertEventTypeToStep } from '../redux/app-upgrade/helpers';
import { useAppUpgradeError, useVersionSuggestedUpgrade } from '../redux/hooks';
@@ -193,12 +195,28 @@ export default function NotificationArea(props: IProps) {
notification.mayDisplay(),
);
+ const notification = notificationProvider?.getInAppNotification();
if (notificationProvider) {
- const notification = notificationProvider.getInAppNotification();
+ if (!notification) {
+ log.error(
+ `Notification providers mayDisplay() returned true but getInAppNotification() returned undefined for ${notificationProvider.constructor.name}`,
+ );
+ }
+ }
+
+ // We only want to animate notifications after first mount,
+ // so as to prevent an animation from animating in when the
+ // app has just started.
+ const mounted = useMounted();
+ const isMounted = mounted();
- if (notification) {
- return (
- <NotificationBanner className={props.className} data-testid="notificationBanner">
+ return (
+ <AnimatePresence>
+ {notification && (
+ <NotificationBanner
+ animateIn={isMounted}
+ aria-hidden={!notification}
+ className={props.className}>
<NotificationIndicator
$type={notification.indicator}
data-testid="notificationIndicator"
@@ -220,15 +238,9 @@ export default function NotificationArea(props: IProps) {
/>
)}
</NotificationBanner>
- );
- } else {
- log.error(
- `Notification providers mayDisplay() returned true but getInAppNotification() returned undefined for ${notificationProvider.constructor.name}`,
- );
- }
- }
-
- return <NotificationBanner className={props.className} aria-hidden={true} />;
+ )}
+ </AnimatePresence>
+ );
}
interface NotificationActionWrapperProps {
diff --git a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationBanner.tsx b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationBanner.tsx
index eedbdb3de4..c22051f825 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/components/NotificationBanner.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/components/NotificationBanner.tsx
@@ -1,4 +1,5 @@
-import React, { useEffect, useState } from 'react';
+import { motion } from 'motion/react';
+import React from 'react';
import styled from 'styled-components';
import { messages } from '../../shared/gettext';
@@ -6,7 +7,6 @@ import { InAppNotificationIndicatorType } from '../../shared/notifications/notif
import { IconButton } from '../lib/components';
import { colors } from '../lib/foundations';
import { useExclusiveTask } from '../lib/hooks/use-exclusive-task';
-import { useEffectEvent, useLastDefinedValue, useStyledRef } from '../lib/utility-hooks';
import { tinyText } from './common-styles';
const NOTIFICATION_AREA_ID = 'notification-area';
@@ -106,22 +106,13 @@ export const NotificationIndicator = styled.div<INotificationIndicatorProps>((pr
: colors.transparent,
}));
-interface ICollapsibleProps {
- $alignBottom: boolean;
- $height?: number;
-}
-
-const Collapsible = styled.div<ICollapsibleProps>((props) => {
- return {
- display: 'flex',
- flexDirection: 'column',
- justifyContent: props.$alignBottom ? 'flex-end' : 'flex-start',
- backgroundColor: colors.darkerBlue50,
- overflow: 'hidden',
- // Using auto as the initial value prevents transition if a notification is visible on mount.
- height: props.$height === undefined ? 'auto' : `${props.$height}px`,
- transition: 'height 250ms ease-in-out',
- };
+const Collapsible = styled(motion.div)({
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'flex-start',
+ translateY: '0%',
+ backgroundColor: colors.darkerBlue50,
+ overflow: 'hidden',
});
const Content = styled.section({
@@ -134,30 +125,20 @@ const Content = styled.section({
interface INotificationBannerProps {
children?: React.ReactNode; // Array<NotificationContent | NotificationActions>,
className?: string;
+ animateIn: boolean;
}
-export function NotificationBanner(props: INotificationBannerProps) {
- const [contentHeight, setContentHeight] = useState<number>();
- const [alignBottom, setAlignBottom] = useState(false);
-
- const contentRef = useStyledRef<HTMLDivElement>();
-
- const children = useLastDefinedValue(props.children);
-
- const updateHeightEvent = useEffectEvent(() => {
- const newHeight =
- props.children !== undefined ? (contentRef.current?.getBoundingClientRect().height ?? 0) : 0;
- if (newHeight !== contentHeight) {
- setContentHeight(newHeight);
- setAlignBottom((alignBottom) => alignBottom || contentHeight === 0 || newHeight === 0);
- }
- });
-
- useEffect(() => updateHeightEvent());
+export function NotificationBanner({ className, children, animateIn }: INotificationBannerProps) {
+ const translateYInitial = animateIn ? '-100%' : '0%';
return (
- <Collapsible $height={contentHeight} className={props.className} $alignBottom={alignBottom}>
- <Content ref={contentRef}>{children}</Content>
+ <Collapsible
+ animate={{ translateY: '0%' }}
+ className={className}
+ exit={{ translateY: '-100%' }}
+ initial={{ translateY: translateYInitial }}
+ transition={{ duration: 0.25 }}>
+ <Content>{children}</Content>
</Collapsible>
);
}