import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useToast } from "../components/Toast"; import { useI18n } from "../i18n"; import { useWallet } from "../wallet/WalletProvider"; import { addFavorite, getFavoriteIds, isFavoritesAuthError, removeFavorite, } from "./api"; type FavoriteStatus = "unknown" | "favorited" | "notFavorited"; type FavoritesContextValue = { favoriteIds: Set; pendingIds: Set; statusFor: (resourceId: string) => FavoriteStatus; ensureFavoriteIds: (resourceIds: string[]) => Promise; toggleFavorite: (resourceId: string) => Promise; markFavorite: (resourceId: string, favorited: boolean) => void; }; const FavoritesContext = createContext(null); export function FavoritesProvider({ children }: { children: ReactNode }) { const { t } = useI18n(); const { showToast } = useToast(); const wallet = useWallet(); const { address, logout, openLoginModal, status, token } = wallet; const handleAuthError = useCallback(() => { logout(); openLoginModal(); showToast(t("favoriteSessionExpired")); }, [logout, openLoginModal, showToast, t]); const [favoriteIds, setFavoriteIds] = useState>(() => new Set()); const [knownIds, setKnownIds] = useState>(() => new Set()); const [pendingIds, setPendingIds] = useState>(() => new Set()); const pendingAfterLoginRef = useRef(null); const lastAddressRef = useRef(null); const knownIdsRef = useRef>(new Set()); const inFlightIdsRef = useRef>(new Set()); const queuedIdsRef = useRef>(new Set()); const batchTimerRef = useRef(null); const tokenRef = useRef(null); useEffect(() => { tokenRef.current = status === "loggedIn" ? token : null; }, [status, token]); const clearFavoriteStatus = useCallback(() => { knownIdsRef.current = new Set(); inFlightIdsRef.current = new Set(); queuedIdsRef.current = new Set(); if (batchTimerRef.current !== null) { window.clearTimeout(batchTimerRef.current); batchTimerRef.current = null; } setFavoriteIds(new Set()); setKnownIds(new Set()); setPendingIds(new Set()); }, []); useEffect(() => { const nextAddress = status === "loggedIn" ? address : null; if (lastAddressRef.current === nextAddress) return; lastAddressRef.current = nextAddress; clearFavoriteStatus(); if (!nextAddress) pendingAfterLoginRef.current = null; }, [address, clearFavoriteStatus, status]); useEffect( () => () => { if (batchTimerRef.current !== null) { window.clearTimeout(batchTimerRef.current); } }, [], ); const markFavorite = useCallback((resourceId: string, favorited: boolean) => { setKnownIds((prev) => { const next = new Set(prev).add(resourceId); knownIdsRef.current = next; return next; }); setFavoriteIds((prev) => { const next = new Set(prev); if (favorited) next.add(resourceId); else next.delete(resourceId); return next; }); }, []); const flushFavoriteIdBatch = useCallback(async () => { const requestToken = tokenRef.current; const requestIds = Array.from(queuedIdsRef.current); queuedIdsRef.current.clear(); if (!requestToken || requestIds.length === 0) { requestIds.forEach((id) => inFlightIdsRef.current.delete(id)); return; } try { const ids = await getFavoriteIds(requestToken, requestIds); if (tokenRef.current !== requestToken) return; setKnownIds((prev) => { const next = new Set(prev); requestIds.forEach((id) => next.add(id)); knownIdsRef.current = next; return next; }); setFavoriteIds((prev) => { const next = new Set(prev); ids.forEach((id) => next.add(id)); return next; }); } catch (error) { if (isFavoritesAuthError(error)) handleAuthError(); } finally { requestIds.forEach((id) => inFlightIdsRef.current.delete(id)); if (queuedIdsRef.current.size > 0 && batchTimerRef.current === null) { batchTimerRef.current = window.setTimeout(() => { batchTimerRef.current = null; void flushFavoriteIdBatch(); }, 0); } } }, [handleAuthError]); const ensureFavoriteIds = useCallback( async (resourceIds: string[]) => { if (!token || status !== "loggedIn") return; const missing = [...new Set(resourceIds)].filter( (id) => !knownIdsRef.current.has(id) && !inFlightIdsRef.current.has(id), ); if (missing.length === 0) return; missing.forEach((id) => { queuedIdsRef.current.add(id); inFlightIdsRef.current.add(id); }); if (batchTimerRef.current !== null) return; batchTimerRef.current = window.setTimeout(() => { batchTimerRef.current = null; void flushFavoriteIdBatch(); }, 0); }, [flushFavoriteIdBatch, status, token], ); const runFavoriteMutation = useCallback( async (resourceId: string): Promise => { if (!token) return null; const currentlyFavorite = favoriteIds.has(resourceId); const nextFavorited = !currentlyFavorite; setPendingIds((prev) => new Set(prev).add(resourceId)); markFavorite(resourceId, nextFavorited); try { if (currentlyFavorite) await removeFavorite(token, resourceId); else await addFavorite(token, resourceId); showToast( currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"), ); return nextFavorited; } catch (error) { markFavorite(resourceId, currentlyFavorite); if (isFavoritesAuthError(error)) handleAuthError(); else showToast(t("favoriteFailed"), "error"); throw error; } finally { setPendingIds((prev) => { const next = new Set(prev); next.delete(resourceId); return next; }); } }, [favoriteIds, handleAuthError, markFavorite, showToast, t, token], ); const toggleFavorite = useCallback( async (resourceId: string) => { if (!token || status !== "loggedIn") { pendingAfterLoginRef.current = resourceId; openLoginModal(); showToast(t("favoriteLoginRequired")); return null; } return runFavoriteMutation(resourceId); }, [openLoginModal, runFavoriteMutation, showToast, status, t, token], ); useEffect(() => { if (status !== "loggedIn" || !token) return; const pending = pendingAfterLoginRef.current; if (!pending) return; pendingAfterLoginRef.current = null; void runFavoriteMutation(pending).catch(() => undefined); }, [runFavoriteMutation, status, token]); const statusFor = useCallback( (resourceId: string): FavoriteStatus => { if (favoriteIds.has(resourceId)) return "favorited"; if (knownIds.has(resourceId)) return "notFavorited"; return "unknown"; }, [favoriteIds, knownIds], ); const value = useMemo( () => ({ favoriteIds, pendingIds, statusFor, ensureFavoriteIds, toggleFavorite, markFavorite, }), [ ensureFavoriteIds, favoriteIds, markFavorite, pendingIds, statusFor, toggleFavorite, ], ); return ( {children} ); } export function useFavorites() { const ctx = useContext(FavoritesContext); if (!ctx) throw new Error("useFavorites must be used within FavoritesProvider"); return ctx; }