/** * Keeps what the signed-in user follows for the Follow buttons, loaded from * `GET /api/me/follow` and again when another tab changes it. `App` mounts * it around every page. * * @module */ import { type ReactNode, useCallback, useEffect, useMemo, useState, } from "react"; import { FollowContext, type FollowContextValue } from "./FollowContext.ts"; import { API_URL } from "../../config/api.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useWS } from "../../hooks/ws/useWS.ts"; import type { FollowStatus } from "../../../shared/follow.ts"; /** Passed to {@link FollowProvider} by `App`. */ export interface FollowProviderProps { children: ReactNode; } // A failed fetch of the follows waits this long before it is tried again. const RETRY_MS = 10_000; /** * Provides {@link FollowContext}. It fetches the follows whenever the token * changes, again when `follows_changed` says another tab changed them, and * after the socket reconnects. It empties them on sign-out. */ export function FollowProvider( { children }: FollowProviderProps, ): React.JSX.Element { const { token, authFetch } = useAuth(); // A reconnection fetches them again too: a `follows_changed` may have been // missed while the socket was down. const { followsVersion, connectionEpoch } = useWS(); const [followedUserIds, setFollowedUserIds] = useState>( new Set(), ); const [followedPlaylistIds, setFollowedPlaylistIds] = useState>( new Set(), ); const [isLoaded, setIsLoaded] = useState(false); const [attempt, setAttempt] = useState(0); useEffect(() => { if (!token) { // The React hooks lint forbids a synchronous setState in an effect. The // microtask changes nothing the user can see. queueMicrotask(() => { setFollowedUserIds(new Set()); setFollowedPlaylistIds(new Set()); setIsLoaded(false); }); return; } const controller = new AbortController(); let retry: ReturnType | undefined; // Whatever went wrong, the buttons stay disabled rather than show empty // sets as the truth, until a later try succeeds. const fail = () => { if (controller.signal.aborted) return; retry = setTimeout(() => setAttempt((n) => n + 1), RETRY_MS); }; fetch(`${API_URL}/api/me/follow`, { headers: { Authorization: `Bearer ${token}` }, signal: controller.signal, }) .then((r) => r.json()) .then((body) => { if (!body.success) return fail(); const status = body.data as FollowStatus; setFollowedUserIds(new Set(status.followedUserIds)); setFollowedPlaylistIds(new Set(status.followedPlaylistIds)); setIsLoaded(true); }) .catch(fail); return () => { controller.abort(); clearTimeout(retry); }; }, [token, followsVersion, connectionEpoch, attempt]); /** * Adds or removes `id` in a set at once, sends the request, and undoes the * change if it fails. */ const toggle = useCallback(async ( setIds: React.Dispatch>>, path: string, id: string, follow: boolean, ): Promise => { const apply = (add: boolean) => setIds((prev) => { const next = new Set(prev); if (add) next.add(id); else next.delete(id); return next; }); apply(follow); try { const res = await authFetch(`${API_URL}/api/me/follow/${path}/${id}`, { method: follow ? "PUT" : "DELETE", }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return true; } catch { apply(!follow); return false; } }, [authFetch]); const followUser = useCallback( (userId: string) => toggle(setFollowedUserIds, "users", userId, true), [toggle], ); const unfollowUser = useCallback( (userId: string) => toggle(setFollowedUserIds, "users", userId, false), [toggle], ); const followPlaylist = useCallback( (playlistId: string) => toggle(setFollowedPlaylistIds, "playlists", playlistId, true), [toggle], ); const unfollowPlaylist = useCallback( (playlistId: string) => toggle(setFollowedPlaylistIds, "playlists", playlistId, false), [toggle], ); const value: FollowContextValue = useMemo(() => ({ followedUserIds, followedPlaylistIds, followUser, unfollowUser, followPlaylist, unfollowPlaylist, isLoaded, }), [ followedUserIds, followedPlaylistIds, followUser, unfollowUser, followPlaylist, unfollowPlaylist, isLoaded, ]); return ( {children} ); }