1 import type { PropsWithChildren } from 'react';
2 import { type FC, createContext, useContext, useMemo } from 'react';
3 import { useHistory, useLocation, useRouteMatch } from 'react-router-dom';
5 import { type Location } from 'history';
7 import type { ItemFilters, Maybe, MaybeNull, SelectedItem } from '@proton/pass/types';
8 import { objectFilter } from '@proton/pass/utils/object/filter';
12 decodeFiltersFromSearch,
20 export type NavigateOptions<LocationState = any> = {
21 filters?: Partial<ItemFilters>;
23 mode?: 'push' | 'replace';
24 searchParams?: { [key: string]: any };
25 state?: LocationState;
28 export type ItemSelectOptions<LocationState = any> = NavigateOptions<LocationState> & {
31 view?: 'edit' | 'view' | 'history';
34 export type NavigationContextValue = {
35 /** Parsed search parameter filters. */
37 /** Flag indicating whether we are currently on an item list page */
38 matchItemList: boolean;
39 /** Flag indicating whether we are currently on the onboarding route */
40 matchOnboarding: boolean;
41 /** Flag indicating whether we are currently on a trash route */
43 /** Selected item's `itemId` and `shareId` parsed from URL */
44 selectedItem: Maybe<SelectedItem>;
45 /** Wraps react-router-dom's `useHistory` and provides extra options
46 * to chose the navigation mode (push, replace) and wether you want to
47 * push new search parameters to the target path. */
48 navigate: <S>(pathname: string, options?: NavigateOptions<S>) => void;
49 /** Navigates to an item view. By default it will go to the `view` screen,
50 * but this can be customized via options. */
51 selectItem: <S>(shareId: string, itemId: string, options?: ItemSelectOptions<S>) => void;
52 /** Sets the filters and updates the location's search parameters. */
53 setFilters: (filters: Partial<ItemFilters>) => void;
54 /** Joins the current location search parameters to the provided path */
55 preserveSearch: (path: string) => string;
56 /** Resolves the current location */
57 getCurrentLocation: () => Location;
60 const NavigationContext = createContext<MaybeNull<NavigationContextValue>>(null);
62 export const NavigationProvider: FC<PropsWithChildren> = ({ children }) => {
63 const history = useHistory();
64 const location = useLocation();
66 const filters = decodeFiltersFromSearch(location.search);
68 const matchTrash = useRouteMatch(getTrashRoute()) !== null;
69 const matchOnboarding = useRouteMatch(getOnboardingRoute()) !== null;
71 const itemRoute = getItemRoute(':shareId', ':itemId', { trashed: matchTrash });
72 const selectedItem = useRouteMatch<SelectedItem>(itemRoute)?.params;
74 const matchRootExact = useRouteMatch({ exact: true, path: getLocalPath() });
75 const matchItemExact = useRouteMatch({ exact: true, path: itemRoute });
76 const matchItemList = matchRootExact !== null || matchItemExact !== null || matchTrash;
78 const navigate = (pathname: string, options: NavigateOptions = { mode: 'push' }) => {
79 const search = new URLSearchParams(history.location.search);
81 if (options.searchParams) {
82 /* If additional search params were provided */
83 Object.entries(options.searchParams).forEach(([key, value]) => search.set(key, value));
86 if (options.filters) {
87 /* Merge the incoming filters with the current ones */
88 const currFilters = decodeFilters(search.get('filters'));
89 const newFilters = objectFilter(options.filters, (_, value) => value !== undefined);
90 const nextFilters = { ...currFilters, ...newFilters };
91 search.set('filters', encodeFilters(nextFilters));
94 history[options.mode ?? 'push']({
96 search: `?${search.toString()}`,
102 const selectItem = (shareId: string, itemId: string, options?: ItemSelectOptions) => {
103 const base = getItemRoute(shareId, itemId, { trashed: options?.inTrash, prefix: options?.prefix });
104 const view = options?.view && options.view !== 'view' ? `/${options.view}` : '';
105 navigate(base + view, options);
108 /** Determines whether to replace the history entry only when the search
109 * filter have changed. For all other changes, it is preferred to push to
110 * the history. This approach prevents creating a new route every time the
111 * debounced search value changes, which would pollute the history stack. */
112 const setFilters = (update: Partial<ItemFilters>) => {
114 (update.selectedShareId && update.selectedShareId !== filters.selectedShareId) ||
115 (update.sort && update.sort !== filters.sort) ||
116 (update.type && update.type !== filters.type);
118 navigate(history.location.pathname, {
119 mode: shouldPush ? 'push' : 'replace',
124 const navigation = useMemo<NavigationContextValue>(
134 preserveSearch: (path) => path + history.location.search,
135 getCurrentLocation: () => ({ ...history.location }),
138 location.search /* indirectly matches filter changes */,
146 return <NavigationContext.Provider value={navigation}>{children}</NavigationContext.Provider>;
149 export const useNavigation = (): NavigationContextValue => useContext(NavigationContext)!;