v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A dump's page.
|
||||
* The page of a dump, with its preview, comments, related dumps and the
|
||||
* playlists holding it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -46,17 +47,19 @@ import { Markdown } from "../components/editor/Markdown.tsx";
|
||||
import { CommentThread } from "../components/comment/CommentThread.tsx";
|
||||
import { Tooltip } from "../components/shared/Tooltip.tsx";
|
||||
|
||||
/** The page's state: loading, failed, or loaded with the dump. */
|
||||
type DumpState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; dump: Dump };
|
||||
|
||||
/**
|
||||
* Renders `/dumps/:selectedDump`, for anyone who may see the dump: its preview,
|
||||
* title (editable in place by its poster), note, poster, categories and votes,
|
||||
* the playlists holding it, related dumps and the comment thread, kept live by
|
||||
* the socket.
|
||||
* Renders `/dumps/:selectedDump`, for anyone who may see the dump: a private
|
||||
* dump answers 404 to anyone but its poster. The dump comes from
|
||||
* `GET /api/dumps/:dumpId`, or from the navigation's state when `DumpEdit` or
|
||||
* a title edit here just saved it. Its comments, related dumps and playlists
|
||||
* come from their own endpoints, and `WSContext`'s events keep them current.
|
||||
* The poster and moderators can edit the title in place, and get a link to
|
||||
* `DumpEdit`.
|
||||
*/
|
||||
export function Dump(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
@@ -146,8 +149,7 @@ export function Dump(): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
// Subscribing to the WS-pushed dump-update stream — setState here mirrors
|
||||
// external state into local state, no synchronous alternative.
|
||||
// Mirrors the socket's edits, which only an effect can observe.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDumpState((prev) => {
|
||||
if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) {
|
||||
@@ -157,7 +159,6 @@ export function Dump(): React.JSX.Element {
|
||||
});
|
||||
}, [lastDumpEvent]);
|
||||
|
||||
// Fetch comments when dump loads
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
@@ -172,11 +173,11 @@ export function Dump(): React.JSX.Element {
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
// connectionEpoch: re-fetch on reconnect to backfill comments missed while
|
||||
// the socket was down.
|
||||
// connectionEpoch refetches on reconnect, for the comments missed while the
|
||||
// socket was down.
|
||||
}, [selectedDump, authFetch, connectionEpoch]);
|
||||
|
||||
// Fetch related dumps (backlinks) when dump loads
|
||||
// The dumps that link to this one, or that it links to.
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
@@ -193,8 +194,7 @@ export function Dump(): React.JSX.Element {
|
||||
return () => controller.abort();
|
||||
}, [selectedDump, authFetch]);
|
||||
|
||||
// Fetch the collections this dump appears in (public ones, plus the
|
||||
// viewer's own private ones — the server does the filtering)
|
||||
// The server leaves out the private playlists of others.
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
const controller = new AbortController();
|
||||
@@ -211,12 +211,12 @@ export function Dump(): React.JSX.Element {
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => controller.abort();
|
||||
// lastPlaylistEvent: adding/removing this dump, flipping a collection's
|
||||
// visibility or deleting it all change the list — playlist events are rare
|
||||
// enough to just refetch on any of them.
|
||||
// Any playlist event may change the list, and they are rare enough to
|
||||
// refetch on each.
|
||||
}, [selectedDump, authFetch, lastPlaylistEvent]);
|
||||
|
||||
// Scroll to and highlight a comment when navigating to #comment-{id}
|
||||
// A notification links to `#comment-<id>`. Rerun when the comments arrive,
|
||||
// since the element doesn't exist before.
|
||||
useEffect(() => {
|
||||
if (!location.hash.startsWith("#comment-")) return;
|
||||
const id = location.hash.slice(1);
|
||||
@@ -231,15 +231,14 @@ export function Dump(): React.JSX.Element {
|
||||
return () => clearTimeout(timer);
|
||||
}, [comments, location.hash]);
|
||||
|
||||
// React to WS comment events
|
||||
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
|
||||
useEffect(() => {
|
||||
if (
|
||||
!lastCommentEvent || !loadedDumpId ||
|
||||
lastCommentEvent.dumpId !== loadedDumpId
|
||||
) return;
|
||||
// Subscribing to the WS-pushed comment-event stream — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// A comment of this user's arrives both from the socket and from
|
||||
// CommentThread's callbacks below, hence the duplicate checks.
|
||||
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setComments((prev) => {
|
||||
@@ -338,8 +337,8 @@ export function Dump(): React.JSX.Element {
|
||||
}
|
||||
const updatedDump = deserializeDump(apiResponse.data);
|
||||
setTitleEditing(false);
|
||||
// The page stays mounted across the slug change below, so it has to be
|
||||
// told about the new title itself.
|
||||
// The page stays mounted across the slug change below, and would keep
|
||||
// the old title.
|
||||
setDumpState({ status: "loaded", dump: updatedDump });
|
||||
if (updatedDump.slug !== dump.slug) {
|
||||
navigate(dumpUrl(updatedDump), {
|
||||
@@ -357,7 +356,6 @@ export function Dump(): React.JSX.Element {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="dump-detail">
|
||||
{/* Post header */}
|
||||
<div className="dump-post-header">
|
||||
<div className="dump-header-block">
|
||||
<div className="dump-header-left">
|
||||
@@ -506,7 +504,6 @@ export function Dump(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="dump-rich-content">
|
||||
{dump.kind === "file"
|
||||
? <FilePreview dump={dump} global />
|
||||
@@ -532,7 +529,6 @@ export function Dump(): React.JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="dump-actions">
|
||||
{canEdit && (
|
||||
<Link to={`${dumpUrl(dump)}/edit`}>
|
||||
@@ -544,7 +540,6 @@ export function Dump(): React.JSX.Element {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Related (backlinks) */}
|
||||
{relatedDumps.length > 0 && (
|
||||
<section className="related-section">
|
||||
<h2 className="related-section-title">
|
||||
@@ -566,7 +561,6 @@ export function Dump(): React.JSX.Element {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* In collections (playlists containing this dump) */}
|
||||
{collections.length > 0 && (
|
||||
<section className="related-section collections-section">
|
||||
<h2 className="related-section-title">
|
||||
@@ -580,7 +574,6 @@ export function Dump(): React.JSX.Element {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Comments */}
|
||||
<CommentThread
|
||||
dumpId={dump.id}
|
||||
comments={comments}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The dump editor.
|
||||
* The page where a dump's poster, or a moderator, edits or deletes it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -44,16 +44,18 @@ import { TextField } from "../components/form/TextField.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
import { VisibilityToggle } from "../components/form/SegmentedField.tsx";
|
||||
|
||||
/** The page's state: loading, failed, or loaded with the dump. */
|
||||
type DumpEditState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; dump: Dump };
|
||||
|
||||
/**
|
||||
* Renders `/dumps/:selectedDump/edit`, for a signed-in user: the dump's title,
|
||||
* link or file, note, visibility and thumbnail to edit, its preview to refresh,
|
||||
* and a delete button.
|
||||
* Renders `/dumps/:selectedDump/edit`, for a signed-in user. The page itself
|
||||
* doesn't check that the user may edit the dump: `Dump` links here only for
|
||||
* its poster and moderators, and the API refuses anyone else's changes. The
|
||||
* dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh
|
||||
* and the deletion apply at once, and the form's other fields on "Save", which
|
||||
* goes back to the dump's page. A deletion goes to the index.
|
||||
*/
|
||||
export function DumpEdit(): React.JSX.Element {
|
||||
const { selectedDump } = useParams();
|
||||
@@ -171,6 +173,8 @@ export function DumpEdit(): React.JSX.Element {
|
||||
setDeleteError(friendlyFetchError(err));
|
||||
return;
|
||||
}
|
||||
// Meant for Index, to hide the dump before `dump_deleted` arrives, but the
|
||||
// redirect from `/` doesn't pass the state on.
|
||||
navigate("/", { state: { deletedDumpId: state.dump.id } });
|
||||
};
|
||||
|
||||
@@ -275,6 +279,8 @@ export function DumpEdit(): React.JSX.Element {
|
||||
onThumbnailChange={handleThumbnailChange}
|
||||
onThumbnailReset={handleThumbnailReset}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
// Passed along so the dump's page shows the saved dump without
|
||||
// refetching it.
|
||||
onSaved={(updated) =>
|
||||
navigate(dumpUrl(updated), { state: { dump: updated } })}
|
||||
/>
|
||||
@@ -291,7 +297,8 @@ export function DumpEdit(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link DumpEditForm}. */
|
||||
// The thumbnail and deletion are handled by DumpEdit, which applies them at
|
||||
// once; the form only reports them.
|
||||
interface DumpEditFormProps {
|
||||
dump: Dump;
|
||||
preview: React.ReactNode;
|
||||
@@ -305,7 +312,6 @@ interface DumpEditFormProps {
|
||||
onSaved: (dump: Dump) => void;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface EditValues {
|
||||
title: string;
|
||||
url: string;
|
||||
@@ -314,7 +320,11 @@ interface EditValues {
|
||||
newFile: File | null;
|
||||
}
|
||||
|
||||
/** Renders the dump's edit form. */
|
||||
/**
|
||||
* Renders the edit form of `dump`, which saves through
|
||||
* `PUT /api/dumps/:dumpId`, or `PUT /api/dumps/:dumpId/file` when a new file
|
||||
* is attached. `DumpEdit` keys it by dump, so the fields reset for another.
|
||||
*/
|
||||
function DumpEditForm({
|
||||
dump,
|
||||
preview,
|
||||
@@ -348,7 +358,7 @@ function DumpEditForm({
|
||||
},
|
||||
});
|
||||
|
||||
// Metadata-extracted title for URL dumps; the source for "Reset to default".
|
||||
// The title scraped from the link, which "Reset to default" puts back.
|
||||
const metaTitle = dump.richContent?.title?.trim() ?? "";
|
||||
const currentTitle = form.watch("title");
|
||||
|
||||
@@ -404,8 +414,8 @@ function DumpEditForm({
|
||||
<FormProvider {...form}>
|
||||
<form className="form dump-edit-form" onSubmit={onSubmit}>
|
||||
{
|
||||
/* Left: the dumped thing itself — what it looks like and where it
|
||||
comes from. Right: how it's presented and shared. */
|
||||
/* The source panel holds what was dumped, the details panel how it
|
||||
is presented and who sees it. */
|
||||
}
|
||||
<div className="dump-edit-panel dump-edit-panel--source">
|
||||
{preview}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The feed page.
|
||||
* Renders the home page, which shows every dump, or one category's, in the
|
||||
* "hot", "new", "journal" and "followed" tabs.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -45,7 +46,6 @@ import { JournalFeed } from "../components/feed/JournalFeed.tsx";
|
||||
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
|
||||
import { NotFound } from "./NotFound.tsx";
|
||||
|
||||
/** The feed's state: loading, failed, or loaded with a page of dumps. */
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -57,19 +57,23 @@ type DumpsState =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** The props of {@link Index}. */
|
||||
/**
|
||||
* Passed to {@link Index} by the router, from the `:categorySlug` of the URL.
|
||||
*/
|
||||
export interface IndexProps {
|
||||
/** The category the feed is scoped to; every dump when undefined. */
|
||||
/** Without it, the feed holds every dump. An unknown slug renders 404. */
|
||||
categorySlug?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone: the shared
|
||||
* feed page, its tabs (hot, new, journal, followed for a signed-in user), who's
|
||||
* online, and new dumps as they arrive. `categorySlug` scopes it to a single
|
||||
* category; when absent it shows every dump. The router keys this component by
|
||||
* scope, so each category gets its own mount/fetch while switching feed tabs
|
||||
* reuses it.
|
||||
* Renders `/~/:feedTab` and `/:categorySlug/:feedTab`, for anyone, the
|
||||
* "followed" tab for a signed-in user only. The feed comes from
|
||||
* `GET /api/dumps/`, or `GET /api/categories/:slug/dumps`, a page at a time,
|
||||
* and the "hot", "new" and "journal" tabs share it. `WSContext` adds the dumps
|
||||
* posted since, the live vote counts, and who's online. The router keys the
|
||||
* page by category, so switching tabs keeps the feed and switching categories
|
||||
* refetches it. It also opens the dump creation modal on a link shared from
|
||||
* Android (`?share_url=`).
|
||||
*/
|
||||
export function Index(
|
||||
{ categorySlug }: IndexProps,
|
||||
@@ -89,7 +93,6 @@ export function Index(
|
||||
connectionEpoch,
|
||||
} = useWS();
|
||||
|
||||
// Resolve the scope to a real category (for its id, name, and 404 handling).
|
||||
const { categories, isLoaded: categoriesLoaded } = useCategories();
|
||||
const category = categorySlug
|
||||
? categories.find((c) => c.slug === categorySlug)
|
||||
@@ -100,23 +103,19 @@ export function Index(
|
||||
categorySlug ? (category ? category.name : undefined) : null,
|
||||
);
|
||||
|
||||
// ── Active feed tab (from the URL) ──
|
||||
|
||||
const { feedTab: rawTab } = useParams();
|
||||
const [preferredTab] = useDefaultFeedTab();
|
||||
const defaultTab: FeedTab = preferredTab === "followed" && !user
|
||||
? DEFAULT_FEED_TAB
|
||||
: preferredTab;
|
||||
// "followed" needs a session: a guest there (a shared link, or signed out
|
||||
// on it) gets the default feed rather than an empty page.
|
||||
// A guest on "followed", from a shared link or after signing out, gets the
|
||||
// default tab rather than an empty page.
|
||||
const tab: FeedTab =
|
||||
(FEED_TABS as readonly string[]).includes(rawTab ?? "") &&
|
||||
(rawTab !== "followed" || !!user)
|
||||
? (rawTab as FeedTab)
|
||||
: defaultTab;
|
||||
|
||||
// ── Main feed state ──
|
||||
|
||||
const feedUrl = useCallback(
|
||||
(page: number) =>
|
||||
categorySlug
|
||||
@@ -144,7 +143,9 @@ export function Index(
|
||||
);
|
||||
const mainFetchDone = useRef(false);
|
||||
|
||||
// Web Share Target: Android share sheet navigates to /?share_url=...
|
||||
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which
|
||||
// the redirect from `/` keeps. The query is dropped from the URL at once, so
|
||||
// a reload doesn't share the link a second time.
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const shareUrl = searchParams.get("share_url") ??
|
||||
searchParams.get("share_text") ?? "";
|
||||
@@ -154,8 +155,6 @@ export function Index(
|
||||
globalThis.history.replaceState({}, "", location.pathname);
|
||||
}, [shareUrl, location.pathname]);
|
||||
|
||||
// ── Main feed fetch ──
|
||||
|
||||
useEffect(() => {
|
||||
if (mainFetchDone.current || cached) return;
|
||||
mainFetchDone.current = true;
|
||||
@@ -187,8 +186,6 @@ export function Index(
|
||||
};
|
||||
}, [cached, token, feedUrl]);
|
||||
|
||||
// ── WS sync for main feed ──
|
||||
|
||||
const setDumpsItems = useCallback(
|
||||
(fn: (prev: Dump[]) => Dump[]) =>
|
||||
setDumpsState((s) =>
|
||||
@@ -198,11 +195,9 @@ export function Index(
|
||||
);
|
||||
useDumpListSync(setDumpsItems);
|
||||
|
||||
// ── Reconnect backfill ──
|
||||
|
||||
// New dumps that arrived while the socket was down weren't delivered. On
|
||||
// reconnect, re-fetch page 1 and merge anything not already present on top,
|
||||
// preserving loaded pages and scroll position.
|
||||
// Dumps posted while the socket was down never arrived. On reconnect, the
|
||||
// first page is fetched again and whatever is new goes on top, keeping the
|
||||
// pages loaded and the scroll position.
|
||||
const backfillEpochRef = useRef(connectionEpoch);
|
||||
useEffect(() => {
|
||||
if (connectionEpoch === backfillEpochRef.current) return;
|
||||
@@ -228,8 +223,6 @@ export function Index(
|
||||
return () => controller.abort();
|
||||
}, [connectionEpoch, feedUrl, token]);
|
||||
|
||||
// ── Load more ──
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (
|
||||
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||
@@ -264,8 +257,6 @@ export function Index(
|
||||
);
|
||||
}, [dumpsState, token, feedUrl]);
|
||||
|
||||
// ── Scroll save / restore ──
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
loadMore,
|
||||
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||
@@ -289,8 +280,6 @@ export function Index(
|
||||
}
|
||||
}, [dumpsState.status, cached]);
|
||||
|
||||
// ── Derived values ──
|
||||
|
||||
const loading = dumpsState.status === "loading";
|
||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||
const dumps = useMemo(
|
||||
@@ -300,7 +289,7 @@ export function Index(
|
||||
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
||||
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
|
||||
|
||||
// New dumps arriving over the WS only belong in this feed if they're in scope.
|
||||
// A category's feed takes only the new dumps filed under it.
|
||||
const categoryId = category?.id;
|
||||
const inScope = useCallback(
|
||||
(d: Dump) =>
|
||||
@@ -311,6 +300,8 @@ export function Index(
|
||||
);
|
||||
|
||||
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||
// Dumps deleted since loading are dropped. `justDeletedId` never matches,
|
||||
// though: DumpEdit navigates to `/`, whose redirect drops the state.
|
||||
const combined = useMemo(
|
||||
() =>
|
||||
[...recentDumps.filter((d) => inScope(d) && !restIds.has(d.id)), ...dumps]
|
||||
@@ -332,8 +323,6 @@ export function Index(
|
||||
removeVote,
|
||||
};
|
||||
|
||||
// ── Render ──
|
||||
|
||||
if (categoryNotFound) return <NotFound />;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The page for a route that doesn't exist.
|
||||
* Renders the 404 page, for any path `App`'s route table doesn't match and for
|
||||
* a category slug `Index` doesn't know.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -10,7 +11,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
|
||||
|
||||
/** Renders any unknown route: a 404 with a link home. */
|
||||
/** Renders the 404 page, with a link home. Anyone may see it. */
|
||||
export function NotFound(): React.JSX.Element {
|
||||
useDocumentTitle(t`Page not found`);
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* The notifications page.
|
||||
* Renders the notifications page, `/notifications`, for a signed-in user. It
|
||||
* reads them from `GET /api/notifications` and adds the ones `WSContext`
|
||||
* receives while it is open.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -33,12 +35,9 @@ import { deserializeNotification } from "../utils/shared/deserialize.ts";
|
||||
import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
|
||||
|
||||
/**
|
||||
* The page's state: loading, failed, or loaded with a page of notifications.
|
||||
*/
|
||||
type State =
|
||||
// `arrived`: sent over the socket before the list came back — the list may
|
||||
// predate them, and the read-all that follows the load covers them anyway.
|
||||
// `arrived` holds what the socket sent before the list came back, which the
|
||||
// list may predate.
|
||||
| { status: "loading"; arrived: Notification[] }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
@@ -49,7 +48,6 @@ type State =
|
||||
loadingMore: boolean;
|
||||
};
|
||||
|
||||
/** The icon a notification shows. */
|
||||
type NotifIconKind =
|
||||
| "upvote"
|
||||
| "follow"
|
||||
@@ -59,7 +57,6 @@ type NotifIconKind =
|
||||
| "comment"
|
||||
| "like";
|
||||
|
||||
/** Returns the icon for a notification of `type`. */
|
||||
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
switch (type) {
|
||||
case "dump_upvoted":
|
||||
@@ -81,28 +78,24 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders the upvote glyph. */
|
||||
const UpvoteSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
|
||||
<polygon points="5,1 9,9 1,9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the follow glyph. */
|
||||
const FollowSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
||||
<polygon points="2,1 9,5 2,9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the like glyph. */
|
||||
const HeartSvg = () => (
|
||||
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
||||
<path d="M5 9 C2 6.5 0 5 0 2.8 C0 1.2 1.2 0 2.6 0 C3.6 0 4.5 0.6 5 1.5 C5.5 0.6 6.4 0 7.4 0 C8.8 0 10 1.2 10 2.8 C10 5 8 6.5 5 9 Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Renders the icon of a notification of `type`. */
|
||||
function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
const kind = notifIconKind(type);
|
||||
const glyphs: Record<NotifIconKind, React.ReactNode> = {
|
||||
@@ -121,7 +114,10 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns where notification `n` leads. */
|
||||
/**
|
||||
* Returns where notification `n` leads. A notification about a comment links
|
||||
* to its `#comment-<id>` anchor on the dump's page.
|
||||
*/
|
||||
function notificationLink(n: Notification): string {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
@@ -149,14 +145,14 @@ function notificationLink(n: Notification): string {
|
||||
return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||
}
|
||||
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||
// Search only: the chat opens over whatever page you're on.
|
||||
// A query string alone keeps this page, and `ChatProvider` opens the
|
||||
// chat over it.
|
||||
if (d.contextType === "chat") return `?chat=open`;
|
||||
return `/playlists/${d.contextId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the sentence notification `n` shows. */
|
||||
function notificationContent(n: Notification): React.ReactNode {
|
||||
const data = n.data as NotificationData;
|
||||
switch (n.type) {
|
||||
@@ -248,7 +244,10 @@ function notificationContent(n: Notification): React.ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns how long ago `date` was within a week, else its day. */
|
||||
/**
|
||||
* Returns how long ago `date` was when it was within a week, else its day and
|
||||
* month.
|
||||
*/
|
||||
function timeAgo(date: Date): string {
|
||||
const abs = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
if (abs < 7 * 86400) return relativeTime(date);
|
||||
@@ -260,7 +259,10 @@ function startOfDay(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
/** Returns `items` grouped under Today, Yesterday and Earlier. */
|
||||
/**
|
||||
* Returns `items` grouped under Today, Yesterday and Earlier, in that order,
|
||||
* without the empty groups. The labels are keys, which the page translates.
|
||||
*/
|
||||
function groupByDate(
|
||||
items: Notification[],
|
||||
): { label: string; items: Notification[] }[] {
|
||||
@@ -284,9 +286,9 @@ function groupByDate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/notifications`, for a signed-in user: their notifications by day,
|
||||
* new ones as they arrive, loading more on demand. Opening the page marks them
|
||||
* all read.
|
||||
* Renders `/notifications`, for a signed-in user. Opening the page marks every
|
||||
* notification read on the server, and so does each that arrives while it is
|
||||
* open. The unread ones stay highlighted until the page is left.
|
||||
*/
|
||||
export function Notifications(): React.JSX.Element {
|
||||
useDocumentTitle(t`Notifications`);
|
||||
@@ -326,20 +328,18 @@ export function Notifications(): React.JSX.Element {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Marking them read is a separate matter: if it fails, the list that
|
||||
// loaded is still worth showing, and they stay unread for next time.
|
||||
// If this fails, the list is still worth showing, and they stay unread
|
||||
// for next time.
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (cancelled || !res.ok) return;
|
||||
// Only the bell clears. The list keeps showing which ones were new
|
||||
// when the page opened: flipping them to read here, a moment after
|
||||
// they appear, would take the dots and the "new" count away before
|
||||
// anyone could see them.
|
||||
// Only the bell clears. Marking the list read too would take its dots
|
||||
// and "new" count away a moment after they appear.
|
||||
clearUnreadNotifications();
|
||||
} catch {
|
||||
// Offline: see above.
|
||||
// Offline: they stay unread for next time.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
@@ -347,6 +347,8 @@ export function Notifications(): React.JSX.Element {
|
||||
};
|
||||
}, [authFetch, clearUnreadNotifications]);
|
||||
|
||||
// Each new `lastNotification` is added once, during render rather than in an
|
||||
// effect, so it shows in the same frame.
|
||||
const [prevLastNotification, setPrevLastNotification] = useState(
|
||||
lastNotification,
|
||||
);
|
||||
@@ -363,7 +365,7 @@ export function Notifications(): React.JSX.Element {
|
||||
}
|
||||
|
||||
// One that arrives while the page is open is being seen: it stays
|
||||
// highlighted here, but the bell shouldn't count it.
|
||||
// highlighted here, but the bell must not count it.
|
||||
const loaded = state.status === "loaded";
|
||||
useEffect(() => {
|
||||
if (!lastNotification || !loaded) return;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A playlist's page.
|
||||
* Renders a playlist's page, where its owner and moderators also edit it and
|
||||
* reorder its dumps.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -53,7 +54,6 @@ import { FormError } from "../components/form/FormError.tsx";
|
||||
import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The page's state: loading, failed, or loaded with the playlist. */
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -61,9 +61,11 @@ type LoadState =
|
||||
|
||||
/**
|
||||
* Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see
|
||||
* the playlist: its cover, title, description and owner, a follow button, and
|
||||
* its dumps, kept live by the socket. Its owner or a moderator can edit or
|
||||
* delete it, drag its dumps into a new order, and remove one (with an undo).
|
||||
* the playlist: a private one only to its owner. The playlist and the dumps
|
||||
* the viewer may see in it come from `GET /api/playlists/:playlistId`, and the
|
||||
* socket keeps them live. Its owner, or a user with `playlist:moderate`, can
|
||||
* edit or delete it, drag its dumps into a new order, and remove one, with a
|
||||
* short window to undo.
|
||||
*/
|
||||
export function PlaylistDetail(): React.JSX.Element {
|
||||
const { playlistId } = useParams<{ playlistId: string }>();
|
||||
@@ -80,7 +82,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
// Stable UUID for WS comparisons — avoids re-running effects on every state change
|
||||
// The socket names playlists by id, while the URL may hold the slug.
|
||||
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||
|
||||
useDocumentTitle(
|
||||
@@ -91,43 +93,42 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
|
||||
// The dumps in the playlist. `state.playlist.dumps` also keeps the removed
|
||||
// ones until their fade ends.
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list
|
||||
// A removed dump shows faded for 2 seconds, while it can be undone, then
|
||||
// slides out ("dismissing"), as on the upvoted page.
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
// While an undo-remove is in flight (POST re-add + PUT reorder), holds the
|
||||
// desired dump order so intermediate WS dumps_updated events don't cause glitches.
|
||||
// An undo re-adds the dump, which the server puts first, then restores its
|
||||
// place. This holds that place's order until both requests are done, so the
|
||||
// socket's echo of the first doesn't flash the dump at the top.
|
||||
const pendingUndoOrderRef = useRef<string[] | null>(null);
|
||||
// Debounce timer for the reorder setState in dumps_updated so that rapid
|
||||
// consecutive events (POST re-add followed immediately by PUT reorder) are
|
||||
// coalesced — only the final order is applied, preventing the glitch on
|
||||
// other clients who don't have pendingUndoOrderRef.
|
||||
// Other clients see the same two echoes without that order, so a new order
|
||||
// from the socket is applied only once none follows for 80 ms.
|
||||
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
|
||||
// without stale closure issues (state would only update on next render).
|
||||
// A ref, since dragover fires faster than state renders.
|
||||
const dragSrcRef = useRef<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// A delete, removal or reorder the server refused.
|
||||
// A delete or a removal the server refused. A refused reorder just reloads.
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
||||
// Updated on every render via useLayoutEffect so it's always current.
|
||||
// For the socket effects, which must not re-run when it changes.
|
||||
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||
// knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
|
||||
// Every dump seen in the playlist, so that one hidden when it turned private
|
||||
// comes back when it turns public.
|
||||
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||
// Authoritative dump order from the server (fetchPlaylist + dumps_updated events).
|
||||
// Used to re-insert dumps at their correct position after private→public transitions.
|
||||
// The server's last order, where such a dump comes back.
|
||||
const dumpOrderRef = useRef<string[]>([]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -146,9 +147,8 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
fetchAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
fetchAbortRef.current = controller;
|
||||
// Deferred to a microtask so callers invoking this directly from an
|
||||
// effect body don't trip the synchronous-setState-in-effect check —
|
||||
// same timing for the user, just not in the same tick.
|
||||
// In a microtask, since the linter forbids a synchronous setState in the
|
||||
// effect that calls this. The user sees no difference.
|
||||
queueMicrotask(() => setState({ status: "loading" }));
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
signal: controller.signal,
|
||||
@@ -195,10 +195,10 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
return () => fetchAbortRef.current?.abort();
|
||||
}, [fetchPlaylist]);
|
||||
|
||||
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
||||
// After the sequence completes, the dump is removed from state.playlist.dumps.
|
||||
// Fades out dump `id`, then drops it from `state.playlist.dumps`. Its entry
|
||||
// in `cancels` stops the fade at whichever step it is.
|
||||
function startFade(id: string) {
|
||||
if (cancels.current.has(id)) return; // already fading
|
||||
if (cancels.current.has(id)) return;
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
|
||||
@@ -220,7 +220,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
const t2 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
// Remove from the playlist.dumps array now that animation is done
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
@@ -260,21 +259,17 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
|
||||
// WS: playlist metadata updated or deleted
|
||||
// The socket's changes to this playlist: its dumps, its details, its end.
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
// Compare against the resolved UUID, not the URL param (which may be a slug)
|
||||
if (ev.playlistId !== playlistUUID) return;
|
||||
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const newIds = new Set(ev.dumpIds);
|
||||
for (const id of newIds) knownDumpIdsRef.current.add(id);
|
||||
// Use the ref so we always diff against the current activeDumpIds,
|
||||
// including changes from deletedDumpIds / lastDumpEvent effects.
|
||||
const prev = activeDumpIdsRef.current;
|
||||
|
||||
// Removed: were active, not in new set → fade out
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
setActiveDumpIds((s) => {
|
||||
@@ -286,14 +281,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Newly added IDs: cancel any fade, mark active, fetch dump data individually.
|
||||
// We never call fetchPlaylist here — that would reset state to "loading", cycle
|
||||
// playlistUUID, and re-trigger this effect in a loop.
|
||||
// An added dump is fetched alone. fetchPlaylist would go back through
|
||||
// "loading", which unsets playlistUUID and runs this effect again.
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
cancels.current.get(id)?.();
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
// Capture ev.dumpIds so we can insert the new dump at its correct position.
|
||||
const orderedIds = ev.dumpIds!;
|
||||
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
@@ -305,7 +298,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||
// Insert at the correct server-ordered position.
|
||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||
dumpMap.set(dump.id, dump);
|
||||
return {
|
||||
@@ -326,11 +318,7 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce the reorder setState so rapid consecutive dumps_updated events
|
||||
// (e.g. POST re-add followed immediately by PUT reorder during an undo)
|
||||
// coalesce into a single update — only the final order is applied.
|
||||
// On the owner's client, pendingUndoOrderRef also suppresses the wrong
|
||||
// intermediate order; this debounce protects other clients on the WS.
|
||||
// Debounced: see dumpReorderTimerRef.
|
||||
if (dumpReorderTimerRef.current) {
|
||||
clearTimeout(dumpReorderTimerRef.current);
|
||||
}
|
||||
@@ -345,12 +333,11 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
.filter((id) => dumpMap.has(id))
|
||||
.map((id) => dumpMap.get(id)!);
|
||||
let ai = 0;
|
||||
// Replace each active slot with the next server-ordered active dump;
|
||||
// fading dumps keep their current slot unchanged.
|
||||
// The fading dumps keep their slots, the others take the new order,
|
||||
// and dumps whose fetch landed late go last.
|
||||
const merged = s.playlist.dumps.map((d) =>
|
||||
newIds.has(d.id) ? orderedActive[ai++] : d
|
||||
);
|
||||
// Append any newly added dumps not yet in the array.
|
||||
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
|
||||
return { ...s, playlist: { ...s.playlist, dumps: merged } };
|
||||
});
|
||||
@@ -375,11 +362,10 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
|
||||
|
||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||
// A deleted dump leaves at once, without the removal's fade.
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
// Subscribing to the WS-pushed deleted-dump-ids set — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// It mirrors the socket's state, which only an effect can do.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
@@ -396,7 +382,8 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
// Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public)
|
||||
// An edited dump changes in place, and one of the playlist's dumps that
|
||||
// turned public again comes back at its place in the server's order.
|
||||
useEffect(() => {
|
||||
if (!lastDumpEvent) return;
|
||||
const dump = lastDumpEvent;
|
||||
@@ -404,13 +391,10 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||
if (idx !== -1) {
|
||||
// Update in-place
|
||||
const dumps = [...prev.playlist.dumps];
|
||||
dumps[idx] = dump;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||
}
|
||||
// Re-add if this dump belongs to the playlist and is now public,
|
||||
// inserting at its correct server-ordered position.
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
const order = dumpOrderRef.current;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
@@ -427,7 +411,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
// Restore to activeDumpIds if re-added
|
||||
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||
setActiveDumpIds((prev) => {
|
||||
if (prev.has(dump.id)) return prev;
|
||||
@@ -444,16 +427,13 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
e.preventDefault();
|
||||
const src = dragSrcRef.current;
|
||||
if (src === null || src === index) return;
|
||||
// Only swap once the pointer has crossed the card's midpoint.
|
||||
// Without this, entering a card immediately re-triggers the swap in the
|
||||
// opposite direction (the two items keep bouncing back and forth).
|
||||
// Swapping as soon as the pointer enters a card would swap it straight
|
||||
// back, so the pointer must cross the card's middle first.
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet
|
||||
if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet
|
||||
// Update visual order in state. Use activeDumpIdsRef so the updater never
|
||||
// reads a stale closure — activeDumpIds can't change mid-drag but this is
|
||||
// the correct pattern for setState updaters.
|
||||
if (src < index && e.clientY < mid) return;
|
||||
if (src > index && e.clientY > mid) return;
|
||||
// The drag indices count only the dumps not fading, which go last.
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const ids = activeDumpIdsRef.current;
|
||||
@@ -467,7 +447,6 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
// Update the ref and highlight index outside the updater (no side effects inside updaters).
|
||||
dragSrcRef.current = index;
|
||||
setDragOverIndex(index);
|
||||
};
|
||||
@@ -502,13 +481,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId) return;
|
||||
// Fire-and-forget the API call; animate immediately
|
||||
// Removed on the server at once; the undo adds it back.
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "DELETE",
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
}).catch(() => {
|
||||
// Refused or offline: cancel the fade and restore the item
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
setActionError(t`The dump couldn't be removed. Try again.`);
|
||||
@@ -525,17 +503,14 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// Capture the desired order now (dump is still in playlist.dumps at its
|
||||
// original position; activeDumpIds hasn't been updated yet in this closure).
|
||||
// A fading dump keeps its slot in `state.playlist.dumps`, so that order is
|
||||
// the one to restore.
|
||||
const restoredIds = new Set([...activeDumpIds, dumpId]);
|
||||
const desiredOrder = state.playlist.dumps
|
||||
.filter((d) => restoredIds.has(d.id))
|
||||
.map((d) => d.id);
|
||||
// Hold the desired order so the WS handler ignores the intermediate
|
||||
// dumps_updated event from the POST (which puts the dump at the top).
|
||||
pendingUndoOrderRef.current = desiredOrder;
|
||||
// Re-add server-side since DELETE already fired, then immediately restore
|
||||
// the original position (addDumpToPlaylist would otherwise put it at top).
|
||||
// The server adds the dump first, so the order is restored after.
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
})
|
||||
@@ -585,7 +560,8 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) await expectOk(res); // throws the server's message
|
||||
// expectOk throws the server's message.
|
||||
if (!res.ok) await expectOk(res);
|
||||
} catch (err) {
|
||||
setActionError(friendlyFetchError(err));
|
||||
return;
|
||||
@@ -622,10 +598,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
|
||||
const { playlist } = state;
|
||||
const isOwner = !!user && user.id === playlist.userId;
|
||||
// Owners edit their own playlists; moderators may edit any (playlist:moderate).
|
||||
const canEdit = isOwner || can(user, "playlist:moderate");
|
||||
|
||||
// Active dumps in playlist order; fading dumps appended so they stay visible
|
||||
// The fading dumps stay on screen, but can't be dragged.
|
||||
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
|
||||
const visibleDumps = playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id) || d.id in fading
|
||||
@@ -814,15 +789,16 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link PlaylistEditForm}. */
|
||||
/** Passed to {@link PlaylistEditForm} by {@link PlaylistDetail}. */
|
||||
interface PlaylistEditFormProps {
|
||||
playlist: PlaylistWithDumps;
|
||||
onCancel: () => void;
|
||||
/** Opens the page's confirmation, which deletes the playlist. */
|
||||
onRequestDelete: () => void;
|
||||
/** Called with the playlist as the server now has it. */
|
||||
onSaved: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
/** The form's fields. */
|
||||
interface EditValues {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -831,7 +807,10 @@ interface EditValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the playlist's edit form: title, description, visibility and cover.
|
||||
* Renders the form that edits the playlist, in place of the page's header. It
|
||||
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
|
||||
* title and description that changed, then a new cover, if one was picked,
|
||||
* with `POST /api/playlists/:playlistId/image`.
|
||||
*/
|
||||
function PlaylistEditForm(
|
||||
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The password reset page.
|
||||
* Renders the page a password reset email links to, where the user sets a new
|
||||
* password.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -20,15 +21,14 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { TextField } from "../components/form/TextField.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The form's fields. */
|
||||
interface Values {
|
||||
newPassword: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/reset-password?token=`, for anyone holding a reset link: the form
|
||||
* that sets a new password.
|
||||
* Renders `/reset-password?token=`, for anyone holding a reset link. It doesn't
|
||||
* sign the user in: a success links to `/login`.
|
||||
*/
|
||||
export function ResetPassword(): React.JSX.Element {
|
||||
useDocumentTitle(t`Reset password`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The guard of the routes for the signed out.
|
||||
* Guards `App`'s routes that only make sense signed out, `/login` and
|
||||
* `/register`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,14 +9,13 @@ import { Navigate } from "react-router";
|
||||
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
|
||||
/** The props of {@link RestrictedGuest}. */
|
||||
/** Passed to {@link RestrictedGuest} by `App`, around a route's page. */
|
||||
export interface RestrictedGuestProps {
|
||||
/** The page. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `children` when no one is signed in, and redirects home otherwise.
|
||||
* Renders `children` when no one is signed in, and redirects to `/` otherwise.
|
||||
*/
|
||||
export function RestrictedGuest(
|
||||
{ children }: RestrictedGuestProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The guard of the routes for the signed in.
|
||||
* Guards `App`'s routes that need a session, the dump editor and
|
||||
* `/notifications`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,15 +9,15 @@ import { Navigate } from "react-router";
|
||||
|
||||
import { useAuth } from "../hooks/auth/useAuth.ts";
|
||||
|
||||
/** The props of {@link RestrictedLoggedIn}. */
|
||||
/** Passed to {@link RestrictedLoggedIn} by `App`, around a route's page. */
|
||||
export interface RestrictedLoggedInProps {
|
||||
/** The page. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `children` when someone is signed in, and redirects to the login page
|
||||
* otherwise.
|
||||
* Renders `children` when someone is signed in, and redirects to `/login`
|
||||
* otherwise. The page it came from is not remembered, so signing in lands on
|
||||
* `/`.
|
||||
*/
|
||||
export function RestrictedLoggedIn(
|
||||
{ children }: RestrictedLoggedInProps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The search results page.
|
||||
* The page that shows the results of a search, in a tab for dumps, one for
|
||||
* users and one for playlists.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -30,12 +31,10 @@ import type { Wire } from "../../shared/api.ts";
|
||||
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts";
|
||||
import { friendlyFetchError } from "../utils/shared/api.ts";
|
||||
|
||||
/** The result tabs. */
|
||||
const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
|
||||
/** One of the {@link SEARCH_TABS}. */
|
||||
type Tab = (typeof SEARCH_TABS)[number];
|
||||
|
||||
/** The page's state: idle, loading, failed, or loaded with the results. */
|
||||
// Only the dumps are paged: the API answers at most 20 users and playlists.
|
||||
type SearchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
@@ -55,8 +54,10 @@ type SearchState =
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders `/search?q=` and `/search/~/:searchTab`, for anyone: the dumps (a
|
||||
* page at a time), users and playlists matching the query, one tab each.
|
||||
* Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come
|
||||
* from `GET /api/search`, signed in when the viewer is, so they find their own
|
||||
* private dumps and playlists too. The dumps load a page at a time as the
|
||||
* "dumps" tab scrolls. A new `q` aborts the search under way.
|
||||
*/
|
||||
export function Search(): React.JSX.Element {
|
||||
useDocumentTitle(t`Search`);
|
||||
@@ -71,9 +72,8 @@ export function Search(): React.JSX.Element {
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const fetchSearch = useCallback(async (query: string, page: number) => {
|
||||
// Deferred to a microtask so callers invoking this directly from an
|
||||
// effect body don't trip the synchronous-setState-in-effect check —
|
||||
// same timing for the user, just not in the same tick.
|
||||
// Deferred: the set-state-in-effect lint rule forbids setting state
|
||||
// synchronously in the effect that calls this.
|
||||
if (!query.trim()) {
|
||||
queueMicrotask(() => setState({ status: "idle" }));
|
||||
return;
|
||||
@@ -163,9 +163,7 @@ export function Search(): React.JSX.Element {
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
// fetchSearch is async; calling it directly is flagged as a synchronous
|
||||
// setState regardless of its own internal deferral, so defer the call
|
||||
// itself by a microtask too — same timing for the user.
|
||||
// The lint rule flags the call itself, whatever fetchSearch defers inside.
|
||||
queueMicrotask(() => fetchSearch(q, 1));
|
||||
return () => abortRef.current?.abort();
|
||||
}, [q, fetchSearch]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A user's full list of dumps.
|
||||
* The page listing all of a user's dumps, which the "dumps" section of their
|
||||
* profile links to.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -22,8 +23,10 @@ import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
|
||||
/**
|
||||
* Renders `/users/:username/dumps`, for anyone: every dump of the user the
|
||||
* viewer may see, a page at a time, kept live by the socket.
|
||||
* Renders `/users/:username/dumps`, for anyone. The dumps come from
|
||||
* `useUserDumpFeed`, the private ones for their owner only, and the socket's
|
||||
* events keep them current. On one's own list, a button opens the dump
|
||||
* creation modal.
|
||||
*/
|
||||
export function UserDumps(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The sign-in page.
|
||||
* Renders the sign-in page, which also requests password reset links.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -24,20 +24,18 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { TextField } from "../components/form/TextField.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The sign-in form's fields. */
|
||||
interface LoginValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** The reset request form's fields. */
|
||||
interface ResetValues {
|
||||
email: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/login`, for the signed out: the sign-in form, and a way to ask for
|
||||
* a password reset link.
|
||||
* Renders `/login`, for the signed out. A sign-in lands on `/`, and "Forgot
|
||||
* password?" unfolds the reset request form below.
|
||||
*/
|
||||
export function UserLogin(): React.JSX.Element {
|
||||
useDocumentTitle(t`Log in`);
|
||||
@@ -111,16 +109,16 @@ export function UserLogin(): React.JSX.Element {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the form that emails a password reset link. The answer is the same
|
||||
* whether or not the address is known.
|
||||
* Renders the form that asks the server to email a password reset link, which
|
||||
* leads to `/reset-password`.
|
||||
*/
|
||||
function ResetRequestPanel() {
|
||||
const [sent, setSent] = useState(false);
|
||||
const form = useApiForm<ResetValues>({ defaultValues: { email: "" } });
|
||||
|
||||
const onSubmit = form.submit(async ({ email }) => {
|
||||
// The answer is the same whether or not the address is known; only a
|
||||
// server that failed to take the request is worth reporting.
|
||||
// The server answers 200 whether or not the address is known, so as not
|
||||
// to reveal which ones are. An error here means it failed.
|
||||
await expectOk(
|
||||
await fetch(`${API_URL}/api/users/request-password-reset`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A user's full lists of playlists.
|
||||
* Renders the page that lists all of a user's playlists, the ones they made and
|
||||
* the ones they follow.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -41,7 +42,7 @@ import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
|
||||
|
||||
/** A paginated list of playlists. */
|
||||
/** One of the page's two lists, as far as it is loaded. */
|
||||
interface PlaylistFeed {
|
||||
items: Playlist[];
|
||||
hasMore: boolean;
|
||||
@@ -49,7 +50,6 @@ interface PlaylistFeed {
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
/** The page's state: loading, failed, or loaded with both lists. */
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -60,15 +60,17 @@ type State =
|
||||
followed: PlaylistFeed;
|
||||
};
|
||||
|
||||
/** Returns the first page of a list. */
|
||||
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
||||
return { items, hasMore, page: 1, loadingMore: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/users/:username/playlists`, for anyone: the playlists the user made
|
||||
* (the private ones only to them) and those they follow, a page at a time, kept
|
||||
* live by the socket; the owner can make and delete playlists.
|
||||
* Renders `/users/:username/playlists`, for anyone. It lists the playlists the
|
||||
* user made (the private ones only to them) and the public ones they follow,
|
||||
* from `GET /api/users/:username/playlists` and `…/followed-playlists`, a page
|
||||
* at a time, and the socket keeps both lists live. The user can make and
|
||||
* delete playlists here. Going back to the page restores both lists and the
|
||||
* scroll position from sessionStorage.
|
||||
*/
|
||||
export function UserPlaylists(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
@@ -112,6 +114,8 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||
? (lastPlaylistEvent.playlist ?? null)
|
||||
: null;
|
||||
// Before usePlaylistListSync: it saves a playlist's place before the sync
|
||||
// takes it out, and puts it back there when it turns public again.
|
||||
usePositionAwareSync(
|
||||
createdItems,
|
||||
setCreated,
|
||||
@@ -142,6 +146,8 @@ export function UserPlaylists(): React.JSX.Element {
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
// Back on the page, the lists come from the cache and only the user is
|
||||
// fetched again.
|
||||
if (cachedCreated && cachedFollowed) {
|
||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||
.then((r) => r.json())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A user's profile.
|
||||
* Renders a user's profile page, which is also where the signed-in user edits
|
||||
* their own profile and settings.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -77,10 +78,9 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/**
|
||||
* Renders the own-profile action row: "Invite someone" then `children` (log
|
||||
* out). The invite button toggles a panel below the row holding a fresh
|
||||
* single-use link; the panel eases in like the inline editors and takes the
|
||||
* row's width.
|
||||
* Renders the "Invite someone" button, then `children`, the log out button.
|
||||
* Each opening of the panel creates a new single-use invite link, for
|
||||
* `/register`.
|
||||
*/
|
||||
function ProfileOwnActions({ children }: { children: React.ReactNode }) {
|
||||
const { authFetch } = useAuth();
|
||||
@@ -124,7 +124,8 @@ function ProfileOwnActions({ children }: { children: React.ReactNode }) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard denied (e.g. insecure context): the field is selectable.
|
||||
// The clipboard is denied outside a secure context. The link can still
|
||||
// be selected by hand.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +180,11 @@ function ProfileOwnActions({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A paginated list. */
|
||||
/**
|
||||
* A list of the profile, of which `page` pages are loaded. The profile holds
|
||||
* the first page only, since each has its own page for the rest, but the
|
||||
* followed users.
|
||||
*/
|
||||
interface PaginatedList<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
@@ -187,12 +192,14 @@ interface PaginatedList<T> {
|
||||
loadingMore: boolean;
|
||||
}
|
||||
|
||||
/** Returns the first page of a list. */
|
||||
function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
|
||||
return { items, hasMore, page: 1, loadingMore: false };
|
||||
}
|
||||
|
||||
/** The profile's state: loading, failed, or loaded with its lists. */
|
||||
/**
|
||||
* The profile's user and its first page of dumps, upvoted dumps and playlists,
|
||||
* fetched together.
|
||||
*/
|
||||
type ProfileState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -204,7 +211,7 @@ type ProfileState =
|
||||
playlists: PaginatedList<Playlist>;
|
||||
};
|
||||
|
||||
/** The followed tab's state, null until it is opened. */
|
||||
/** Null until the "followed" tab is first opened, which fetches it. */
|
||||
type FollowedState =
|
||||
| null
|
||||
| { status: "loading" }
|
||||
@@ -215,16 +222,18 @@ type FollowedState =
|
||||
playlists: PaginatedList<Playlist>;
|
||||
};
|
||||
|
||||
/** The invitees tab's state, null until it is opened. */
|
||||
/** Null until the "invitees" tab is first opened, which fetches it. */
|
||||
type InviteTreeState =
|
||||
| null
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; entries: Invitee[] };
|
||||
|
||||
/** An invitee, with those they invited. */
|
||||
type InviteTreeNode = Invitee & { children: InviteTreeNode[] };
|
||||
/** The profile's tabs. */
|
||||
/**
|
||||
* The profile's tabs, the `:profileTab` of `/users/:username/~/:profileTab`.
|
||||
* "dumps" is the default, at `/users/:username`.
|
||||
*/
|
||||
const PROFILE_TABS = [
|
||||
"dumps",
|
||||
"playlists",
|
||||
@@ -233,10 +242,12 @@ const PROFILE_TABS = [
|
||||
"settings",
|
||||
"manage",
|
||||
] as const;
|
||||
/** One of the {@link PROFILE_TABS}. */
|
||||
type ProfileTab = (typeof PROFILE_TABS)[number];
|
||||
|
||||
/** Returns the invitees of `parentId` among `entries`, each with theirs. */
|
||||
/**
|
||||
* Returns the invitees of `parentId` among `entries`, each with theirs. The
|
||||
* server answers the whole tree as a flat list.
|
||||
*/
|
||||
function buildInviteTree(
|
||||
entries: Invitee[],
|
||||
parentId: string,
|
||||
@@ -247,13 +258,13 @@ function buildInviteTree(
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone:
|
||||
* the user's avatar, name, role and description, and their dumps, playlists,
|
||||
* follows and invitees, one tab each. On their own profile a user can change
|
||||
* their avatar, description and email, invite someone, and set the theme,
|
||||
* sounds, default feed tab and password. The manage tab changes another user's
|
||||
* role, with `user:manage`, or on one's own profile manages the categories,
|
||||
* with `category:manage`.
|
||||
* Renders `/users/:username` and `/users/:username/~/:profileTab`, for anyone.
|
||||
* Its tabs show the user's dumps and upvotes, playlists, follows and invitees,
|
||||
* and the lists follow the socket's events. On their own profile, users edit
|
||||
* their avatar, description and email, create invites, and get a "settings"
|
||||
* tab for their password and this browser's preferences. The "manage" tab
|
||||
* shows another user's role to those who hold `user:manage`, and one's own
|
||||
* categories to those who hold `category:manage`.
|
||||
*/
|
||||
export function UserPublicProfile(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
@@ -301,20 +312,17 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
// Active tab is driven by the `/~/<tab>` URL path (linkable / refresh-safe).
|
||||
// `settings` is own-profile only; `manage` requires the user:manage permission
|
||||
// on someone else's profile. Invalid tabs fall back to "dumps".
|
||||
const canManageUsers = can(me, "user:manage");
|
||||
const canManageCategories = can(me, "category:manage");
|
||||
// The "manage" tab is an admin/moderation area. On someone else's profile it
|
||||
// holds per-user tools (role management); on your own it holds global tools
|
||||
// (category management, with more to come).
|
||||
// "manage" holds tools for another user on their profile, and site-wide
|
||||
// tools on one's own.
|
||||
const showManageTab = isOwnProfile ? canManageCategories : canManageUsers;
|
||||
const availableTabs: ProfileTab[] = PROFILE_TABS.filter((t) => {
|
||||
if (t === "settings") return isOwnProfile;
|
||||
if (t === "manage") return showManageTab;
|
||||
return true;
|
||||
});
|
||||
// A tab not available here, as while the profile loads, shows "dumps".
|
||||
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
|
||||
|
||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
@@ -338,8 +346,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
||||
// a future dump_updated event (that would only be for private→public transitions).
|
||||
// A withdrawn vote leaves its dump in the list, for UpvotedDumpList to fade
|
||||
// out. If the dump then turns private and public again, it must not return.
|
||||
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||
@@ -383,12 +391,10 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
skipReinsert: true,
|
||||
});
|
||||
|
||||
// Update profile user when they edit their own profile
|
||||
useEffect(() => {
|
||||
if (!lastUserEvent) return;
|
||||
const { user } = lastUserEvent;
|
||||
// Subscribing to the WS-pushed user-update stream — setState here
|
||||
// mirrors external state into local state, no synchronous alternative.
|
||||
// Mirrors a socket event into state, which can only happen in an effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.user.id !== user.id) return s;
|
||||
@@ -423,8 +429,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
setInviteTreeState(null);
|
||||
}
|
||||
|
||||
// Refs can't be written during render — reset in an effect instead, which
|
||||
// still runs (and clears the ref) before the vote-diffing effect below.
|
||||
// A ref can't be written during render. This effect runs before the one
|
||||
// below that diffs the votes, so it still resets in time.
|
||||
useEffect(() => {
|
||||
prevMyVotesRef.current = null;
|
||||
}, [username]);
|
||||
@@ -436,7 +442,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
||||
|
||||
if (allCached) {
|
||||
// Only fetch the user object (lightweight, always fresh)
|
||||
// The lists come from this tab's session cache, as on Back. The user is
|
||||
// always fetched afresh.
|
||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
@@ -548,7 +555,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
return () => controller.abort();
|
||||
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
|
||||
|
||||
// Own profile: prepend dumps newly voted by the user to the preview list
|
||||
// On one's own profile, a vote on one's own dump shows in the upvoted list
|
||||
// at once, from the optimistic `myVotes`, before the socket's vote event.
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
@@ -571,15 +579,15 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
|
||||
// Real-time upvoted list sync via WS vote events
|
||||
// Votes by the profile's user, from any browser, update the upvoted list.
|
||||
useEffect(() => {
|
||||
if (!lastVoteEvent || !profileUserId) return;
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
|
||||
if (action === "remove") {
|
||||
// Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
|
||||
// its own votedIds + fading state and will animate the removal.
|
||||
// The dump stays in the list: UpvotedDumpList fades it out, then hides
|
||||
// it.
|
||||
withdrawnVoteIdsRef.current.add(dumpId);
|
||||
} else {
|
||||
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||
@@ -592,12 +600,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
if (s.status !== "loaded") return s;
|
||||
const idx = s.votes.items.findIndex((d) => d.id === dumpId);
|
||||
if (idx !== -1) {
|
||||
// Ghost re-voted: update in-place.
|
||||
// A withdrawn vote cast again, before its dump was reloaded.
|
||||
const items = [...s.votes.items];
|
||||
items[idx] = dump;
|
||||
return { ...s, votes: { ...s.votes, items } };
|
||||
}
|
||||
// First-time vote: prepend.
|
||||
return {
|
||||
...s,
|
||||
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
||||
@@ -608,7 +615,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
}
|
||||
}, [lastVoteEvent, profileUserId]);
|
||||
|
||||
// Save scroll position + loaded state to sessionStorage on scroll
|
||||
// Saves the lists and the scroll position, so that Back restores both.
|
||||
useEffect(() => {
|
||||
if (state.status !== "loaded") return;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -634,8 +641,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
};
|
||||
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||
|
||||
// Keep the playlists cache current whenever the list changes (e.g. via WS),
|
||||
// so a page refresh restores the up-to-date list rather than a stale snapshot.
|
||||
// The playlists also change from the socket without a scroll, and the cache
|
||||
// must not restore a stale list.
|
||||
const playlistFeed = state.status === "loaded" ? state.playlists : null;
|
||||
useEffect(() => {
|
||||
if (!playlistFeed) return;
|
||||
@@ -647,13 +654,13 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
);
|
||||
}, [playlistFeed, savePlaylists]);
|
||||
|
||||
// Lazy-load followed users + playlists when the Followed tab is first opened
|
||||
useEffect(() => {
|
||||
if (
|
||||
tab !== "followed" || followedState !== null || state.status !== "loaded"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Deferred, since the lint forbids a setState in an effect's body.
|
||||
queueMicrotask(() => setFollowedState({ status: "loading" }));
|
||||
const controller = new AbortController();
|
||||
Promise.all([
|
||||
@@ -691,12 +698,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
setFollowedState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// followedState intentionally omitted: it's a read-once guard, not a trigger.
|
||||
// Including it would abort the in-flight fetch when state changes to "loading".
|
||||
// followedState only guards the first fetch. As a dependency, its change
|
||||
// to "loading" would abort that fetch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
// Lazy-load invite tree when the Invitees tab is first opened
|
||||
useEffect(() => {
|
||||
if (
|
||||
tab !== "invitees" || inviteTreeState !== null ||
|
||||
@@ -722,12 +728,11 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
setInviteTreeState({ status: "error", error: friendlyFetchError(err) });
|
||||
});
|
||||
return () => controller.abort();
|
||||
// inviteTreeState intentionally omitted — same read-once guard pattern.
|
||||
// inviteTreeState only guards the first fetch, as followedState above.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, state.status, username]);
|
||||
|
||||
// There's no page of its own for who someone follows, so the list grows
|
||||
// here.
|
||||
// The followed users have no page of their own, so the list grows here.
|
||||
const loadMoreFollowedUsers = () => {
|
||||
if (
|
||||
followedState?.status !== "loaded" || followedState.users.loadingMore
|
||||
@@ -755,7 +760,7 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
|
||||
};
|
||||
|
||||
// Restore scroll position after cache restoration
|
||||
// Before paint, so the restored page doesn't flash at the top.
|
||||
const scrollRestored = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
||||
@@ -972,8 +977,8 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
onClick={isOwnProfile
|
||||
? () => setDescEditing(true)
|
||||
: undefined}
|
||||
// Reachable from the keyboard too; not a button, so the
|
||||
// markdown inside keeps its own structure.
|
||||
// Focusable rather than a button, so the Markdown inside
|
||||
// keeps its own structure and links.
|
||||
tabIndex={isOwnProfile ? 0 : undefined}
|
||||
title={isOwnProfile ? t`Edit description` : undefined}
|
||||
onKeyDown={isOwnProfile
|
||||
@@ -1451,9 +1456,10 @@ export function UserPublicProfile(): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plain dump list ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Renders a list of dump cards. */
|
||||
/**
|
||||
* Renders the first page of the user's dumps, with a link to all of them. The
|
||||
* owner also gets a "New dump" button.
|
||||
*/
|
||||
function DumpList(
|
||||
{
|
||||
title,
|
||||
@@ -1528,9 +1534,10 @@ function DumpList(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Upvoted list: fades items out when votes are removed ─────────────────────
|
||||
|
||||
/** Renders the upvoted list, fading items out when their votes are removed. */
|
||||
/**
|
||||
* Renders the first page of the dumps the user upvoted. A dump whose vote is
|
||||
* withdrawn fades out, and comes back if the vote is cast again meanwhile.
|
||||
*/
|
||||
function UpvotedDumpList(
|
||||
{
|
||||
title,
|
||||
@@ -1559,26 +1566,22 @@ function UpvotedDumpList(
|
||||
const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
|
||||
const { fading, startFading, cancelFading } = useFading();
|
||||
|
||||
// votedIds is managed locally so setVotedIds + startFading/cancelFading can
|
||||
// be called in the same effect body — guaranteeing a single render where the
|
||||
// dump is always in visibleDumps (with or without fading class). This prevents
|
||||
// the DOM node from being unmounted/remounted, which would break CSS transitions.
|
||||
// Held here rather than derived, so that a dump leaves `votedIds` and starts
|
||||
// fading in the same render. In between, the card would unmount and its
|
||||
// transition break.
|
||||
const [votedIds, setVotedIds] = useState(() =>
|
||||
new Set(dumps.map((d) => d.id))
|
||||
);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
|
||||
// setVotedIds and startFading/cancelFading must be called together synchronously
|
||||
// in the same effect to guarantee a single render where the DOM node isn't
|
||||
// unmounted — converting to render-phase isn't possible because startFading/
|
||||
// cancelFading are themselves setState calls that can't run during render.
|
||||
// On one's own profile, votes follow `myVotes`, which changes at once on a
|
||||
// vote here. This must be an effect, since startFading and cancelFading set
|
||||
// state too.
|
||||
useEffect(() => {
|
||||
if (!profileUserId || !isOwnProfile) return;
|
||||
if (prevMyVotesRef.current === null) {
|
||||
// Only a baseline: the list as loaded stands. myVotes may still be the
|
||||
// empty set from before the socket's welcome, and taking it as the
|
||||
// truth would hide every dump.
|
||||
// Only a baseline. myVotes may still be empty, from before the socket's
|
||||
// welcome, and would hide every dump.
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
return;
|
||||
}
|
||||
@@ -1594,16 +1597,13 @@ function UpvotedDumpList(
|
||||
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
|
||||
|
||||
// Non-own profile: sync votedIds with WS vote events for the profile user.
|
||||
// Same constraint as above: setVotedIds and startFading/cancelFading must
|
||||
// fire together so the DOM node stays mounted throughout the transition.
|
||||
// On another's profile, votes follow the socket's vote events.
|
||||
useEffect(() => {
|
||||
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
if (action === "remove") {
|
||||
// setVotedIds and startFading must fire together to avoid a render with
|
||||
// stale votedIds between the two updates.
|
||||
// Both updates must land in one render, as above.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
@@ -1666,9 +1666,7 @@ function UpvotedDumpList(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Invite tree ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Renders the invite tree. */
|
||||
/** Renders the invite tree, each invitee nested under who invited them. */
|
||||
function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
|
||||
return (
|
||||
<ul className="invite-tree">
|
||||
@@ -1693,9 +1691,7 @@ function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Followed user card ───────────────────────────────────────────────────────
|
||||
|
||||
/** Renders a followed user. */
|
||||
/** Renders a followed user, with a button to follow or unfollow them. */
|
||||
function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
return (
|
||||
<li className="followed-user-card">
|
||||
@@ -1717,7 +1713,6 @@ function FollowedUserCard({ user }: { user: PublicUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The roles, with their labels. */
|
||||
const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
|
||||
{ value: "user", label: <Trans>User</Trans> },
|
||||
{ value: "moderator", label: <Trans>Moderator</Trans> },
|
||||
@@ -1725,8 +1720,8 @@ const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Renders the role badge shown next to the username. The default "user" role is
|
||||
* implicit, so only elevated roles get a chip to avoid labelling every profile.
|
||||
* Renders the role badge next to the username, for moderators and admins
|
||||
* only.
|
||||
*/
|
||||
function RoleChip({ role }: { role: Role }) {
|
||||
if (role === "user") return null;
|
||||
@@ -1738,7 +1733,6 @@ function RoleChip({ role }: { role: Role }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link RoleSwitcher}. */
|
||||
interface RoleSwitcherProps {
|
||||
userId: string;
|
||||
currentRole: Role;
|
||||
@@ -1746,8 +1740,8 @@ interface RoleSwitcherProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the segmented role control (matches the dump-visibility toggle
|
||||
* style), gated behind the user:manage permission by its caller.
|
||||
* Renders the control that sets another user's role, which its caller shows
|
||||
* only to those who hold `user:manage`. The server checks it again.
|
||||
*/
|
||||
function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
|
||||
const { authFetch } = useAuth();
|
||||
@@ -1801,14 +1795,16 @@ function RoleSwitcher({ userId, currentRole, onSaved }: RoleSwitcherProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link EmailEditor}. */
|
||||
interface EmailEditorProps {
|
||||
initialEmail: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (email: string) => void;
|
||||
}
|
||||
|
||||
/** Renders the inline editor of the user's email address. */
|
||||
/**
|
||||
* Renders the inline editor of the signed-in user's email, which only they
|
||||
* see.
|
||||
*/
|
||||
function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
|
||||
const { authFetch } = useAuth();
|
||||
const form = useApiForm<{ email: string }>({
|
||||
@@ -1866,14 +1862,16 @@ function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The props of {@link DescriptionEditor}. */
|
||||
interface DescriptionEditorProps {
|
||||
initialDescription: string;
|
||||
onCancel: () => void;
|
||||
onSaved: (description: string | undefined) => void;
|
||||
}
|
||||
|
||||
/** Renders the inline editor of the user's description. */
|
||||
/**
|
||||
* Renders the inline editor of the signed-in user's description, in Markdown.
|
||||
* Other browsers see the change through the socket's `user_updated` event.
|
||||
*/
|
||||
function DescriptionEditor(
|
||||
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
|
||||
) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The sign-up page.
|
||||
* Renders the sign-up page, which only works from an invite link. Members copy
|
||||
* those links from their own profile page.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -25,13 +26,11 @@ import { SubmitButton } from "../components/form/SubmitButton.tsx";
|
||||
import { TextField } from "../components/form/TextField.tsx";
|
||||
import { useApiForm } from "../hooks/form/useApiForm.ts";
|
||||
|
||||
/** The invite's state: being checked, invalid, or valid. */
|
||||
type TokenState =
|
||||
| { status: "checking" }
|
||||
| { status: "invalid" }
|
||||
| { status: "valid" };
|
||||
|
||||
/** The form's fields. */
|
||||
interface Values {
|
||||
username: string;
|
||||
email: string;
|
||||
@@ -39,8 +38,8 @@ interface Values {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `/register?token=`, for the signed out holding an invite: the invite
|
||||
* is checked, then the sign-up form, which signs the new user in.
|
||||
* Renders `/register?token=`, for the signed out. It checks the invite before
|
||||
* showing the form, and signs the new user in on success.
|
||||
*/
|
||||
export function UserRegister(): React.JSX.Element {
|
||||
useDocumentTitle(t`Register`);
|
||||
@@ -58,6 +57,8 @@ export function UserRegister(): React.JSX.Element {
|
||||
defaultValues: { username: "", email: "", password: "" },
|
||||
});
|
||||
|
||||
// Reset during render, so the form never shows for a new token while the
|
||||
// previous one's result is still in state.
|
||||
if (prevToken !== token) {
|
||||
setPrevToken(token);
|
||||
setTokenState(token ? { status: "checking" } : { status: "invalid" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* A user's full list of upvoted dumps.
|
||||
* The page listing all the dumps a user upvoted, which the "upvoted" section of
|
||||
* their profile links to.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -24,9 +25,10 @@ import { PageShell } from "../components/layout/PageShell.tsx";
|
||||
import { PageError } from "../components/layout/PageError.tsx";
|
||||
|
||||
/**
|
||||
* Renders `/users/:username/upvoted`, for anyone: every dump the user upvoted
|
||||
* that the viewer may see, a page at a time; on one's own list a withdrawn vote
|
||||
* fades its dump out.
|
||||
* Renders `/users/:username/upvoted`, for anyone. The dumps come from
|
||||
* `useUserDumpFeed`. The socket's vote events keep the list current: a dump
|
||||
* the user upvotes is fetched and prepended, and one whose vote they withdraw
|
||||
* fades out (`useFading`) unless they vote again within 2 seconds.
|
||||
*/
|
||||
export function UserUpvoted(): React.JSX.Element {
|
||||
const { username } = useParams();
|
||||
@@ -35,6 +37,8 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
|
||||
useDocumentTitle(username ?? null);
|
||||
|
||||
// The dumps still upvoted. A loaded dump outside it shows only while it
|
||||
// fades.
|
||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||
const { fading, startFading, cancelFading, cancelAll } = useFading();
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
@@ -90,7 +94,7 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
const prev = prevMyVotesRef.current;
|
||||
// Votes cast or removed since — from this tab or another — show or fade.
|
||||
// Votes cast or withdrawn since, in any of the user's tabs.
|
||||
setVotedIds((ids) => {
|
||||
const next = new Set(ids);
|
||||
for (const id of prev) if (!myVotes.has(id)) next.delete(id);
|
||||
@@ -108,8 +112,8 @@ export function UserUpvoted(): React.JSX.Element {
|
||||
if (voterId !== profileUserId) return;
|
||||
|
||||
if (action === "remove") {
|
||||
// setVotedIds and startFading must fire together to avoid a render with
|
||||
// stale votedIds between the two updates.
|
||||
// Both updates must land in the same render, or the card unmounts
|
||||
// before its fade starts.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
|
||||
Reference in New Issue
Block a user