diff --git a/web/src/hooks/use-overlay-state.tsx b/web/src/hooks/use-overlay-state.tsx index ab4b7fcfe..34389c5cf 100644 --- a/web/src/hooks/use-overlay-state.tsx +++ b/web/src/hooks/use-overlay-state.tsx @@ -1,6 +1,8 @@ -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useContext, useEffect, useMemo } from "react"; 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( key: string, @@ -79,6 +81,60 @@ export function usePersistedOverlayState( ]; } +/** + * 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( + 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(); + const currentLocationState = useMemo(() => location.state, [location]); + + // currently selected value from URL state + const overlayStateValue = useMemo( + () => location.state && location.state[key], + [location, key], + ); + + // saved value from previous session (user-namespaced with migration) + const [persistedValue, setPersistedValue, loaded, deletePersistedValue] = + useUserPersistence(key, overlayStateValue); + + const setOverlayStateValue = useCallback( + (value: S | undefined, replace: boolean = false) => { + setPersistedValue(value); + const newLocationState = { ...currentLocationState }; + newLocationState[key] = value; + navigate(location.pathname, { state: newLocationState, replace }); + }, + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + [key, currentLocationState, 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, + ]; +} + export function useHashState(): [ S | undefined, (value: S) => void, diff --git a/web/src/hooks/use-user-persistence.ts b/web/src/hooks/use-user-persistence.ts new file mode 100644 index 000000000..2eb8a8ccb --- /dev/null +++ b/web/src/hooks/use-user-persistence.ts @@ -0,0 +1,159 @@ +import { useEffect, useState, useCallback, useContext, useRef } from "react"; +import { get as getData, set as setData, del as delData } from "idb-keyval"; +import { AuthContext } from "@/context/auth-context"; + +type useUserPersistenceReturn = [ + value: S | undefined, + setValue: (value: S | undefined) => void, + loaded: boolean, + deleteValue: () => void, +]; + +// Key used to track which keys have been migrated to prevent re-reading old keys +const MIGRATED_KEYS_STORAGE_KEY = "frigate-migrated-user-keys"; + +/** + * Get the set of keys that have already been migrated for a specific user. + */ +async function getMigratedKeys(username: string): Promise> { + const allMigrated = + (await getData>(MIGRATED_KEYS_STORAGE_KEY)) || {}; + return new Set(allMigrated[username] || []); +} + +/** + * Mark a key as migrated for a specific user. + */ +async function markKeyAsMigrated(username: string, key: string): Promise { + const allMigrated = + (await getData>(MIGRATED_KEYS_STORAGE_KEY)) || {}; + const userMigrated = new Set(allMigrated[username] || []); + userMigrated.add(key); + allMigrated[username] = Array.from(userMigrated); + await setData(MIGRATED_KEYS_STORAGE_KEY, allMigrated); +} + +/** + * Hook for user-namespaced persistence with automatic migration from legacy keys. + * + * This hook: + * 1. Namespaces storage keys by username to isolate per-user preferences + * 2. Automatically migrates data from legacy (non-namespaced) keys on first use + * 3. Tracks migrated keys to prevent re-reading stale data after migration + * 4. Waits for auth to load before returning values to prevent race conditions + * + * @param key - The base key name (will be namespaced with username) + * @param defaultValue - Default value if no persisted value exists + */ +export function useUserPersistence( + key: string, + defaultValue: S | undefined = undefined, +): useUserPersistenceReturn { + const { auth } = useContext(AuthContext); + const [value, setInternalValue] = useState(defaultValue); + const [loaded, setLoaded] = useState(false); + const migrationAttemptedRef = useRef(false); + + // Compute the user-namespaced key + const username = auth?.user?.username; + const isAuthenticated = + username && username !== "anonymous" && !auth.isLoading; + const namespacedKey = isAuthenticated ? `${key}:${username}` : key; + + const setValue = useCallback( + (newValue: S | undefined) => { + setInternalValue(newValue); + async function update() { + await setData(namespacedKey, newValue); + } + update(); + }, + [namespacedKey], + ); + + const deleteValue = useCallback(async () => { + await delData(namespacedKey); + setInternalValue(defaultValue); + }, [namespacedKey, defaultValue]); + + useEffect(() => { + // Don't load until auth is resolved + if (auth.isLoading) { + return; + } + + // Reset migration flag when key changes + migrationAttemptedRef.current = false; + setLoaded(false); + + async function loadWithMigration() { + // For authenticated users, check if we need to migrate from legacy key + if (isAuthenticated && username && !migrationAttemptedRef.current) { + migrationAttemptedRef.current = true; + + const migratedKeys = await getMigratedKeys(username); + + // Check if we already have data in the namespaced key + const existingNamespacedValue = await getData(namespacedKey); + + if (typeof existingNamespacedValue !== "undefined") { + // Already have namespaced data, use it + setInternalValue(existingNamespacedValue); + setLoaded(true); + return; + } + + // Check if this key has already been migrated (even if value was deleted) + if (migratedKeys.has(key)) { + // Already migrated, don't read from legacy key + setInternalValue(defaultValue); + setLoaded(true); + return; + } + + // Try to migrate from legacy key + const legacyValue = await getData(key); + if (typeof legacyValue !== "undefined") { + // Migrate: copy to namespaced key, delete legacy key, mark as migrated + await setData(namespacedKey, legacyValue); + await delData(key); + await markKeyAsMigrated(username, key); + setInternalValue(legacyValue); + setLoaded(true); + return; + } + + // No legacy value, just mark as migrated so we don't check again + await markKeyAsMigrated(username, key); + setInternalValue(defaultValue); + setLoaded(true); + return; + } + + // For unauthenticated users or after migration check, just load normally + const storedValue = await getData(namespacedKey); + if (typeof storedValue !== "undefined") { + setInternalValue(storedValue); + } else { + setInternalValue(defaultValue); + } + setLoaded(true); + } + + loadWithMigration(); + }, [ + auth.isLoading, + isAuthenticated, + username, + key, + namespacedKey, + defaultValue, + ]); + + // Don't return a value until auth has finished loading + if (auth.isLoading) { + return [undefined, setValue, false, deleteValue]; + } + + return [value, setValue, loaded, deleteValue]; +}