Files
frigate/web/src/hooks/use-overlay-state.tsx
T

258 lines
7.0 KiB
TypeScript
Raw Normal View History

2026-03-24 13:53:39 -06:00
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
2024-09-12 08:46:29 -06:00
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence";
import { useUserPersistence } from "./use-user-persistence";
import { AuthContext } from "@/context/auth-context";
export function useOverlayState<S>(
2024-03-04 16:18:30 -07:00
key: string,
defaultValue: S | undefined = undefined,
2024-12-12 08:22:30 -06:00
preserveSearch: boolean = true,
): [S | undefined, (value: S, replace?: boolean) => void] {
const location = useLocation();
const navigate = useNavigate();
2026-03-06 14:41:15 -06:00
const locationRef = useRef(location);
locationRef.current = location;
const setOverlayStateValue = useCallback(
(value: S, replace: boolean = false) => {
2026-03-06 14:41:15 -06:00
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
const newLocationState = { ...loc.state };
newLocationState[key] = value;
2026-03-06 14:41:15 -06:00
navigate(loc.pathname + (preserveSearch ? loc.search : ""), {
state: newLocationState,
replace,
});
},
2026-03-06 14:41:15 -06:00
// locationRef is stable so we don't need it in deps
2024-02-28 15:23:56 -07:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-03-06 14:41:15 -06:00
[key, navigate, preserveSearch],
);
const overlayStateValue = useMemo<S | undefined>(
2024-03-04 16:18:30 -07:00
() => location.state && location.state[key],
[location, key],
);
return [overlayStateValue ?? defaultValue, setOverlayStateValue];
}
export function usePersistedOverlayState<S extends string>(
key: string,
defaultValue: S | undefined = undefined,
): [
S | undefined,
(value: S | undefined, replace?: boolean) => void,
2025-09-30 17:53:48 -05:00
boolean,
() => void,
] {
const location = useLocation();
const navigate = useNavigate();
2026-03-06 14:41:15 -06:00
const locationRef = useRef(location);
locationRef.current = location;
// currently selected value
const overlayStateValue = useMemo<S | undefined>(
() => location.state && location.state[key],
[location, key],
);
// saved value from previous session
2025-09-30 17:53:48 -05:00
const [persistedValue, setPersistedValue, loaded, deletePersistedValue] =
usePersistence<S>(key, overlayStateValue);
const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => {
2026-03-06 14:41:15 -06:00
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
setPersistedValue(value);
2026-03-06 14:41:15 -06:00
const newLocationState = { ...loc.state };
newLocationState[key] = value;
2026-03-06 14:41:15 -06:00
navigate(loc.pathname, { state: newLocationState, replace });
},
2026-03-06 14:41:15 -06:00
// locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-03-06 14:41:15 -06:00
[key, navigate, setPersistedValue],
);
return [
overlayStateValue ?? persistedValue ?? defaultValue,
setOverlayStateValue,
2025-09-30 17:53:48 -05:00
loaded,
deletePersistedValue,
];
}
2024-03-15 12:46:17 -06:00
/**
* Like usePersistedOverlayState, but namespaces the persistence key by username.
* This ensures different users on the same browser don't share state.
* Automatically migrates data from legacy (non-namespaced) keys on first use.
*/
export function useUserPersistedOverlayState<S extends string>(
key: string,
defaultValue: S | undefined = undefined,
): [
S | undefined,
(value: S | undefined, replace?: boolean) => void,
boolean,
() => void,
] {
const { auth } = useContext(AuthContext);
const location = useLocation();
const navigate = useNavigate();
2026-03-06 14:41:15 -06:00
const locationRef = useRef(location);
locationRef.current = location;
// currently selected value from URL state
const overlayStateValue = useMemo<S | undefined>(
() => location.state && location.state[key],
[location, key],
);
// saved value from previous session (user-namespaced with migration)
const [persistedValue, setPersistedValue, loaded, deletePersistedValue] =
useUserPersistence<S>(key, overlayStateValue);
const setOverlayStateValue = useCallback(
(value: S | undefined, replace: boolean = false) => {
2026-03-06 14:41:15 -06:00
const loc = locationRef.current;
const currentValue = loc.state?.[key] as S | undefined;
if (Object.is(currentValue, value)) {
return;
}
setPersistedValue(value);
2026-03-06 14:41:15 -06:00
const newLocationState = { ...loc.state };
newLocationState[key] = value;
2026-03-06 14:41:15 -06:00
navigate(loc.pathname, { state: newLocationState, replace });
},
2026-03-06 14:41:15 -06:00
// locationRef is stable so we don't need it in deps
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-03-06 14:41:15 -06:00
[key, navigate, setPersistedValue],
);
// Don't return a value until auth has finished loading
if (auth.isLoading) {
return [undefined, setOverlayStateValue, false, deletePersistedValue];
}
return [
overlayStateValue ?? persistedValue ?? defaultValue,
setOverlayStateValue,
loaded,
deletePersistedValue,
];
}
2024-03-15 12:46:17 -06:00
export function useHashState<S extends string>(): [
S | undefined,
(value: S) => void,
] {
const location = useLocation();
const navigate = useNavigate();
2026-03-06 14:41:15 -06:00
const locationRef = useRef(location);
locationRef.current = location;
2024-03-15 12:46:17 -06:00
const setHash = useCallback(
(value: S | undefined) => {
2026-03-06 14:41:15 -06:00
const loc = locationRef.current;
2024-03-15 12:46:17 -06:00
if (!value) {
2026-03-06 14:41:15 -06:00
navigate(loc.pathname);
2024-03-15 12:46:17 -06:00
} else {
2026-03-06 14:41:15 -06:00
navigate(`${loc.pathname}#${value}`, { state: loc.state });
2024-03-15 12:46:17 -06:00
}
},
2026-03-06 14:41:15 -06:00
// locationRef is stable so we don't need it in deps
2024-03-15 12:46:17 -06:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-03-06 14:41:15 -06:00
[navigate],
2024-03-15 12:46:17 -06:00
);
const hash = useMemo(
() => location.hash.substring(1) as unknown as S,
[location.hash],
);
return [hash, setHash];
}
export function useSearchEffect(
key: string,
2024-09-12 08:46:29 -06:00
callback: (value: string) => boolean,
) {
2025-03-24 11:25:36 -06:00
const location = useLocation();
2025-10-09 08:49:42 -06:00
const navigate = useNavigate();
const [searchParams] = useSearchParams();
2026-03-24 13:53:39 -06:00
const [pendingRemoval, setPendingRemoval] = useState(false);
const processedRef = useRef<string | null>(null);
2026-03-24 13:53:39 -06:00
const currentParam = searchParams.get(key);
2026-03-24 13:53:39 -06:00
// Process the param via callback (once per unique param value)
useEffect(() => {
2026-03-24 13:53:39 -06:00
if (currentParam == null || currentParam === processedRef.current) {
return;
}
2026-03-24 13:53:39 -06:00
const decoded = decodeURIComponent(currentParam);
const shouldRemove = callback(decoded);
2024-09-12 08:46:29 -06:00
2026-03-24 13:53:39 -06:00
if (shouldRemove) {
processedRef.current = currentParam;
setPendingRemoval(true);
2024-09-12 08:46:29 -06:00
}
2026-03-24 13:53:39 -06:00
}, [currentParam, callback, key]);
// Remove the search param in a separate render cycle so that any state
// changes from the callback (e.g., overlay state navigations) are already
// reflected in location.state before we navigate to strip the param.
useEffect(() => {
if (!pendingRemoval) {
return;
}
setPendingRemoval(false);
navigate(location.pathname + location.hash, {
state: location.state,
replace: true,
});
2025-10-09 08:49:42 -06:00
}, [
2026-03-24 13:53:39 -06:00
pendingRemoval,
navigate,
2025-10-09 08:49:42 -06:00
location.pathname,
location.hash,
2026-03-24 13:53:39 -06:00
location.state,
2025-10-09 08:49:42 -06:00
]);
2026-03-24 13:53:39 -06:00
// Reset tracking when param is removed from the URL
useEffect(() => {
if (currentParam == null) {
processedRef.current = null;
}
}, [currentParam]);
}