346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
/**
|
|
* Everything a playlist's page shows and changes goes through this hook. It
|
|
* loads the playlist with its dumps, keeps them live, and makes the changes its
|
|
* owner or a moderator makes there.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useNavigate } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { API_URL } from "../../config/api.ts";
|
|
import type { Wire } from "../../../shared/api.ts";
|
|
import type { Dump } from "../../../shared/dump.ts";
|
|
import type {
|
|
Playlist,
|
|
PlaylistWithDumps,
|
|
ReorderPlaylistRequest,
|
|
} from "../../../shared/playlist.ts";
|
|
import {
|
|
deserializeDump,
|
|
deserializePlaylistWithDumps,
|
|
} from "../../utils/shared/deserialize.ts";
|
|
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
|
|
import { isAt, playlistUrl, userApiUrl } from "../../utils/shared/urls.ts";
|
|
import { useCanonicalPath } from "../shared/useCanonicalPath.ts";
|
|
import { useAuth } from "../auth/useAuth.ts";
|
|
import { useOnReconnect, useWSEvent } from "../ws/useWS.ts";
|
|
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
|
|
import { type FadeState, useFading } from "../feed/useFading.ts";
|
|
import { useDumpListSync } from "../dump/useDumpListSync.ts";
|
|
|
|
/** Returned by {@link usePlaylist} to `PlaylistDetail`. */
|
|
export interface UsePlaylistResult {
|
|
state: FetchState<PlaylistWithDumps>;
|
|
/**
|
|
* It holds the dumps being removed, which stay listed while they fade. A
|
|
* dump in it can't be dragged, and during its `cooldown` its removal can be
|
|
* undone.
|
|
*/
|
|
fading: Record<string, FadeState>;
|
|
/** It says why the server refused a removal, a reorder or a delete. */
|
|
actionError: string | null;
|
|
/** Shows the playlist as `PlaylistEditForm` saved it. */
|
|
applyDetails: (playlist: Playlist) => void;
|
|
/**
|
|
* Fades the dump out, and takes it out of the playlist on the server when
|
|
* the fade ends, or when the page is left before.
|
|
*/
|
|
remove: (dumpId: string) => void;
|
|
/** Keeps a dump that `remove` is fading out. Nothing was sent yet. */
|
|
undoRemove: (dumpId: string) => void;
|
|
/**
|
|
* Moves the dump at `from` to `to`, counted among the dumps not fading, on
|
|
* screen only, as a drag goes along.
|
|
*/
|
|
move: (from: number, to: number) => void;
|
|
/** Sends the order `move` left, as a drag ends. */
|
|
saveOrder: () => void;
|
|
/** Leaves for home once the playlist is deleted, or sets `actionError`. */
|
|
deletePlaylist: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Returns `items` with those that `ids` names put in its order, in the slots
|
|
* those items held. The others, as the dumps fading out, keep their slots.
|
|
*/
|
|
function inOrder<T extends { id: string }>(items: T[], ids: string[]): T[] {
|
|
const byId = new Map(items.map((i) => [i.id, i]));
|
|
const ordered = ids.filter((id) => byId.has(id)).map((id) => byId.get(id)!);
|
|
const named = new Set(ordered.map((i) => i.id));
|
|
let next = 0;
|
|
return items.map((i) => named.has(i.id) ? ordered[next++] : i);
|
|
}
|
|
|
|
/** Returns `playlist` with the details of `updated`, keeping its dumps. */
|
|
function withDetails(
|
|
playlist: PlaylistWithDumps,
|
|
updated: Playlist,
|
|
): PlaylistWithDumps {
|
|
return {
|
|
...playlist,
|
|
...updated,
|
|
// Set only where the dumps were counted.
|
|
dumpCount: updated.dumpCount ?? playlist.dumpCount,
|
|
dumps: playlist.dumps,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the playlist at `/@username/playlists/slug` from
|
|
* `GET /api/users/@:username/playlists/:slug`, with the dumps the viewer may
|
|
* see in it. The socket's events keep its details and dumps live: a dump
|
|
* another client removes, or that turns private, fades out, one it adds, or
|
|
* that turns public, is fetched, a new order is applied, and a playlist
|
|
* deleted, or turned private by someone else, sends the page home. A
|
|
* reconnection loads it again. A new slug replaces the URL, keeping
|
|
* the page's `view`, and a path that names the playlist on screen, as that
|
|
* one does, fetches nothing.
|
|
*/
|
|
export function usePlaylist(
|
|
username: string | undefined,
|
|
slug: string | undefined,
|
|
view?: "edit",
|
|
): UsePlaylistResult {
|
|
const navigate = useNavigate();
|
|
const { user, authFetch } = useAuth();
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
|
|
// The path last fetched. The URL may move on to the same playlist's current
|
|
// path, which mustn't fetch it again.
|
|
const [target, setTarget] = useState({ username, slug });
|
|
|
|
// The server's last order of the dumps this viewer may see, which a refused
|
|
// reorder goes back to.
|
|
const serverOrderRef = useRef<string[]>([]);
|
|
const { state, setData, reload } = useFetchState(
|
|
target.username && target.slug &&
|
|
`${target.username}/${target.slug}:${user?.id ?? "guest"}`,
|
|
(signal) =>
|
|
authFetch(
|
|
// The key above is falsy, and nothing fetched, without both.
|
|
userApiUrl({ username: target.username! }, "playlists", target.slug!),
|
|
{ signal },
|
|
)
|
|
.then(expectOk<Wire<PlaylistWithDumps>>)
|
|
.then((wire) => {
|
|
const playlist = deserializePlaylistWithDumps(wire);
|
|
serverOrderRef.current = playlist.dumps.map((d) => d.id);
|
|
return playlist;
|
|
}),
|
|
);
|
|
const playlist = state.status === "loaded" ? state.data : null;
|
|
if (
|
|
(target.username !== username || target.slug !== slug) &&
|
|
!isAt(playlist, username, slug)
|
|
) {
|
|
setTarget({ username, slug });
|
|
}
|
|
useCanonicalPath(playlist && playlistUrl(playlist, view));
|
|
// The socket and the API's writes name playlists by id.
|
|
const id = playlist?.id;
|
|
const url = `${API_URL}/api/playlists/${id}`;
|
|
|
|
const setDumps = useCallback(
|
|
(fn: (dumps: Dump[]) => Dump[]) =>
|
|
setData((p) => ({ ...p, dumps: fn(p.dumps) })),
|
|
[setData],
|
|
);
|
|
|
|
// This viewer's removals whose fade hasn't ended, with the dump and its
|
|
// place, to put it back if the server refuses.
|
|
const pendingRef = useRef(new Map<string, { dump: Dump; index: number }>());
|
|
|
|
const sendRemoval = useCallback((dumpId: string) => {
|
|
const pending = pendingRef.current.get(dumpId);
|
|
if (!pending) return;
|
|
pendingRef.current.delete(dumpId);
|
|
// `keepalive`: it may be sent as the page is left.
|
|
authFetch(`${url}/dumps/${dumpId}`, { method: "DELETE", keepalive: true })
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
})
|
|
.catch(() => {
|
|
setDumps((dumps) => {
|
|
if (dumps.some((d) => d.id === dumpId)) return dumps;
|
|
const next = [...dumps];
|
|
next.splice(Math.min(pending.index, next.length), 0, pending.dump);
|
|
return next;
|
|
});
|
|
setActionError(t`The dump couldn't be removed. Try again.`);
|
|
});
|
|
}, [authFetch, url, setDumps]);
|
|
|
|
const { fading, startFading, cancelFading } = useFading((dumpId) => {
|
|
setDumps((dumps) => dumps.filter((d) => d.id !== dumpId));
|
|
sendRemoval(dumpId);
|
|
});
|
|
|
|
// For the effects, which must run on new events only, and the calls below,
|
|
// which must stay stable.
|
|
const latestRef = useRef({
|
|
playlist,
|
|
fading,
|
|
startFading,
|
|
cancelFading,
|
|
sendRemoval,
|
|
});
|
|
useLayoutEffect(() => {
|
|
latestRef.current = {
|
|
playlist,
|
|
fading,
|
|
startFading,
|
|
cancelFading,
|
|
sendRemoval,
|
|
};
|
|
});
|
|
|
|
useEffect(() => {
|
|
const flush = () =>
|
|
pendingRef.current.forEach((_, dumpId) =>
|
|
latestRef.current.sendRemoval(dumpId)
|
|
);
|
|
globalThis.addEventListener("pagehide", flush);
|
|
return () => {
|
|
globalThis.removeEventListener("pagehide", flush);
|
|
flush();
|
|
};
|
|
}, []);
|
|
|
|
// A new slug, or owner's name, reaches the URL through useCanonicalPath.
|
|
const applyDetails = useCallback((updated: Playlist) => {
|
|
setData((p) => withDetails(p, updated));
|
|
}, [setData]);
|
|
|
|
useWSEvent("playlist", (ev) => {
|
|
if (!id || ev.playlistId !== id) return;
|
|
if (ev.type === "updated" && ev.playlist) {
|
|
applyDetails(ev.playlist);
|
|
} else if (ev.type === "deleted") {
|
|
navigate("/");
|
|
} else if (ev.type === "dumps_updated" && ev.dumpIds) {
|
|
const ids = ev.dumpIds;
|
|
const { playlist, startFading, cancelFading } = latestRef.current;
|
|
serverOrderRef.current = ids;
|
|
const listed = new Set(ids);
|
|
const shown = new Set(playlist?.dumps.map((d) => d.id));
|
|
for (const dumpId of shown) {
|
|
if (listed.has(dumpId)) continue;
|
|
startFading(dumpId);
|
|
}
|
|
for (const dumpId of ids) {
|
|
// This viewer's own removal, which the server hasn't had yet.
|
|
if (pendingRef.current.has(dumpId)) continue;
|
|
if (shown.has(dumpId)) {
|
|
// Put back while it was fading out.
|
|
cancelFading(dumpId);
|
|
continue;
|
|
}
|
|
authFetch(`${API_URL}/api/dumps/${dumpId}`)
|
|
.then(expectOk<Wire<Dump>>)
|
|
.then((wire) => {
|
|
const dump = deserializeDump(wire);
|
|
setDumps((dumps) =>
|
|
dumps.some((d) => d.id === dump.id)
|
|
? dumps
|
|
: inOrder([...dumps, dump], serverOrderRef.current)
|
|
);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
setDumps((dumps) => inOrder(dumps, ids));
|
|
}
|
|
});
|
|
|
|
useWSEvent("user", ({ user }) => {
|
|
setData((p) =>
|
|
p.userId === user.id && p.ownerUsername !== user.username
|
|
? { ...p, ownerUsername: user.username }
|
|
: p
|
|
);
|
|
});
|
|
|
|
// A private dump is listed only to the playlist's owner, and only their own,
|
|
// which is the only private dump whose changes reach them. Which dumps come
|
|
// and go is the playlist's `dumps_updated`, above.
|
|
useDumpListSync(playlist?.dumps ?? [], setDumps, {
|
|
isOwner: !!user && user.id === playlist?.userId,
|
|
});
|
|
useOnReconnect(reload);
|
|
|
|
const remove = useCallback((dumpId: string) => {
|
|
const dumps = latestRef.current.playlist?.dumps ?? [];
|
|
const index = dumps.findIndex((d) => d.id === dumpId);
|
|
if (index === -1) return;
|
|
pendingRef.current.set(dumpId, { dump: dumps[index], index });
|
|
startFading(dumpId);
|
|
}, [startFading]);
|
|
|
|
const undoRemove = useCallback((dumpId: string) => {
|
|
pendingRef.current.delete(dumpId);
|
|
cancelFading(dumpId);
|
|
}, [cancelFading]);
|
|
|
|
const move = useCallback((from: number, to: number) => {
|
|
const { fading } = latestRef.current;
|
|
setDumps((dumps) => {
|
|
const ids = dumps.filter((d) => !(d.id in fading)).map((d) => d.id);
|
|
const [moved] = ids.splice(from, 1);
|
|
ids.splice(to, 0, moved);
|
|
return inOrder(dumps, ids);
|
|
});
|
|
}, [setDumps]);
|
|
|
|
const saveOrder = useCallback(() => {
|
|
// The server wants every dump it lists to this viewer, those whose
|
|
// removal isn't sent yet included.
|
|
const onServer = new Set(serverOrderRef.current);
|
|
const dumpIds = (latestRef.current.playlist?.dumps ?? [])
|
|
.map((d) => d.id)
|
|
.filter((dumpId) => onServer.has(dumpId));
|
|
authFetch(`${url}/order`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({ dumpIds } satisfies ReorderPlaylistRequest),
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
})
|
|
.catch(() => {
|
|
setDumps((dumps) => inOrder(dumps, serverOrderRef.current));
|
|
setActionError(t`The new order couldn't be saved. Try again.`);
|
|
});
|
|
}, [authFetch, url, setDumps]);
|
|
|
|
const deletePlaylist = useCallback(async () => {
|
|
setActionError(null);
|
|
try {
|
|
const res = await authFetch(url, { method: "DELETE" });
|
|
// expectOk throws the server's message.
|
|
if (!res.ok) await expectOk(res);
|
|
} catch (err) {
|
|
setActionError(friendlyFetchError(err));
|
|
return;
|
|
}
|
|
navigate("/");
|
|
}, [authFetch, url, navigate]);
|
|
|
|
return {
|
|
state,
|
|
fading,
|
|
actionError,
|
|
applyDetails,
|
|
remove,
|
|
undoRemove,
|
|
move,
|
|
saveOrder,
|
|
deletePlaylist,
|
|
};
|
|
}
|