Files
gerbeur/src/contexts/social/FollowProvider.tsx
khannurien 308a8299f3 v4: every live update reaches every page that shows what it changed — the client kept each kind of event in one "latest" slot, so two arriving before a render lost the first (two notifications of one dump going public showed one); they now go through a subscription, useWSEvent, and each reaches every subscriber. A playlist made private sent its new title and description to everyone, and kept them on a visitor's page; playlists now follow the dumps' rule, broadcastChange: others get playlist_deleted, and playlist_created when it goes public again. A dump deleted or turned private sends its playlists' dumps again, so their counts and pages follow. Comments on a private dump reach its poster's other tabs. Reading the notifications clears the bell in every tab (notifications_read), and a category change makes every client fetch the categories again (categories_changed). The dump page gives way to a notice when its dump is deleted or made private. A profile gains the user's new dumps, the followed feed those of the users followed and the playlists followed, the search results follow edits and deletions, the index's category feed drops a dump edited out of it, and a renamed user's name follows on cards and comments. After a reconnection, every live list fetches the pages it holds again (usePagedList's resync), and the playlist, the profile, the dump, the categories and the follows are fetched again. The position memory of usePositionAwareSync is now inside the list sync hooks, so a returning item is put back without depending on the order the hooks are called.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:34:49 +00:00

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