/** * Keeps a list of the dumps a user upvoted in step with their votes. * * @module */ import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { API_URL } from "../../config/api.ts"; import type { Dump } from "../../../shared/dump.ts"; import type { Wire } from "../../../shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts"; import { deserializeDump } from "../../utils/shared/deserialize.ts"; import { useAuth } from "../auth/useAuth.ts"; import { useWS } from "../ws/useWS.ts"; import { fadeClassName, useFading } from "../feed/useFading.ts"; /** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */ export interface UseUpvotedListOptions { /** * The list holds this user's upvotes. It is null while their profile loads. */ profileUserId: string | null; /** Does nothing until the list is loaded. */ setItems: (fn: (prev: Dump[]) => Dump[]) => void; /** * It holds dumps already at hand, which a vote of the signed-in user on * their own list puts first at once, before the socket's vote event. The * profile passes its first page of the user's dumps. */ source?: Dump[]; } /** Returned by {@link useUpvotedList}. */ export interface UseUpvotedListResult { /** It goes to `DumpCardList`'s `cardClassName`. */ cardClassName: (dump: Dump) => string | undefined; /** * Checks whether the vote on dump `id` was withdrawn since the list loaded. * The profile's `usePositionAwareSync` keeps such a dump from coming back. */ isWithdrawn: (id: string) => boolean; } /** * Follows the votes of `profileUserId` into their upvoted list. A dump they * upvote is fetched and put first. One whose vote they withdraw fades out * (`useFading`), then leaves the list, unless they vote again meanwhile. The * socket's vote events report every browser's votes; on one's own list, * `myVotes` also reports this tab's at once. */ export function useUpvotedList( { profileUserId, setItems, source }: UseUpvotedListOptions, ): UseUpvotedListResult { const { user: me, token } = useAuth(); const { myVotes, lastVoteEvent } = useWS(); const isOwn = !!profileUserId && me?.id === profileUserId; const withdrawnRef = useRef>(new Set()); const prevMyVotesRef = useRef | null>(null); // A ref, so that a new first page doesn't count as new votes. const sourceRef = useRef(source); useLayoutEffect(() => { sourceRef.current = source; }); const { fading, startFading, cancelFading, cancelAll } = useFading((id) => setItems((items) => items.filter((d) => d.id !== id)) ); const withdraw = useCallback((id: string) => { withdrawnRef.current.add(id); startFading(id); }, [startFading]); const recast = useCallback((id: string) => { withdrawnRef.current.delete(id); cancelFading(id); }, [cancelFading]); // Another user's list starts afresh. This runs before the effect below that // diffs the votes, so its baseline resets in time. useEffect(() => { withdrawnRef.current = new Set(); prevMyVotesRef.current = null; cancelAll(); }, [profileUserId, cancelAll]); useEffect(() => { if (!isOwn) return; const prev = prevMyVotesRef.current; prevMyVotesRef.current = new Set(myVotes); // This is only a baseline, and the list as loaded stands. myVotes may // still be the empty set from before the socket's welcome (or the socket // may never connect), and taking it as the truth here would hide every // dump. if (prev === null) return; for (const id of prev) if (!myVotes.has(id)) withdraw(id); const added = new Set([...myVotes].filter((id) => !prev.has(id))); for (const id of added) recast(id); const known = (sourceRef.current ?? []).filter((d) => added.has(d.id)); if (known.length === 0) return; setItems((items) => { const ids = new Set(items.map((d) => d.id)); const toAdd = known.filter((d) => !ids.has(d.id)); return toAdd.length === 0 ? items : [...toAdd, ...items]; }); }, [myVotes, isOwn, withdraw, recast, setItems]); useEffect(() => { if (!lastVoteEvent || !profileUserId) return; const { dumpId, voterId, action } = lastVoteEvent; if (voterId !== profileUserId) return; if (action === "remove") { withdraw(dumpId); return; } recast(dumpId); // Fetched signed in, or a private dump the viewer owns would be refused. fetch(`${API_URL}/api/dumps/${dumpId}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then(expectOk>) .then((wire) => { const dump = deserializeDump(wire); setItems((items) => // A withdrawn vote cast again before its dump left the list. items.some((d) => d.id === dumpId) ? items.map((d) => d.id === dumpId ? dump : d) : [dump, ...items] ); }) .catch(() => {}); }, [lastVoteEvent, profileUserId, token, withdraw, recast, setItems]); const cardClassName = useCallback( (dump: Dump) => fadeClassName(fading[dump.id]), [fading], ); const isWithdrawn = useCallback( (id: string) => withdrawnRef.current.has(id), [], ); return { cardClassName, isWithdrawn }; }