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]; }