2026-06-02 00:36:11 +08:00
|
|
|
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, removeFavorite } from "./api";
|
|
|
|
|
|
|
|
|
|
type FavoriteStatus = "unknown" | "favorited" | "notFavorited";
|
|
|
|
|
|
|
|
|
|
type FavoritesContextValue = {
|
|
|
|
|
favoriteIds: Set<string>;
|
|
|
|
|
pendingIds: Set<string>;
|
|
|
|
|
statusFor: (resourceId: string) => FavoriteStatus;
|
|
|
|
|
ensureFavoriteIds: (resourceIds: string[]) => Promise<void>;
|
|
|
|
|
toggleFavorite: (resourceId: string) => Promise<void>;
|
|
|
|
|
markFavorite: (resourceId: string, favorited: boolean) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
|
|
|
|
|
|
|
|
|
|
export function FavoritesProvider({ children }: { children: ReactNode }) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const { showToast } = useToast();
|
|
|
|
|
const wallet = useWallet();
|
2026-06-02 00:57:37 +08:00
|
|
|
const { address, openLoginModal, status, token } = wallet;
|
2026-06-02 00:36:11 +08:00
|
|
|
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set());
|
|
|
|
|
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
|
|
|
|
|
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
|
|
|
|
|
const pendingAfterLoginRef = useRef<string | null>(null);
|
2026-06-02 00:57:37 +08:00
|
|
|
const lastAddressRef = useRef<string | null>(null);
|
2026-06-02 01:11:00 +08:00
|
|
|
const knownIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
|
const inFlightIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
|
const queuedIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
|
const batchTimerRef = useRef<number | null>(null);
|
|
|
|
|
const tokenRef = useRef<string | null>(null);
|
2026-06-02 00:36:11 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-02 01:11:00 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-02 00:57:37 +08:00
|
|
|
setFavoriteIds(new Set());
|
|
|
|
|
setKnownIds(new Set());
|
|
|
|
|
setPendingIds(new Set());
|
2026-06-02 01:11:00 +08:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const nextAddress = status === "loggedIn" ? address : null;
|
|
|
|
|
if (lastAddressRef.current === nextAddress) return;
|
|
|
|
|
lastAddressRef.current = nextAddress;
|
|
|
|
|
clearFavoriteStatus();
|
2026-06-02 00:57:37 +08:00
|
|
|
if (!nextAddress) pendingAfterLoginRef.current = null;
|
2026-06-02 01:11:00 +08:00
|
|
|
}, [address, clearFavoriteStatus, status]);
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
if (batchTimerRef.current !== null) {
|
|
|
|
|
window.clearTimeout(batchTimerRef.current);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[],
|
|
|
|
|
);
|
2026-06-02 00:36:11 +08:00
|
|
|
|
|
|
|
|
const markFavorite = useCallback((resourceId: string, favorited: boolean) => {
|
2026-06-02 01:11:00 +08:00
|
|
|
setKnownIds((prev) => {
|
|
|
|
|
const next = new Set(prev).add(resourceId);
|
|
|
|
|
knownIdsRef.current = next;
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-06-02 00:36:11 +08:00
|
|
|
setFavoriteIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
if (favorited) next.add(resourceId);
|
|
|
|
|
else next.delete(resourceId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-02 01:11:00 +08:00
|
|
|
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;
|
2026-06-02 00:36:11 +08:00
|
|
|
setKnownIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
2026-06-02 01:11:00 +08:00
|
|
|
requestIds.forEach((id) => next.add(id));
|
|
|
|
|
knownIdsRef.current = next;
|
2026-06-02 00:36:11 +08:00
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
setFavoriteIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
ids.forEach((id) => next.add(id));
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-06-02 01:11:00 +08:00
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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);
|
2026-06-02 00:36:11 +08:00
|
|
|
},
|
2026-06-02 01:11:00 +08:00
|
|
|
[flushFavoriteIdBatch, status, token],
|
2026-06-02 00:36:11 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const runFavoriteMutation = useCallback(
|
|
|
|
|
async (resourceId: string) => {
|
2026-06-02 00:57:37 +08:00
|
|
|
if (!token) return;
|
2026-06-02 00:36:11 +08:00
|
|
|
const currentlyFavorite = favoriteIds.has(resourceId);
|
|
|
|
|
setPendingIds((prev) => new Set(prev).add(resourceId));
|
|
|
|
|
markFavorite(resourceId, !currentlyFavorite);
|
|
|
|
|
try {
|
2026-06-02 00:57:37 +08:00
|
|
|
if (currentlyFavorite) await removeFavorite(token, resourceId);
|
|
|
|
|
else await addFavorite(token, resourceId);
|
2026-06-02 00:36:11 +08:00
|
|
|
showToast(
|
|
|
|
|
currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"),
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
markFavorite(resourceId, currentlyFavorite);
|
|
|
|
|
showToast(t("favoriteFailed"), "error");
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
setPendingIds((prev) => {
|
|
|
|
|
const next = new Set(prev);
|
|
|
|
|
next.delete(resourceId);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-02 00:57:37 +08:00
|
|
|
[favoriteIds, markFavorite, showToast, t, token],
|
2026-06-02 00:36:11 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const toggleFavorite = useCallback(
|
|
|
|
|
async (resourceId: string) => {
|
2026-06-02 00:57:37 +08:00
|
|
|
if (!token || status !== "loggedIn") {
|
2026-06-02 00:36:11 +08:00
|
|
|
pendingAfterLoginRef.current = resourceId;
|
2026-06-02 00:57:37 +08:00
|
|
|
openLoginModal();
|
2026-06-02 00:36:11 +08:00
|
|
|
showToast(t("favoriteLoginRequired"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await runFavoriteMutation(resourceId);
|
|
|
|
|
},
|
2026-06-02 00:57:37 +08:00
|
|
|
[openLoginModal, runFavoriteMutation, showToast, status, t, token],
|
2026-06-02 00:36:11 +08:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-02 00:57:37 +08:00
|
|
|
if (status !== "loggedIn" || !token) return;
|
2026-06-02 00:36:11 +08:00
|
|
|
const pending = pendingAfterLoginRef.current;
|
|
|
|
|
if (!pending) return;
|
|
|
|
|
pendingAfterLoginRef.current = null;
|
|
|
|
|
void runFavoriteMutation(pending).catch(() => undefined);
|
2026-06-02 00:57:37 +08:00
|
|
|
}, [runFavoriteMutation, status, token]);
|
2026-06-02 00:36:11 +08:00
|
|
|
|
|
|
|
|
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<FavoritesContextValue>(
|
|
|
|
|
() => ({
|
|
|
|
|
favoriteIds,
|
|
|
|
|
pendingIds,
|
|
|
|
|
statusFor,
|
|
|
|
|
ensureFavoriteIds,
|
|
|
|
|
toggleFavorite,
|
|
|
|
|
markFavorite,
|
|
|
|
|
}),
|
|
|
|
|
[
|
|
|
|
|
ensureFavoriteIds,
|
|
|
|
|
favoriteIds,
|
|
|
|
|
markFavorite,
|
|
|
|
|
pendingIds,
|
|
|
|
|
statusFor,
|
|
|
|
|
toggleFavorite,
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<FavoritesContext.Provider value={value}>
|
|
|
|
|
{children}
|
|
|
|
|
</FavoritesContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useFavorites() {
|
|
|
|
|
const ctx = useContext(FavoritesContext);
|
|
|
|
|
if (!ctx)
|
|
|
|
|
throw new Error("useFavorites must be used within FavoritesProvider");
|
|
|
|
|
return ctx;
|
|
|
|
|
}
|