146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
/**
|
|
* 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<Set<string>>(new Set());
|
|
const prevMyVotesRef = useRef<Set<string> | 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<Wire<Dump>>)
|
|
.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 };
|
|
}
|