summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--desktop/packages/mullvad-vpn/assets/css/global.css11
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts1
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts3
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx79
-rw-r--r--desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts130
-rw-r--r--desktop/packages/mullvad-vpn/types/global/index.d.ts11
6 files changed, 182 insertions, 53 deletions
diff --git a/desktop/packages/mullvad-vpn/assets/css/global.css b/desktop/packages/mullvad-vpn/assets/css/global.css
index dfa159e020..e8d4d1619c 100644
--- a/desktop/packages/mullvad-vpn/assets/css/global.css
+++ b/desktop/packages/mullvad-vpn/assets/css/global.css
@@ -40,3 +40,14 @@ body {
transition-duration: 0ms !important;
}
}
+
+::view-transition-image-pair(root) {
+ isolation: auto;
+}
+
+::view-transition-old(root),
+::view-transition-new(root) {
+ animation: none;
+ mix-blend-mode: normal;
+ display: block;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts
new file mode 100644
index 0000000000..33ea694d04
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/index.ts
@@ -0,0 +1 @@
+export * from './reduce-motion';
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts
new file mode 100644
index 0000000000..6b000bf7b7
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/functions/reduce-motion.ts
@@ -0,0 +1,3 @@
+export function getReduceMotion() {
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx
index 741c298da6..afea9582ab 100644
--- a/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/history.tsx
@@ -5,54 +5,27 @@ import { IHistoryObject, LocationState } from '../../shared/ipc-types';
import { GeneratedRoutePath } from './routeHelpers';
import { RoutePath } from './routes';
-export interface ITransitionSpecification {
- name: string;
- duration: number;
+export enum TransitionType {
+ show,
+ dismiss,
+ push,
+ pop,
+ none,
}
-interface ITransitionMap {
- [name: string]: ITransitionSpecification;
-}
-
-/**
- * Transition descriptors
- */
-export const transitions: ITransitionMap = {
- show: {
- name: 'slide-up',
- duration: 450,
- },
- dismiss: {
- name: 'slide-down',
- duration: 450,
- },
- push: {
- name: 'push',
- duration: 450,
- },
- pop: {
- name: 'pop',
- duration: 450,
- },
- none: {
- name: '',
- duration: 0,
- },
-};
-
-const transitionOpposites: Record<string, string> = {
- 'slide-up': 'slide-down',
- 'slide-down': 'slide-up',
- push: 'pop',
- pop: 'push',
- '': '',
-};
-
-function oppositeTransition(transition: ITransitionSpecification): ITransitionSpecification {
- return {
- ...transition,
- name: transitionOpposites[transition.name],
- };
+function oppositeTransition(transition: TransitionType): TransitionType {
+ switch (transition) {
+ case TransitionType.show:
+ return TransitionType.dismiss;
+ case TransitionType.dismiss:
+ return TransitionType.none;
+ case TransitionType.push:
+ return TransitionType.pop;
+ case TransitionType.pop:
+ return TransitionType.none;
+ case TransitionType.none:
+ return TransitionType.none;
+ }
}
type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObject<LocationState>;
@@ -60,7 +33,7 @@ type LocationDescriptor = RoutePath | GeneratedRoutePath | LocationDescriptorObj
type LocationListener = (
location: Location<LocationState>,
action: Action,
- transition: ITransitionSpecification,
+ transition: TransitionType,
) => void;
export default class History {
@@ -95,7 +68,7 @@ export default class History {
}
public push = (nextLocation: LocationDescriptor, nextState?: Partial<LocationState>) => {
- const state = { transition: transitions.push, ...nextState };
+ const state = { transition: TransitionType.push, ...nextState };
this.pushImpl(nextLocation, state);
this.notify(state.transition);
};
@@ -113,7 +86,7 @@ export default class History {
this.index = 0;
this.entries = [location];
- this.notify(nextState?.transition ?? transitions.none);
+ this.notify(nextState?.transition ?? TransitionType.none);
};
public replaceRoot = (
@@ -125,7 +98,7 @@ export default class History {
this.entries.splice(0, 1, location);
if (this.index === 0) {
- this.notify(replacementState?.transition ?? transitions.none);
+ this.notify(replacementState?.transition ?? TransitionType.none);
}
};
@@ -188,7 +161,7 @@ export default class History {
this.entries.splice(this.index, this.entries.length - this.index, location);
}
- private popImpl(n = 1): ITransitionSpecification | undefined {
+ private popImpl(n = 1): TransitionType | undefined {
if (this.canGo(-n)) {
const transition = this.getPopTransition(n);
@@ -202,7 +175,7 @@ export default class History {
}
}
- private notify(transition: ITransitionSpecification) {
+ private notify(transition: TransitionType) {
this.listeners.forEach((listener) => listener(this.location, this.action, transition));
}
@@ -242,7 +215,7 @@ export default class History {
return {
scrollPosition: state?.scrollPosition ?? [0, 0],
expandedSections: state?.expandedSections ?? {},
- transition: state?.transition ?? transitions.none,
+ transition: state?.transition ?? TransitionType.none,
};
}
diff --git a/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts b/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts
new file mode 100644
index 0000000000..ee4a8f7eae
--- /dev/null
+++ b/desktop/packages/mullvad-vpn/src/renderer/lib/transition-hooks.ts
@@ -0,0 +1,130 @@
+import { Location } from 'history';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { flushSync } from 'react-dom';
+
+import { ViewTransition } from '../../../types/global';
+import { LocationState } from '../../shared/ipc-types';
+import { useAppContext } from '../context';
+import { TransitionType, useHistory } from '../lib/history';
+import { getReduceMotion } from './functions';
+import { useEffectEvent } from './utility-hooks';
+
+type QueueItem = { location: Location<LocationState>; transition: TransitionType };
+
+const TRANSITION_DURATION = 450;
+
+const viewTransitionRef: { current?: ViewTransition } = {};
+
+export function useAfterTransition() {
+ const runAfterTransition = useCallback((fn: () => void) => {
+ if (viewTransitionRef.current) {
+ void viewTransitionRef.current.finished.then(() => runAfterTransition(fn));
+ } else {
+ fn();
+ }
+ }, []);
+
+ return runAfterTransition;
+}
+
+export function useViewTransitions(onTransition?: () => void): Location<LocationState> {
+ const history = useHistory();
+ const [currentLocation, setCurrentLocation] = useState(history.location);
+ const queuedLocationRef = useRef<QueueItem | undefined>();
+ const { setNavigationHistory } = useAppContext();
+
+ const reduceMotion = getReduceMotion();
+
+ const updateView = useEffectEvent((location: Location<LocationState>) => {
+ setNavigationHistory(history.asObject);
+ setCurrentLocation(location);
+ });
+
+ const transitionToView = useEffectEvent(
+ (location: Location<LocationState>, transition: TransitionType) => {
+ if (reduceMotion) {
+ updateView(location);
+ return;
+ }
+
+ flushSync(() => {
+ viewTransitionRef.current = document.startViewTransition(() => {
+ updateView(location);
+ });
+
+ void viewTransitionRef.current.ready.then(() => animateNavigation(transition));
+ void viewTransitionRef.current.finished.then(() => {
+ const queueLocation = queuedLocationRef.current;
+
+ delete viewTransitionRef.current;
+ delete queuedLocationRef.current;
+
+ if (queueLocation) {
+ transitionToView(queueLocation.location, queueLocation.transition);
+ } else {
+ onTransition?.();
+ }
+ });
+ });
+ },
+ );
+
+ useEffect(() => {
+ // React throttles updates, so it's impossible to capture the intermediate navigation without
+ // listening to the history directly.
+ const unobserveHistory = history.listen((location, _, transition) => {
+ if (viewTransitionRef.current === undefined) {
+ transitionToView(location, transition);
+ } else {
+ queuedLocationRef.current = { location, transition };
+ }
+ });
+
+ return () => {
+ unobserveHistory?.();
+ };
+ }, [history]);
+
+ return currentLocation;
+}
+
+function animateNavigation(transition: TransitionType) {
+ const oldInFront = transition === TransitionType.dismiss || transition === TransitionType.pop;
+ const oldZIndex = oldInFront ? 2 : 0;
+
+ document.documentElement.animate(
+ [
+ { transform: 'translate(0%, 0%)', zIndex: oldZIndex },
+ { transform: oldToTransform[transition], zIndex: oldZIndex },
+ ],
+ {
+ duration: TRANSITION_DURATION,
+ easing: 'ease-in-out',
+ pseudoElement: '::view-transition-old(root)',
+ },
+ );
+ document.documentElement.animate(
+ [{ transform: newFromTransform[transition] }, { transform: 'translate(0%, 0%)' }],
+ {
+ duration: TRANSITION_DURATION,
+ easing: 'ease-in-out',
+ pseudoElement: '::view-transition-new(root)',
+ },
+ );
+}
+
+const oldToTransform = {
+ [TransitionType.show]: 'translateY(0%)',
+ [TransitionType.dismiss]: 'translateY(100%)',
+ [TransitionType.push]: 'translateX(-33%)',
+ [TransitionType.pop]: 'translateX(100%)',
+ [TransitionType.none]: '',
+};
+
+const newFromTransform = {
+ [TransitionType.show]: 'translateY(100%)',
+ [TransitionType.dismiss]: 'translateY(0%)',
+ [TransitionType.push]: 'translateX(100%)',
+ [TransitionType.pop]: 'translateX(-33%)',
+ [TransitionType.none]: '',
+};
diff --git a/desktop/packages/mullvad-vpn/types/global/index.d.ts b/desktop/packages/mullvad-vpn/types/global/index.d.ts
index fafa8e3b8c..31570bd59c 100644
--- a/desktop/packages/mullvad-vpn/types/global/index.d.ts
+++ b/desktop/packages/mullvad-vpn/types/global/index.d.ts
@@ -1,9 +1,20 @@
import { IpcRendererEventChannel } from '../../src/renderer/lib/ipc-event-channel';
+// The ViewTransition types can be removed from here whenever TS adds support for them.
+interface ViewTransition {
+ readonly ready: Promise<void>;
+ readonly finished: Promise<void>;
+}
+
declare global {
interface Window {
ipc: typeof IpcRendererEventChannel;
env: { platform: NodeJS.Platform; development: boolean; e2e: boolean };
e2e: { location: string };
}
+
+ // The ViewTransition types can be removed from here whenever TS adds support for them.
+ interface Document {
+ startViewTransition(callback: () => void): ViewTransition;
+ }
}