162 lines
4.7 KiB
TypeScript
162 lines
4.7 KiB
TypeScript
/**
|
|
* 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<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [followedPlaylistIds, setFollowedPlaylistIds] = useState<Set<string>>(
|
|
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<typeof setTimeout> | 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<React.SetStateAction<Set<string>>>,
|
|
path: string,
|
|
id: string,
|
|
follow: boolean,
|
|
): Promise<boolean> => {
|
|
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 (
|
|
<FollowContext.Provider value={value}>
|
|
{children}
|
|
</FollowContext.Provider>
|
|
);
|
|
}
|