+ );
+}
diff --git a/src/components/dump/NewDumpButton.tsx b/src/components/dump/NewDumpButton.tsx
new file mode 100644
index 00000000..29a4fd12
--- /dev/null
+++ b/src/components/dump/NewDumpButton.tsx
@@ -0,0 +1,33 @@
+/**
+ * Renders the button that opens the dump creation modal, on the user's own
+ * profile and dumps pages.
+ *
+ * @module
+ */
+
+import { useState } from "react";
+import { Trans } from "@lingui/react/macro";
+import { DumpCreateModal } from "./create/DumpCreateModal.tsx";
+
+/**
+ * Renders a "+ New dump" button, as `NewPlaylistButton` does for playlists,
+ * that opens {@link DumpCreateModal}. The modal hands the new dump to
+ * `WSContext` itself, so the button takes no callback.
+ */
+export function NewDumpButton(): React.JSX.Element {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+ {open && setOpen(false)} />}
+ >
+ );
+}
diff --git a/src/components/dump/create/DumpCreateModal.tsx b/src/components/dump/create/DumpCreateModal.tsx
index c122ce5c..515b23fb 100644
--- a/src/components/dump/create/DumpCreateModal.tsx
+++ b/src/components/dump/create/DumpCreateModal.tsx
@@ -22,15 +22,14 @@ import type {
LinkPreview,
RichContent,
} from "../../../../shared/dump.ts";
-import type { PlaylistMembership } from "../../../../shared/playlist.ts";
import type { Wire } from "../../../../shared/api.ts";
import {
deserializeDump,
deserializeDumpUrlMatch,
- deserializePlaylistMembership,
} from "../../../utils/shared/deserialize.ts";
import { useAuth } from "../../../hooks/auth/useAuth.ts";
import { useWS } from "../../../hooks/ws/useWS.ts";
+import { usePlaylistMemberships } from "../../../hooks/playlist/usePlaylistMemberships.ts";
import { dumpUrl, normalizeUrl } from "../../../utils/shared/urls.ts";
import {
formatBytes,
@@ -183,9 +182,6 @@ export function DumpCreateModal(
return next;
});
- const [memberships, setMemberships] = useState([]);
- const [playlistsLoading, setPlaylistsLoading] = useState(false);
-
const selectFile = useCallback((f: File | null) => {
setValue("file", f);
if (f) {
@@ -442,41 +438,9 @@ export function DumpCreateModal(
injectDump(dump);
setCreatedDump(dump);
goToStep("playlist", "forward");
- setPlaylistsLoading(true);
- authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
- .then((r) => r.json())
- .then((body) => {
- if (body.success) {
- setMemberships(
- (body.data as Wire[]).map(
- deserializePlaylistMembership,
- ),
- );
- }
- })
- .catch(() => {})
- .finally(() => setPlaylistsLoading(false));
});
- const toggleMembership = async (membership: PlaylistMembership) => {
- if (!createdDump) return;
- const { playlist, hasDump } = membership;
- // Not optimistic: the box changes only once the server agrees.
- try {
- const res = await authFetch(
- `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
- { method: hasDump ? "DELETE" : "POST" },
- );
- if (!res.ok) return;
- } catch {
- return;
- }
- setMemberships((prev) =>
- prev.map((m) =>
- m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
- )
- );
- };
+ const playlists = usePlaylistMemberships(createdDump?.id ?? null);
const canProceed = !!file || !!canonicalUrl;
@@ -565,11 +529,10 @@ export function DumpCreateModal(
- setMemberships((prev) => [membership, ...prev])}
+ memberships={playlists.memberships}
+ loading={playlists.loading}
+ onToggle={playlists.toggle}
+ onPlaylistCreated={playlists.add}
/>
diff --git a/src/components/feed/DumpFeed.tsx b/src/components/feed/DumpFeed.tsx
index a708e7bf..a2bc2100 100644
--- a/src/components/feed/DumpFeed.tsx
+++ b/src/components/feed/DumpFeed.tsx
@@ -6,35 +6,36 @@
*/
import { type Ref, useMemo } from "react";
-import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { DumpCard } from "../dump/DumpCard.tsx";
-import { ErrorCard } from "../shared/ErrorCard.tsx";
+import { DumpCardList } from "../dump/DumpCardList.tsx";
+import { ListState } from "../shared/ListState.tsx";
+import { FeedFooter } from "./FeedFooter.tsx";
import type { Dump } from "../../../shared/dump.ts";
-import type { SelfUser } from "../../../shared/user.ts";
+import type { LoadState } from "../../utils/shared/loadState.ts";
import { hotScore } from "../../utils/feed/hotScore.ts";
+/**
+ * Held by `Index` for its feed, and by `FollowedFeed` for each of its
+ * sections: the pages of dumps loaded so far.
+ */
+export type DumpFeedState = LoadState<{
+ dumps: Dump[];
+ hasMore: boolean;
+ page: number;
+ loadingMore: boolean;
+}>;
+
/**
* Passed by `Index` to whichever of the "hot", "new" and "journal" tabs is
* showing. The tabs share this one state, so switching tabs doesn't refetch.
*/
export interface MainFeedProps {
+ /** The feed's status and paging. Its own dumps are in `dumps`. */
+ state: DumpFeedState;
+ /** The loaded dumps, with those `WSContext` added since and less deleted. */
dumps: Dump[];
- loading: boolean;
- error: string | null;
- hasMore: boolean;
- loadingMore: boolean;
/** From `useInfiniteScroll`. The feed places it after the last card. */
sentinelRef: Ref;
- /**
- * Counts pushed by the socket. A dump missing here shows its fetched
- * `voteCount`.
- */
- voteCounts: Record;
- myVotes: Set;
- user: SelfUser | null;
- castVote: (id: string, shownCount?: number) => void;
- removeVote: (id: string, shownCount?: number) => void;
}
/**
@@ -46,25 +47,12 @@ export interface DumpFeedProps extends MainFeedProps {
}
/**
- * Renders the "hot" and "new" tabs as a list of {@link DumpCard}, sorted on the
- * client, followed by the infinite-scroll sentinel. It also renders the
- * loading, error and empty states. `Index` is its only user.
+ * Renders the "hot" and "new" tabs as a {@link DumpCardList}, sorted on the
+ * client, followed by the infinite-scroll sentinel, or the loading, error or
+ * empty state. `Index` is its only user.
*/
export function DumpFeed(
- {
- order,
- dumps,
- loading,
- error,
- hasMore,
- loadingMore,
- sentinelRef,
- voteCounts,
- myVotes,
- user,
- castVote,
- removeVote,
- }: DumpFeedProps,
+ { order, state, dumps, sentinelRef }: DumpFeedProps,
): React.JSX.Element {
// The server pages by date, so "hot" only ranks the pages loaded so far.
const sorted = useMemo(
@@ -77,49 +65,26 @@ export function DumpFeed(
[dumps, order],
);
- if (loading) {
- return (
-
- Loading…
-
- );
- }
- if (error) return ;
- if (sorted.length === 0) {
- return (
-
- )}
- >
+
);
}
diff --git a/src/components/feed/FeedFooter.tsx b/src/components/feed/FeedFooter.tsx
new file mode 100644
index 00000000..fc8f03f0
--- /dev/null
+++ b/src/components/feed/FeedFooter.tsx
@@ -0,0 +1,44 @@
+/**
+ * Renders what follows the last item of an infinitely scrolled list.
+ *
+ * @module
+ */
+
+import type { Ref } from "react";
+import { Trans } from "@lingui/react/macro";
+
+/**
+ * Passed to {@link FeedFooter} by every list that loads its next page on
+ * scroll: the index's feeds, the search results and a profile's full lists.
+ */
+export interface FeedFooterProps {
+ hasMore: boolean;
+ loadingMore: boolean;
+ /** From `useInfiniteScroll`. */
+ sentinelRef: Ref;
+}
+
+/**
+ * Renders the sentinel while there is more to load, "Loading more…" while it
+ * loads, and "You've reached the end." once there is no more. The list renders
+ * it after a non-empty list only, since an empty list has its own message.
+ */
+export function FeedFooter(
+ { hasMore, loadingMore, sentinelRef }: FeedFooterProps,
+): React.JSX.Element {
+ return (
+ <>
+ {hasMore && }
+ {loadingMore && (
+
+ Loading more…
+
+ )}
+ {!hasMore && (
+
+ You've reached the end.
+
+ )}
+ >
+ );
+}
diff --git a/src/components/feed/FollowedFeed.tsx b/src/components/feed/FollowedFeed.tsx
index 0c10aa94..7cd597df 100644
--- a/src/components/feed/FollowedFeed.tsx
+++ b/src/components/feed/FollowedFeed.tsx
@@ -9,8 +9,10 @@ import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { DumpCard } from "../dump/DumpCard.tsx";
-import { ErrorCard } from "../shared/ErrorCard.tsx";
+import { DumpCardList } from "../dump/DumpCardList.tsx";
+import { ListState } from "../shared/ListState.tsx";
+import { FeedFooter } from "./FeedFooter.tsx";
+import type { DumpFeedState } from "./DumpFeed.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
@@ -23,335 +25,170 @@ import {
} from "../../utils/shared/deserialize.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
-import type { SelfUser } from "../../../shared/user.ts";
-import { friendlyFetchError } from "../../utils/shared/api.ts";
-
-type FeedState =
- | { status: "loading" }
- | { status: "error"; error: string }
- | {
- status: "loaded";
- dumps: Dump[];
- hasMore: boolean;
- page: number;
- loadingMore: boolean;
- };
-
-/**
- * Returns the page of dumps in the body of `res`.
- *
- * @throws {Error} The server's own message on an error response, so that
- * `friendlyFetchError` doesn't report it as a lost connection.
- */
-async function readPage(res: Response): Promise>> {
- const body = await res.json();
- if (!body.success) {
- throw new Error(body.error?.message ?? `HTTP ${res.status}`);
- }
- return body.data as PaginatedData>;
-}
+import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
type FollowedSection = "users" | "playlists";
/**
- * Passed to {@link FollowedFeed} by `Index`, with the vote state of
- * `WSContext`. Unlike the other tabs, it loads its own dumps.
+ * Returns one section's feed, from `GET /api/follows/feed/`, and the
+ * call that loads its next page. A section cached like the index's feed starts
+ * loaded and isn't fetched.
*/
-export interface FollowedFeedProps {
- /** Scopes both sections to a category. */
- categorySlug?: string;
- voteCounts: Record;
- myVotes: Set;
- /** `Index` renders the tab for a signed-in user only. */
- user: SelfUser;
- castVote: (id: string, shownCount?: number) => void;
- removeVote: (id: string, shownCount?: number) => void;
+function useFollowedSection(
+ section: FollowedSection,
+ categorySlug: string | undefined,
+): { state: DumpFeedState; loadMore: () => void } {
+ const { token } = useAuth();
+ const catQuery = categorySlug
+ ? `&category=${encodeURIComponent(categorySlug)}`
+ : "";
+
+ const { cached, saveState } = useFeedCache(
+ `feed:followed-${section}:${categorySlug ?? "all"}`,
+ hydrateDump,
+ );
+ const [state, setState] = useState(() =>
+ cached
+ ? {
+ status: "loaded",
+ dumps: cached.items,
+ hasMore: cached.hasMore,
+ page: cached.page,
+ loadingMore: false,
+ }
+ : { status: "loading" }
+ );
+
+ const setItems = useCallback(
+ (fn: (prev: Dump[]) => Dump[]) =>
+ setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }),
+ [],
+ );
+ useDumpListSync(setItems);
+
+ useEffect(() => {
+ if (!token || state.status !== "loading") return;
+ fetch(
+ `${API_URL}/api/follows/feed/${section}?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
+ { headers: { Authorization: `Bearer ${token}` } },
+ )
+ .then(expectOk>>)
+ .then(({ items, hasMore }) =>
+ setState({
+ status: "loaded",
+ dumps: items.map(deserializeDump),
+ hasMore,
+ page: 1,
+ loadingMore: false,
+ })
+ )
+ .catch((err) =>
+ setState({ status: "error", error: friendlyFetchError(err) })
+ );
+ }, [token, state.status, section, catQuery]);
+
+ // Only the list is kept here: `Index` saves and restores the page's scroll
+ // for every tab, this one included.
+ useScrollSave(
+ state.status === "loaded",
+ useCallback(() => {
+ if (state.status !== "loaded") return;
+ saveState(state.dumps, state.page, state.hasMore, 0);
+ }, [state, saveState]),
+ );
+
+ const loadMore = useCallback(() => {
+ if (
+ state.status !== "loaded" || !state.hasMore || state.loadingMore ||
+ !token
+ ) return;
+ const nextPage = state.page + 1;
+ setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
+ fetch(
+ `${API_URL}/api/follows/feed/${section}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
+ { headers: { Authorization: `Bearer ${token}` } },
+ )
+ .then(expectOk>>)
+ .then(({ items, hasMore }) =>
+ setState((s) =>
+ s.status === "loaded"
+ ? {
+ ...s,
+ dumps: [...s.dumps, ...items.map(deserializeDump)],
+ hasMore,
+ page: nextPage,
+ loadingMore: false,
+ }
+ : s
+ )
+ )
+ .catch(() =>
+ setState((s) =>
+ s.status === "loaded" ? { ...s, loadingMore: false } : s
+ )
+ );
+ }, [state, token, section, catQuery]);
+
+ return { state, loadMore };
}
interface FollowedSubFeedProps {
- state: FeedState;
- voteCounts: Record;
- myVotes: Set;
- user: SelfUser;
- castVote: (id: string, shownCount?: number) => void;
- removeVote: (id: string, shownCount?: number) => void;
+ state: DumpFeedState;
emptyMessage: string;
onLoadMore: () => void;
}
/** Renders one section's list, with its own infinite-scroll sentinel. */
-function FollowedSubFeed({
- state,
- voteCounts,
- myVotes,
- user,
- castVote,
- removeVote,
- emptyMessage,
- onLoadMore,
-}: FollowedSubFeedProps) {
- const enabled = state.status === "loaded" && state.hasMore &&
- !state.loadingMore;
- const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
-
- if (state.status === "loading") {
- return (
-
diff --git a/src/components/feed/JournalFeed.tsx b/src/components/feed/JournalFeed.tsx
index 38f5f076..3a013098 100644
--- a/src/components/feed/JournalFeed.tsx
+++ b/src/components/feed/JournalFeed.tsx
@@ -6,91 +6,73 @@
*/
import { Fragment, useMemo } from "react";
-import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { ErrorCard } from "../shared/ErrorCard.tsx";
+import { ListState } from "../shared/ListState.tsx";
import { JournalCard } from "../dump/JournalCard.tsx";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useWS } from "../../hooks/ws/useWS.ts";
+import { FeedFooter } from "./FeedFooter.tsx";
import { composeJournal } from "../../utils/feed/journalLayout.ts";
import type { MainFeedProps } from "./DumpFeed.tsx";
/**
* Renders the feed `Index` passes as blocks of {@link JournalCard}, laid out
- * by `composeJournal`, followed by the infinite-scroll sentinel. It also
- * renders the loading, error and empty states.
+ * by `composeJournal`, followed by the infinite-scroll sentinel, or the
+ * loading, error or empty state. The votes come from `WSContext`.
*/
export function JournalFeed(
- {
- dumps,
- loading,
- error,
- hasMore,
- loadingMore,
- sentinelRef,
- voteCounts,
- myVotes,
- user,
- castVote,
- removeVote,
- }: MainFeedProps,
+ { state, dumps, sentinelRef }: MainFeedProps,
): React.JSX.Element {
+ const { user } = useAuth();
+ const { voteCounts, myVotes, castVote, removeVote } = useWS();
const blocks = useMemo(() => composeJournal(dumps), [dumps]);
- if (loading) {
- return (
-
- Loading…
-
- );
- }
- if (error) return ;
- if (blocks.length === 0) {
- return (
-
- No dumps yet. Be the first!
-
- );
- }
-
return (
- <>
- {
- /* Each block is its own grid, with a themable between them. Inside
- a grid the would be stretched to a 9rem row, while out here a
- theme can shrink it to nothing. */
+ blocks.length === 0}
+ empty={
+
+ >
+ {({ hasMore, loadingMore }) => (
+ <>
+ {
+ /* Each block is its own grid, with a themable between them.
+ Inside a grid the would be stretched to a 9rem row, while out
+ here a theme can shrink it to nothing. */
+ }
+
- )}
- >
+
);
}
diff --git a/src/components/form/SegmentedField.tsx b/src/components/form/SegmentedField.tsx
index f162c1fa..4296a8e7 100644
--- a/src/components/form/SegmentedField.tsx
+++ b/src/components/form/SegmentedField.tsx
@@ -5,8 +5,12 @@
* @module
*/
-import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
+import {
+ Segmented,
+ type SegmentedOption,
+ type SegmentedProps,
+} from "../shared/Segmented.tsx";
import {
Controller,
type FieldValues,
@@ -14,15 +18,6 @@ import {
useFormContext,
} from "react-hook-form";
-/**
- * Passed in {@link SegmentedFieldProps.options}. Its `value` is compared to the
- * field's with `===` and must be unique as a string, since it keys the button.
- */
-export interface SegmentedOption {
- value: V;
- label: ReactNode;
-}
-
/**
* Passed to {@link SegmentedField} by a form, or by {@link VisibilityToggle}.
*/
@@ -34,9 +29,8 @@ export interface SegmentedFieldProps {
}
/**
- * Renders the options as buttons, the one equal to the field's value marked
- * active, in the `.visibility-toggle` style. It must sit under a
- * react-hook-form `FormProvider`.
+ * Renders a {@link Segmented} bound to the field `name`, under its label. It
+ * must sit under a react-hook-form `FormProvider`.
*/
export function SegmentedField({
name,
@@ -53,25 +47,24 @@ export function SegmentedField({
name={name}
control={control}
render={({ field }) => (
-
- {options.map((opt) => (
-
- ))}
-
+
)}
/>
);
}
+/** The Public/Private options, true for public. */
+const VISIBILITY_OPTIONS: SegmentedOption[] = [
+ { value: true, label: Public },
+ { value: false, label: Private },
+];
+
/** Passed to {@link VisibilityToggle} by a dump or playlist form. */
export interface VisibilityToggleProps {
/** A boolean field, true for public. */
@@ -95,10 +88,18 @@ export function VisibilityToggle({
name={name}
label={label}
disabled={disabled}
- options={[
- { value: true, label: Public },
- { value: false, label: Private },
- ]}
+ options={VISIBILITY_OPTIONS}
/>
);
}
+
+/**
+ * Renders a Public/Private {@link Segmented} outside `SegmentedField`, for
+ * `PlaylistDetail`'s edit form, which binds it with its own `Controller` and
+ * styles it apart.
+ */
+export function VisibilitySegmented(
+ props: Omit, "options">,
+): React.JSX.Element {
+ return ;
+}
diff --git a/src/components/playlist/AddToPlaylistModal.tsx b/src/components/playlist/AddToPlaylistModal.tsx
index d4334262..70de77d5 100644
--- a/src/components/playlist/AddToPlaylistModal.tsx
+++ b/src/components/playlist/AddToPlaylistModal.tsx
@@ -5,13 +5,8 @@
* @module
*/
-import { useEffect, useState } from "react";
import { t } from "@lingui/core/macro";
-import { API_URL } from "../../config/api.ts";
-import { useAuth } from "../../hooks/auth/useAuth.ts";
-import type { PlaylistMembership } from "../../../shared/playlist.ts";
-import type { Wire } from "../../../shared/api.ts";
-import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts";
+import { usePlaylistMemberships } from "../../hooks/playlist/usePlaylistMemberships.ts";
import { Modal } from "../shared/Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
@@ -23,50 +18,12 @@ export interface AddToPlaylistModalProps {
/**
* Renders a {@link PlaylistMembershipPanel} in a modal, for the playlists
- * `GET /api/playlists/by-dump/:dumpId/memberships` answers. A toggle adds or
- * removes the dump on the server and shows only once the server agrees.
+ * `usePlaylistMemberships` loads and toggles.
*/
export function AddToPlaylistModal(
{ dumpId, onClose }: AddToPlaylistModalProps,
): React.JSX.Element {
- const { authFetch } = useAuth();
- const [memberships, setMemberships] = useState([]);
- const [loading, setLoading] = useState(true);
-
- useEffect(() => {
- authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`)
- .then((r) => r.json())
- .then((body) => {
- if (body.success) {
- setMemberships(
- (body.data as Wire[]).map(
- deserializePlaylistMembership,
- ),
- );
- }
- })
- .catch(() => {})
- .finally(() => setLoading(false));
- }, [dumpId, authFetch]);
-
- const toggleMembership = async (membership: PlaylistMembership) => {
- const { playlist, hasDump } = membership;
- // A refusal or a dropped connection leaves the box showing the truth.
- try {
- const res = await authFetch(
- `${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
- { method: hasDump ? "DELETE" : "POST" },
- );
- if (!res.ok) return;
- } catch {
- return;
- }
- setMemberships((prev) =>
- prev.map((m) =>
- m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
- )
- );
- };
+ const { memberships, loading, toggle, add } = usePlaylistMemberships(dumpId);
return (
@@ -74,9 +31,8 @@ export function AddToPlaylistModal(
dumpId={dumpId}
memberships={memberships}
loading={loading}
- onToggle={toggleMembership}
- onPlaylistCreated={(membership) =>
- setMemberships((prev) => [membership, ...prev])}
+ onToggle={toggle}
+ onPlaylistCreated={add}
/>
);
diff --git a/src/components/playlist/PlaylistCardList.tsx b/src/components/playlist/PlaylistCardList.tsx
new file mode 100644
index 00000000..4b0adb94
--- /dev/null
+++ b/src/components/playlist/PlaylistCardList.tsx
@@ -0,0 +1,42 @@
+/**
+ * Renders a list of playlists as cards.
+ *
+ * @module
+ */
+
+import type { Playlist } from "../../../shared/playlist.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { PlaylistCard } from "./PlaylistCard.tsx";
+
+/**
+ * Passed to {@link PlaylistCardList} by every list of playlist cards: the
+ * profile's and `UserPlaylists`' lists, the search results and a dump's
+ * collections.
+ */
+export interface PlaylistCardListProps {
+ playlists: Playlist[];
+ /** Given, each card shows a delete button, which calls it. */
+ onDelete?: (playlist: Playlist) => void;
+}
+
+/**
+ * Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being
+ * its owner's when the playlist is the signed-in user's.
+ */
+export function PlaylistCardList(
+ { playlists, onDelete }: PlaylistCardListProps,
+): React.JSX.Element {
+ const { user } = useAuth();
+ return (
+
setEditing(true) : undefined}
+ // 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
+ ? (e) => {
+ if (
+ (e.key === "Enter" || e.key === " ") &&
+ e.target === e.currentTarget
+ ) {
+ e.preventDefault();
+ setEditing(true);
+ }
+ }
+ : undefined}
+ >
+ {description
+ ? (
+
+ {description}
+
+ )
+ : (
+
+ Who am I?
+
+ )}
+ {isOwnProfile && (
+
+ ✎
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/profile/ProfileDumpsTab.tsx b/src/components/profile/ProfileDumpsTab.tsx
new file mode 100644
index 00000000..3e9bc1bc
--- /dev/null
+++ b/src/components/profile/ProfileDumpsTab.tsx
@@ -0,0 +1,71 @@
+/**
+ * The "dumps" tab of a profile: the user's dumps beside those they upvoted.
+ *
+ * @module
+ */
+
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import type { Dump } from "../../../shared/dump.ts";
+import type { PagedList } from "../../utils/feed/pagedList.ts";
+import { DumpCardList } from "../dump/DumpCardList.tsx";
+import { NewDumpButton } from "../dump/NewDumpButton.tsx";
+import { ProfileSection } from "./ProfileSection.tsx";
+
+/** Passed to {@link ProfileDumpsTab} by `UserPublicProfile`. */
+export interface ProfileDumpsTabProps {
+ username: string;
+ dumps: PagedList;
+ votes: PagedList;
+ isOwnProfile: boolean;
+ /** From `useProfile`, which fades out a withdrawn upvote. */
+ upvotedCardClassName: (dump: Dump) => string | undefined;
+}
+
+/**
+ * Renders the first page of both lists, each linking to its full page. The
+ * owner also gets a "New dump" button.
+ */
+export function ProfileDumpsTab(
+ { username, dumps, votes, isOwnProfile, upvotedCardClassName }:
+ ProfileDumpsTabProps,
+): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/components/profile/ProfileFollowedTab.tsx b/src/components/profile/ProfileFollowedTab.tsx
new file mode 100644
index 00000000..5ea16a1e
--- /dev/null
+++ b/src/components/profile/ProfileFollowedTab.tsx
@@ -0,0 +1,119 @@
+/**
+ * The "followed" tab of a profile: the users and the playlists the user
+ * follows.
+ *
+ * @module
+ */
+
+import { Link } from "react-router";
+import { Trans } from "@lingui/react/macro";
+import type { Playlist } from "../../../shared/playlist.ts";
+import type { PublicUser } from "../../../shared/user.ts";
+import type { PagedList } from "../../utils/feed/pagedList.ts";
+import type { FetchState } from "../../hooks/shared/useFetchState.ts";
+import { Avatar } from "../shared/Avatar.tsx";
+import { ListState } from "../shared/ListState.tsx";
+import { LoadMoreButton } from "../shared/LoadMoreButton.tsx";
+import { FollowUserButton } from "../social/FollowButton.tsx";
+import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
+import { ProfileSection } from "./ProfileSection.tsx";
+
+/**
+ * Loaded by `UserPublicProfile` when the tab is first opened. The followed
+ * users have no page of their own, so their list grows in the tab.
+ */
+export interface ProfileFollowed {
+ users: PagedList;
+ playlists: PagedList;
+}
+
+/** Passed to {@link ProfileFollowedTab} by `UserPublicProfile`. */
+export interface ProfileFollowedTabProps {
+ username: string;
+ state: FetchState;
+ onLoadMoreUsers: () => void;
+}
+
+/** Renders a followed user, with a button to follow or unfollow them. */
+function FollowedUserCard({ user }: { user: PublicUser }) {
+ return (
+
+
+
+ @{user.username}
+
+
+
+ );
+}
+
+/**
+ * Renders the followed users, with a "Load more" button, beside the first
+ * page of the followed playlists, which link to the user's playlists page.
+ */
+export function ProfileFollowedTab(
+ { username, state, onLoadMoreUsers }: ProfileFollowedTabProps,
+): React.JSX.Element {
+ return (
+
+ );
+}
+
+/** Passed to {@link ProfileInviteesTab} by `UserPublicProfile`. */
+export interface ProfileInviteesTabProps {
+ userId: string;
+ /**
+ * Loaded when the tab is first opened, from
+ * `GET /api/users/:username/invitees`.
+ */
+ state: FetchState;
+}
+
+/** Renders the invitees of `userId` as a tree. */
+export function ProfileInviteesTab(
+ { userId, state }: ProfileInviteesTabProps,
+): React.JSX.Element {
+ const nodes = useMemo(
+ () => state.status === "loaded" ? buildInviteTree(state.data, userId) : [],
+ [state, userId],
+ );
+ return (
+ Invitees}>
+ data.length === 0}
+ empty={
+
+ No invitees yet.
+
+ }
+ >
+ {() => }
+
+
+ );
+}
diff --git a/src/components/profile/ProfileManageTab.tsx b/src/components/profile/ProfileManageTab.tsx
new file mode 100644
index 00000000..56c3ad58
--- /dev/null
+++ b/src/components/profile/ProfileManageTab.tsx
@@ -0,0 +1,55 @@
+/**
+ * The "manage" tab of a profile, which holds tools for another user on their
+ * profile and site-wide tools on one's own.
+ *
+ * @module
+ */
+
+import { Trans } from "@lingui/react/macro";
+import type { PublicUser, Role } from "../../../shared/user.ts";
+import { CategoryManager } from "../category/CategoryManager.tsx";
+import { ProfileSection } from "./ProfileSection.tsx";
+import { RoleSwitcher } from "./RoleSwitcher.tsx";
+
+/**
+ * Passed to {@link ProfileManageTab} by `UserPublicProfile`, which shows the
+ * tab only when one of its sections applies.
+ */
+export interface ProfileManageTabProps {
+ user: PublicUser;
+ isOwnProfile: boolean;
+ canManageUsers: boolean;
+ canManageCategories: boolean;
+ onRoleSaved: (role: Role) => void;
+}
+
+/**
+ * Renders the user's role to those who hold `user:manage`, on another user's
+ * profile, and the {@link CategoryManager} to those who hold
+ * `category:manage`, on their own.
+ */
+export function ProfileManageTab(
+ { user, isOwnProfile, canManageUsers, canManageCategories, onRoleSaved }:
+ ProfileManageTabProps,
+): React.JSX.Element {
+ return (
+ <>
+ {!isOwnProfile && canManageUsers && (
+ User management}>
+
+
+
+
+ )}
+ {isOwnProfile && canManageCategories && (
+ Categories}>
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/profile/ProfileOwnActions.tsx b/src/components/profile/ProfileOwnActions.tsx
new file mode 100644
index 00000000..60f08372
--- /dev/null
+++ b/src/components/profile/ProfileOwnActions.tsx
@@ -0,0 +1,119 @@
+/**
+ * Renders the actions of the signed-in user's own profile header: inviting
+ * someone, and whatever the header adds, the log out button.
+ *
+ * @module
+ */
+
+import { useState } from "react";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { API_URL } from "../../config/api.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
+
+/**
+ * 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`.
+ */
+export function ProfileOwnActions(
+ { children }: { children: React.ReactNode },
+): React.JSX.Element {
+ const { authFetch } = useAuth();
+ const [inviteUrl, setInviteUrl] = useState(null);
+ const [pending, setPending] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const [error, setError] = useState(null);
+ const ref = useHeightTransition(
+ (inviteUrl ?? "") + (error ?? ""),
+ );
+
+ async function toggle() {
+ setError(null);
+ setCopied(false);
+ if (inviteUrl) {
+ setInviteUrl(null);
+ return;
+ }
+ setPending(true);
+ try {
+ const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
+ const body = await res.json();
+ if (body.success) {
+ setInviteUrl(
+ `${globalThis.location.origin}/register?token=${body.data.token}`,
+ );
+ } else {
+ setError(t`Failed to generate invite`);
+ }
+ } catch {
+ setError(t`Failed to generate invite`);
+ } finally {
+ setPending(false);
+ }
+ }
+
+ async function copy() {
+ if (!inviteUrl) return;
+ try {
+ await navigator.clipboard.writeText(inviteUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // The clipboard is denied outside a secure context. The link can still
+ // be selected by hand.
+ }
+ }
+
+ return (
+
+
+
+ {children}
+
+ {inviteUrl && (
+
+ e.currentTarget.select()}
+ />
+
+
+ Single use, valid for 7 days.
+
+
+ )}
+ {error && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/profile/ProfilePlaylistsTab.tsx b/src/components/profile/ProfilePlaylistsTab.tsx
new file mode 100644
index 00000000..f8207a65
--- /dev/null
+++ b/src/components/profile/ProfilePlaylistsTab.tsx
@@ -0,0 +1,57 @@
+/**
+ * The "playlists" tab of a profile: the playlists the user made.
+ *
+ * @module
+ */
+
+import { Trans } from "@lingui/react/macro";
+import type { Playlist } from "../../../shared/playlist.ts";
+import type { PagedList } from "../../utils/feed/pagedList.ts";
+import { withFirst } from "../../utils/feed/pagedList.ts";
+import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
+import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
+import { ProfileSection } from "./ProfileSection.tsx";
+
+/** Passed to {@link ProfilePlaylistsTab} by `UserPublicProfile`. */
+export interface ProfilePlaylistsTabProps {
+ username: string;
+ playlists: PagedList;
+ isOwnProfile: boolean;
+ /** From `useProfile`, to put a playlist made here first. */
+ setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
+}
+
+/**
+ * Renders the first page of the playlists, linking to the full page. The
+ * owner also gets a "New playlist" button.
+ */
+export function ProfilePlaylistsTab(
+ { username, playlists, isOwnProfile, setPlaylists }: ProfilePlaylistsTabProps,
+): React.JSX.Element {
+ return (
+
+ Playlists ({playlists.items.length}
+ {playlists.hasMore ? "+" : ""})
+
+ }
+ actions={isOwnProfile && (
+ setPlaylists((items) => withFirst(items, p))}
+ />
+ )}
+ viewAllHref={playlists.items.length > 0
+ ? `/users/${username}/playlists`
+ : undefined}
+ >
+ {playlists.items.length === 0
+ ? (
+
+ No playlists yet.
+
+ )
+ : }
+
+ );
+}
diff --git a/src/components/profile/ProfileSection.tsx b/src/components/profile/ProfileSection.tsx
new file mode 100644
index 00000000..de07a4b6
--- /dev/null
+++ b/src/components/profile/ProfileSection.tsx
@@ -0,0 +1,48 @@
+/**
+ * Renders a titled section of a profile page.
+ *
+ * @module
+ */
+
+import type { ReactNode } from "react";
+import { Link } from "react-router";
+import { Trans } from "@lingui/react/macro";
+
+/**
+ * Passed to {@link ProfileSection} by the profile's tabs and by
+ * `UserPlaylists`.
+ */
+export interface ProfileSectionProps {
+ title: ReactNode;
+ /** Buttons at the end of the title row. */
+ actions?: ReactNode;
+ /**
+ * The page with the whole list, of which the section shows the first page.
+ * The profile leaves it out while the list is empty.
+ */
+ viewAllHref?: string;
+ children: ReactNode;
+}
+
+/**
+ * Renders a `.profile-section`: its title row, `children`, then a
+ * "View all →" link to `viewAllHref`.
+ */
+export function ProfileSection(
+ { title, actions, viewAllHref, children }: ProfileSectionProps,
+): React.JSX.Element {
+ return (
+
+
+
{title}
+ {actions}
+
+ {children}
+ {viewAllHref && (
+
+ View all →
+
+ )}
+
+ );
+}
diff --git a/src/components/profile/ProfileSettingsTab.tsx b/src/components/profile/ProfileSettingsTab.tsx
new file mode 100644
index 00000000..d9ba562e
--- /dev/null
+++ b/src/components/profile/ProfileSettingsTab.tsx
@@ -0,0 +1,128 @@
+/**
+ * The "settings" tab of the signed-in user's own profile: their password, and
+ * this browser's preferences.
+ *
+ * @module
+ */
+
+import { useState } from "react";
+import { Trans } from "@lingui/react/macro";
+import type {
+ ColorScheme,
+ StyleName,
+} from "../../contexts/theme/ThemeContext.ts";
+import type { FeedTab } from "../../config/feedTabs.ts";
+import { useTheme } from "../../hooks/theme/useTheme.ts";
+import { useDefaultFeedTab } from "../../hooks/feed/useDefaultFeedTab.ts";
+import { useSoundsEnabled } from "../../hooks/shared/useSoundsEnabled.ts";
+import { previewChime } from "../../utils/shared/sound.ts";
+import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx";
+import { ChangePasswordModal } from "./ChangePasswordModal.tsx";
+import { ProfileSection } from "./ProfileSection.tsx";
+import { SettingRow } from "./SettingRow.tsx";
+
+// The styles' names are their own, in every language.
+const STYLE_OPTIONS: SegmentedOption[] = [
+ { value: "smooth", label: "Smooth" },
+ { value: "brutalist", label: "Brutalist" },
+ { value: "geocities", label: "GeoCities" },
+ { value: "nyt", label: "Times" },
+ { value: "kanagawa", label: "Kanagawa" },
+ { value: "melange", label: "Melange" },
+];
+
+const COLOR_SCHEME_OPTIONS: SegmentedOption[] = [
+ { value: "auto", label: Auto },
+ { value: "light", label: Light },
+ { value: "dark", label: Dark },
+];
+
+const FEED_TAB_OPTIONS: SegmentedOption[] = [
+ { value: "hot", label: Hot },
+ { value: "new", label: New },
+ { value: "journal", label: Journal },
+ { value: "followed", label: Followed },
+];
+
+const ON_OFF_OPTIONS: SegmentedOption[] = [
+ { value: true, label: On },
+ { value: false, label: Off },
+];
+
+/**
+ * Renders the password change, which opens {@link ChangePasswordModal}, and
+ * the style, color scheme, default feed tab and sounds, each stored for this
+ * browser as soon as it is clicked. `UserPublicProfile` renders it on one's
+ * own profile only.
+ */
+export function ProfileSettingsTab(): React.JSX.Element {
+ const { style, colorScheme, setStyle, setColorScheme } = useTheme();
+ const [defaultFeedTab, setDefaultFeedTab] = useDefaultFeedTab();
+ const [soundsEnabled, setSoundsEnabled] = useSoundsEnabled();
+ const [changePasswordOpen, setChangePasswordOpen] = useState(false);
+
+ return (
+ <>
+ Account}>
+
+ Password}>
+
+
+
+
+ Appearance}>
+
+ Style}>
+
+
+ Color scheme}>
+
+
+
+
+ Feeds}>
+
+ Default tab}>
+
+
+
+
+ Notifications}>
+
+ Sounds}>
+ {
+ setSoundsEnabled(on);
+ // Let the user hear what they just turned on.
+ if (on) previewChime("notify");
+ }}
+ />
+
+
+
+ {changePasswordOpen && (
+ setChangePasswordOpen(false)} />
+ )}
+ >
+ );
+}
diff --git a/src/components/profile/RoleSwitcher.tsx b/src/components/profile/RoleSwitcher.tsx
new file mode 100644
index 00000000..f21f0450
--- /dev/null
+++ b/src/components/profile/RoleSwitcher.tsx
@@ -0,0 +1,95 @@
+/**
+ * Shows a user's role on their profile, and lets those who hold `user:manage`
+ * change it.
+ *
+ * @module
+ */
+
+import { useState } from "react";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import { API_URL } from "../../config/api.ts";
+import type { Wire } from "../../../shared/api.ts";
+import type { PublicUser, Role } from "../../../shared/user.ts";
+import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
+import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
+import { useAuth } from "../../hooks/auth/useAuth.ts";
+import { ErrorCard } from "../shared/ErrorCard.tsx";
+import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx";
+import { SettingRow } from "./SettingRow.tsx";
+
+const ROLE_OPTIONS: SegmentedOption[] = [
+ { value: "user", label: User },
+ { value: "moderator", label: Moderator },
+ { value: "admin", label: Admin },
+];
+
+/**
+ * Renders the role badge next to the username in `ProfileHeader`, for
+ * moderators and admins only.
+ */
+export function RoleChip({ role }: { role: Role }): React.JSX.Element | null {
+ if (role === "user") return null;
+ const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
+ return (
+
+ {label}
+
+ );
+}
+
+/**
+ * Passed to {@link RoleSwitcher} by `ProfileManageTab`, which shows it only to
+ * those who hold `user:manage`, on another user's profile.
+ */
+export interface RoleSwitcherProps {
+ userId: string;
+ currentRole: Role;
+ /** Called with the role the server kept. */
+ onSaved: (role: Role) => void;
+}
+
+/**
+ * Renders the user's role as a {@link Segmented} whose clicks save it to
+ * `PATCH /api/users/:id/role`, which checks the permission again.
+ */
+export function RoleSwitcher(
+ { userId, currentRole, onSaved }: RoleSwitcherProps,
+): React.JSX.Element {
+ const { authFetch } = useAuth();
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function changeRole(role: Role) {
+ if (role === currentRole || saving) return;
+ setSaving(true);
+ setError(null);
+ try {
+ const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
+ method: "PATCH",
+ body: JSON.stringify({ role }),
+ });
+ onSaved(
+ deserializePublicUser(await expectOk>(res)).role,
+ );
+ } catch (err) {
+ setError(friendlyFetchError(err));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+ <>
+ Role}>
+
+
+ {error && }
+ >
+ );
+}
diff --git a/src/components/profile/SettingRow.tsx b/src/components/profile/SettingRow.tsx
new file mode 100644
index 00000000..6dec2d18
--- /dev/null
+++ b/src/components/profile/SettingRow.tsx
@@ -0,0 +1,31 @@
+/**
+ * Renders one labelled setting of the profile's "settings" and "manage" tabs.
+ *
+ * @module
+ */
+
+import type { ReactNode } from "react";
+
+/**
+ * Passed to {@link SettingRow} by `ProfileSettingsTab` and `RoleSwitcher`.
+ */
+export interface SettingRowProps {
+ label: ReactNode;
+ /** The control, usually a `Segmented`. */
+ children: ReactNode;
+}
+
+/**
+ * Renders `label` beside `children`, as a row of the
+ * `.profile-appearance-grid` its caller wraps the rows in.
+ */
+export function SettingRow(
+ { label, children }: SettingRowProps,
+): React.JSX.Element {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
diff --git a/src/components/shared/ListState.tsx b/src/components/shared/ListState.tsx
new file mode 100644
index 00000000..6cb312a3
--- /dev/null
+++ b/src/components/shared/ListState.tsx
@@ -0,0 +1,46 @@
+/**
+ * Renders a loaded list, or in its place the loading, error or empty state.
+ *
+ * @module
+ */
+
+import type { ReactNode } from "react";
+import { t } from "@lingui/core/macro";
+import { Trans } from "@lingui/react/macro";
+import type { LoadState } from "../../utils/shared/loadState.ts";
+import { ErrorCard } from "./ErrorCard.tsx";
+
+/**
+ * Passed to {@link ListState} by the feeds of the index and the lists of a
+ * profile.
+ */
+export interface ListStateProps {
+ state: LoadState;
+ /** Called only once `state` is loaded. */
+ isEmpty: (loaded: L) => boolean;
+ /** Shown when `isEmpty`, usually a `.empty-state` or `.index-status` line. */
+ empty: ReactNode;
+ /** Called only once `state` is loaded and not empty. */
+ children: (loaded: L) => ReactNode;
+}
+
+/**
+ * Renders "Loading…" while `state` loads, its error in an {@link ErrorCard},
+ * then `empty` or `children`. Those two are only built once loaded, so they can
+ * read the loaded fields without checking the status again.
+ */
+export function ListState(
+ { state, isEmpty, empty, children }: ListStateProps,
+): ReactNode {
+ if (state.status === "loading") {
+ return (
+
+ Loading…
+
+ );
+ }
+ if (state.status === "error") {
+ return ;
+ }
+ return isEmpty(state) ? empty : children(state);
+}
diff --git a/src/components/shared/LoadMoreButton.tsx b/src/components/shared/LoadMoreButton.tsx
new file mode 100644
index 00000000..9465bb30
--- /dev/null
+++ b/src/components/shared/LoadMoreButton.tsx
@@ -0,0 +1,35 @@
+/**
+ * Renders the button that loads a list's next page, where the list has no
+ * room to load it on scroll.
+ *
+ * @module
+ */
+
+import { Trans } from "@lingui/react/macro";
+
+/**
+ * Passed to {@link LoadMoreButton} by `Notifications`, `UserListPopover` and
+ * the profile's followed users, which render it while there is more to load.
+ */
+export interface LoadMoreButtonProps {
+ loadingMore: boolean;
+ onClick: () => void;
+ /** `load-more-btn` by default. */
+ className?: string;
+}
+
+/** Renders "Load more", disabled and reading "Loading…" while it loads. */
+export function LoadMoreButton(
+ { loadingMore, onClick, className = "load-more-btn" }: LoadMoreButtonProps,
+): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/components/shared/Segmented.tsx b/src/components/shared/Segmented.tsx
new file mode 100644
index 00000000..28b0cf75
--- /dev/null
+++ b/src/components/shared/Segmented.tsx
@@ -0,0 +1,51 @@
+/**
+ * Renders a choice among a few values as a row of buttons, in the
+ * `.visibility-toggle` style.
+ *
+ * @module
+ */
+
+import type { ReactNode } from "react";
+
+/**
+ * Passed in {@link SegmentedProps.options}. Its `value` is compared with `===`
+ * and must be unique as a string, since it keys the button.
+ */
+export interface SegmentedOption {
+ value: V;
+ label: ReactNode;
+}
+
+/**
+ * Passed to {@link Segmented} by `SegmentedField` for a form, and by the
+ * profile's settings and role switcher, which save each click at once.
+ */
+export interface SegmentedProps {
+ options: SegmentedOption[];
+ value: V;
+ onChange: (value: V) => void;
+ disabled?: boolean;
+ /** Added to `visibility-toggle`. */
+ className?: string;
+}
+
+/** Renders the options as buttons, the one equal to `value` marked active. */
+export function Segmented(
+ { options, value, onChange, disabled, className }: SegmentedProps,
+): React.JSX.Element {
+ return (
+
+ {options.map((opt) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/social/UserListPopover.tsx b/src/components/social/UserListPopover.tsx
index 9452d8dd..cc501276 100644
--- a/src/components/social/UserListPopover.tsx
+++ b/src/components/social/UserListPopover.tsx
@@ -11,6 +11,7 @@ import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Avatar } from "../shared/Avatar.tsx";
+import { LoadMoreButton } from "../shared/LoadMoreButton.tsx";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
@@ -251,16 +252,11 @@ export function UserListPopover(
))}
{state.hasMore && (
-
+ />
)}
>
)}
diff --git a/src/contexts/ws/WSContext.ts b/src/contexts/ws/WSContext.ts
index 17fb0200..78614510 100644
--- a/src/contexts/ws/WSContext.ts
+++ b/src/contexts/ws/WSContext.ts
@@ -62,7 +62,7 @@ export interface CommentEvent {
/**
* Set as `lastUserEvent` by `WSProvider` on a `user_updated`, which the
- * server sends on any change to a profile. `UserPublicProfile` applies it.
+ * server sends on any change to a profile. `useProfile` applies it.
*/
export interface UserEvent {
user: PublicUser;
diff --git a/src/hooks/dump/useUpvotedList.ts b/src/hooks/dump/useUpvotedList.ts
new file mode 100644
index 00000000..bb78d4fc
--- /dev/null
+++ b/src/hooks/dump/useUpvotedList.ts
@@ -0,0 +1,142 @@
+/**
+ * Keeps a list of the dumps a user upvoted in step with their votes.
+ *
+ * @module
+ */
+
+import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
+
+import { API_URL } from "../../config/api.ts";
+import type { Dump } from "../../../shared/dump.ts";
+import type { Wire } from "../../../shared/api.ts";
+import { expectOk } from "../../utils/shared/api.ts";
+import { deserializeDump } from "../../utils/shared/deserialize.ts";
+import { useAuth } from "../auth/useAuth.ts";
+import { useWS } from "../ws/useWS.ts";
+import { fadeClassName, useFading } from "../feed/useFading.ts";
+
+/** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */
+export interface UseUpvotedListOptions {
+ /** Whose upvotes the list holds, null while their profile loads. */
+ profileUserId: string | null;
+ /** Does nothing until the list is loaded. */
+ setItems: (fn: (prev: Dump[]) => Dump[]) => void;
+ /**
+ * Dumps already at hand, which a vote of the signed-in user on their own
+ * list puts first at once, before the socket's vote event. The profile
+ * passes its first page of the user's dumps.
+ */
+ source?: Dump[];
+}
+
+/** Returned by {@link useUpvotedList}. */
+export interface UseUpvotedListResult {
+ /** For `DumpCardList`'s `cardClassName`. */
+ cardClassName: (dump: Dump) => string | undefined;
+ /**
+ * Whether the vote on dump `id` was withdrawn since the list loaded. The
+ * profile's `usePositionAwareSync` keeps such a dump from coming back.
+ */
+ isWithdrawn: (id: string) => boolean;
+}
+
+/**
+ * Follows the votes of `profileUserId` into their upvoted list. A dump they
+ * upvote is fetched and put first. One whose vote they withdraw fades out
+ * (`useFading`), then leaves the list, unless they vote again meanwhile. The
+ * socket's vote events report every browser's votes; on one's own list,
+ * `myVotes` also reports this tab's at once.
+ */
+export function useUpvotedList(
+ { profileUserId, setItems, source }: UseUpvotedListOptions,
+): UseUpvotedListResult {
+ const { user: me, token } = useAuth();
+ const { myVotes, lastVoteEvent } = useWS();
+ const isOwn = !!profileUserId && me?.id === profileUserId;
+
+ const withdrawnRef = useRef>(new Set());
+ const prevMyVotesRef = useRef | null>(null);
+ // A ref, so that a new first page doesn't count as new votes.
+ const sourceRef = useRef(source);
+ useLayoutEffect(() => {
+ sourceRef.current = source;
+ });
+
+ const { fading, startFading, cancelFading, cancelAll } = useFading((id) =>
+ setItems((items) => items.filter((d) => d.id !== id))
+ );
+
+ const withdraw = useCallback((id: string) => {
+ withdrawnRef.current.add(id);
+ startFading(id);
+ }, [startFading]);
+ const recast = useCallback((id: string) => {
+ withdrawnRef.current.delete(id);
+ cancelFading(id);
+ }, [cancelFading]);
+
+ // Another user's list starts afresh. This runs before the effect below that
+ // diffs the votes, so its baseline resets in time.
+ useEffect(() => {
+ withdrawnRef.current = new Set();
+ prevMyVotesRef.current = null;
+ cancelAll();
+ }, [profileUserId, cancelAll]);
+
+ useEffect(() => {
+ if (!isOwn) return;
+ const prev = prevMyVotesRef.current;
+ prevMyVotesRef.current = new Set(myVotes);
+ // Only a baseline: the list as loaded stands. myVotes may still be the
+ // empty set from before the socket's welcome (or the socket may never
+ // connect), and taking it as the truth here would hide every dump.
+ if (prev === null) return;
+ for (const id of prev) if (!myVotes.has(id)) withdraw(id);
+ const added = new Set([...myVotes].filter((id) => !prev.has(id)));
+ for (const id of added) recast(id);
+ const known = (sourceRef.current ?? []).filter((d) => added.has(d.id));
+ if (known.length === 0) return;
+ setItems((items) => {
+ const ids = new Set(items.map((d) => d.id));
+ const toAdd = known.filter((d) => !ids.has(d.id));
+ return toAdd.length === 0 ? items : [...toAdd, ...items];
+ });
+ }, [myVotes, isOwn, withdraw, recast, setItems]);
+
+ useEffect(() => {
+ if (!lastVoteEvent || !profileUserId) return;
+ const { dumpId, voterId, action } = lastVoteEvent;
+ if (voterId !== profileUserId) return;
+ if (action === "remove") {
+ withdraw(dumpId);
+ return;
+ }
+ recast(dumpId);
+ // Fetched signed in, or a private dump the viewer owns would be refused.
+ fetch(`${API_URL}/api/dumps/${dumpId}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ })
+ .then(expectOk>)
+ .then((wire) => {
+ const dump = deserializeDump(wire);
+ setItems((items) =>
+ // A withdrawn vote cast again before its dump left the list.
+ items.some((d) => d.id === dumpId)
+ ? items.map((d) => d.id === dumpId ? dump : d)
+ : [dump, ...items]
+ );
+ })
+ .catch(() => {});
+ }, [lastVoteEvent, profileUserId, token, withdraw, recast, setItems]);
+
+ const cardClassName = useCallback(
+ (dump: Dump) => fadeClassName(fading[dump.id]),
+ [fading],
+ );
+ const isWithdrawn = useCallback(
+ (id: string) => withdrawnRef.current.has(id),
+ [],
+ );
+
+ return { cardClassName, isWithdrawn };
+}
diff --git a/src/hooks/dump/useUserDumpFeed.ts b/src/hooks/dump/useUserDumpFeed.ts
index e051bf76..e9fdfa2f 100644
--- a/src/hooks/dump/useUserDumpFeed.ts
+++ b/src/hooks/dump/useUserDumpFeed.ts
@@ -5,29 +5,23 @@
* @module
*/
-import {
- type RefCallback,
- useCallback,
- useEffect,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
+import { type RefCallback, useCallback, useEffect, useState } from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
+import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
+import { fetchFirstPage } from "../../utils/feed/pagedList.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
deserializeDump,
- deserializePublicUser,
hydrateDump,
} from "../../utils/shared/deserialize.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useFeedCache } from "../feed/useFeedCache.ts";
import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
-import { useScrollSave } from "../feed/useScrollSave.ts";
+import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
/**
* Held by {@link useUserDumpFeed} for `UserDumps` and `UserUpvoted`. It fails
@@ -46,18 +40,10 @@ export type UserDumpFeedState =
loadingMore: boolean;
};
-/** Passed to {@link useUserDumpFeed} by `UserUpvoted`. */
-export interface UseUserDumpFeedOptions {
- /** Called with the dumps of each page after the first, once appended. */
- onItemsAppended?: (items: Dump[]) => void;
-}
-
/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */
export interface UseUserDumpFeedResult {
state: UserDumpFeedState;
- /** Lets `UserUpvoted` prepend a dump the user just upvoted. */
- setState: React.Dispatch>;
- /** Does nothing until loaded. Passed to `useDumpListSync`. */
+ /** Does nothing until loaded. Passed to `useDumpListSync` and `useUpvotedList`. */
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
/** From `useInfiniteScroll`, for an element after the last card. */
sentinelRef: RefCallback;
@@ -75,7 +61,6 @@ export function useUserDumpFeed(
username: string | undefined,
endpoint: string,
cacheKey: string,
- options?: UseUserDumpFeedOptions,
): UseUserDumpFeedResult {
const { token } = useAuth();
const { cached, saveState } = useFeedCache(cacheKey, hydrateDump);
@@ -94,64 +79,32 @@ export function useUserDumpFeed(
useEffect(() => {
if (!username) return;
const controller = new AbortController();
-
- if (cached) {
- fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
- .then((r) => r.json())
- .then((body) => {
- if (!body.success) throw new Error("User not found");
- setState({
- status: "loaded",
- profileUser: deserializePublicUser(body.data),
- items: cached.items,
- hasMore: cached.hasMore,
- page: cached.page,
- loadingMore: false,
- });
- })
- .catch((err) => {
- if (err.name === "AbortError") return;
- setState({ status: "error", error: friendlyFetchError(err) });
- });
- return () => controller.abort();
- }
-
- const authHeaders: HeadersInit = token
- ? { Authorization: `Bearer ${token}` }
- : {};
- Promise.all([
- fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
- fetch(
- `${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`,
- { headers: authHeaders, signal: controller.signal },
- ),
- ])
- .then(([userRes, itemsRes]) =>
- Promise.all([userRes.json(), itemsRes.json()])
- )
- .then(([userBody, itemsBody]) => {
- if (!userBody.success) throw new Error("User not found");
- const { items, hasMore } = itemsBody.success
- ? itemsBody.data as PaginatedData>
- : { items: [], hasMore: false };
+ const { signal } = controller;
+ // Back on the page, the list comes from the cache and only the user is
+ // fetched again.
+ const list = cached ?? fetchFirstPage(
+ `${API_URL}/api/users/${username}/${endpoint}`,
+ deserializeDump,
+ { headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
+ );
+ Promise.all([fetchPublicUser(username, signal), list])
+ .then(([profileUser, { items, hasMore, page }]) =>
setState({
status: "loaded",
- profileUser: deserializePublicUser(userBody.data),
- items: items.map(deserializeDump),
+ profileUser,
+ items,
hasMore,
- page: 1,
+ page,
loadingMore: false,
- });
- })
+ })
+ )
.catch((err) => {
- if (err.name === "AbortError") return;
+ if (signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, endpoint, cached, token]);
- const { onItemsAppended } = options ?? {};
-
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
@@ -177,14 +130,13 @@ export function useUserDumpFeed(
}
: s
);
- onItemsAppended?.(newItems);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
- }, [state, username, token, endpoint, onItemsAppended]);
+ }, [state, username, token, endpoint]);
const sentinelRef = useInfiniteScroll(
loadMore,
@@ -199,14 +151,7 @@ export function useUserDumpFeed(
}, [state, saveState]),
);
- const scrollRestored = useRef(false);
- useLayoutEffect(() => {
- if (cached?.scrollY == null || scrollRestored.current) return;
- if (state.status === "loaded") {
- globalThis.scrollTo(0, cached.scrollY);
- scrollRestored.current = true;
- }
- }, [state.status, cached]);
+ useScrollRestore(cached?.scrollY, state.status === "loaded");
- return { state, setState, setItems, sentinelRef };
+ return { state, setItems, sentinelRef };
}
diff --git a/src/hooks/feed/useFading.ts b/src/hooks/feed/useFading.ts
index 6e8100e2..087f046e 100644
--- a/src/hooks/feed/useFading.ts
+++ b/src/hooks/feed/useFading.ts
@@ -1,100 +1,114 @@
/**
- * Times the fade-out of dumps leaving an upvoted list, so that a vote withdrawn
- * by mistake can be cast again before the dump is gone.
+ * Times the fade-out of items leaving a list, so that a removal made by
+ * mistake can be undone before the item is gone.
*
* @module
*/
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
/**
- * The phase of a fading item, which the list maps to a CSS class. `cooldown`
- * lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS collapse.
+ * The phase of a fading item, which {@link fadeClassName} maps to a CSS class.
+ * `cooldown` lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS
+ * collapse.
*/
export type FadeState = "cooldown" | "dismissing";
+const COOLDOWN_MS = 2000;
+const DISMISS_MS = 350;
+
/**
- * Returned by {@link useFading} to `UserUpvoted` and the profile's upvoted
- * section. The list keeps showing an item while it is in `fading`, then drops
- * it.
+ * Returned by {@link useFading} to `useUpvotedList` and `PlaylistDetail`. The
+ * list keeps showing an item while it is in `fading`.
*/
export interface UseFadingResult {
fading: Record;
/** Does nothing when `id` is already fading. */
startFading: (id: string) => void;
- /** Called when the vote is cast again: the item stays. */
+ /** Called when the removal is undone: the item stays. */
cancelFading: (id: string) => void;
- /** Called when the list changes to another user's. */
+ /** Called when the list is replaced, as by another user's or a reload. */
cancelAll: () => void;
}
/**
- * Returns the fading state of a list's items and the calls that drive it. The
- * timers are cleared on unmount.
+ * Returns the fading state of a list's items and the calls that drive it.
+ * `onFaded` is called with an item whose fade ran to its end, for the list to
+ * drop it; a cancelled fade doesn't call it. The timers are cleared on
+ * unmount.
*/
-export function useFading(): UseFadingResult {
+export function useFading(onFaded?: (id: string) => void): UseFadingResult {
const [fading, setFading] = useState>({});
- const cancels = useRef
- )}
- {dumps.length > 0 && (
-
- View all →
-
- )}
-
- );
-}
-
-/**
- * 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,
- dumps,
- profileUserId,
- isOwnProfile,
- voteCounts,
- myVotes,
- canVote,
- castVote,
- removeVote,
- viewAllHref,
- }: {
- title: string;
- dumps: Dump[];
- profileUserId: string | null;
- isOwnProfile: boolean;
- voteCounts: Record;
- myVotes: Set;
- canVote: boolean;
- castVote: (id: string, shownCount?: number) => void;
- removeVote: (id: string, shownCount?: number) => void;
- viewAllHref: string;
- },
-) {
- const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
- const { fading, startFading, cancelFading } = useFading();
-
- // 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 | null>(null);
-
- // 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. myVotes may still be empty, from before the socket's
- // welcome, and would hide every dump.
- prevMyVotesRef.current = new Set(wsMyVotes);
- return;
- }
- const prev = prevMyVotesRef.current;
- setVotedIds((ids) => {
- const next = new Set(ids);
- for (const id of prev) if (!wsMyVotes.has(id)) next.delete(id);
- for (const id of wsMyVotes) next.add(id);
- return next;
- });
- for (const id of prev) if (!wsMyVotes.has(id)) startFading(id);
- for (const id of wsMyVotes) if (!prev.has(id)) cancelFading(id);
- prevMyVotesRef.current = new Set(wsMyVotes);
- }, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
-
- // 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") {
- // 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);
- n.delete(dumpId);
- return n;
- });
- startFading(dumpId);
- } else {
- setVotedIds((prev) => new Set([...prev, dumpId]));
- cancelFading(dumpId);
- }
- }, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);
-
- const visibleDumps = dumps.filter((d) =>
- votedIds.has(d.id) || d.id in fading
- );
-
- return (
-
-
- );
-}
-
-/** Renders a followed user, with a button to follow or unfollow them. */
-function FollowedUserCard({ user }: { user: PublicUser }) {
- return (
-
-
-
- @{user.username}
-
-
-
- );
-}
-
-const ROLE_OPTIONS: { value: Role; label: React.ReactNode }[] = [
- { value: "user", label: User },
- { value: "moderator", label: Moderator },
- { value: "admin", label: Admin },
-];
-
-/**
- * Renders the role badge next to the username, for moderators and admins
- * only.
- */
-function RoleChip({ role }: { role: Role }) {
- if (role === "user") return null;
- const label = ROLE_OPTIONS.find((o) => o.value === role)?.label;
- return (
-
- {label}
-
- );
-}
-
-interface RoleSwitcherProps {
- userId: string;
- currentRole: Role;
- onSaved: (role: Role) => void;
-}
-
-/**
- * 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();
- const [saving, setSaving] = useState(false);
- const [error, setError] = useState(null);
-
- async function changeRole(role: Role) {
- if (role === currentRole || saving) return;
- setSaving(true);
- setError(null);
- try {
- const res = await authFetch(`${API_URL}/api/users/${userId}/role`, {
- method: "PATCH",
- body: JSON.stringify({ role }),
- });
- const body = parseAPIResponse>(await res.json());
- if (!body.success) {
- setError(body.error.message);
- return;
- }
- onSaved(deserializePublicUser(body.data).role);
- } catch (err) {
- setError(friendlyFetchError(err));
- } finally {
- setSaving(false);
- }
- }
-
- return (
- <>
-
-
- Role
-
-
- {ROLE_OPTIONS.map((opt) => (
-
- ))}
-
-
- {error && }
- >
- );
-}
-
-interface EmailEditorProps {
- initialEmail: string;
- onCancel: () => void;
- onSaved: (email: string) => void;
-}
-
-/**
- * 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 }>({
- defaultValues: { email: initialEmail },
- });
- const email = form.watch("email");
- const submitting = form.formState.isSubmitting;
-
- const onSubmit = form.submit(async ({ email }) => {
- await expectOk>(
- await authFetch(`${API_URL}/api/users/me`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(
- { email: email.trim() } satisfies UpdateUserRequest,
- ),
- }),
- );
- onSaved(email.trim());
- });
-
- return (
-
-
-
- );
-}
-
-interface DescriptionEditorProps {
- initialDescription: string;
- onCancel: () => void;
- onSaved: (description: string | undefined) => void;
-}
-
-/**
- * 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,
-) {
- const { authFetch } = useAuth();
- const form = useApiForm<{ description: string }>({
- defaultValues: { description: initialDescription },
- });
- const description = form.watch("description");
- const submitting = form.formState.isSubmitting;
-
- const onSubmit = form.submit(async ({ description }) => {
- const trimmed = description.trim();
- // Sent even when empty: left out, the server would keep the old one.
- await expectOk>(
- await authFetch(`${API_URL}/api/users/me`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(
- { description: trimmed } satisfies UpdateUserRequest,
- ),
- }),
- );
- onSaved(trimmed || undefined);
- });
-
- return (
-
-
-
- );
-}
diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx
index 10d49c1f..7b6560ee 100644
--- a/src/pages/UserUpvoted.tsx
+++ b/src/pages/UserUpvoted.tsx
@@ -5,150 +5,41 @@
* @module
*/
-import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
-import { API_URL } from "../config/api.ts";
-import type { Dump } from "../../shared/dump.ts";
-import { deserializeDump } from "../utils/shared/deserialize.ts";
-import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
-import { useWS } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
-import { useFading } from "../hooks/feed/useFading.ts";
+import { useUpvotedList } from "../hooks/dump/useUpvotedList.ts";
import { useUserDumpFeed } from "../hooks/dump/useUserDumpFeed.ts";
-import { DumpCard } from "../components/dump/DumpCard.tsx";
+import { DumpCardList } from "../components/dump/DumpCardList.tsx";
+import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
/**
* 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.
+ * `useUserDumpFeed`, and `useUpvotedList` keeps them in step with the user's
+ * votes.
*/
export function UserUpvoted(): React.JSX.Element {
const { username } = useParams();
- const { user: me, token } = useAuth();
- const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
useDocumentTitle(username ?? null);
- // The dumps still upvoted. A loaded dump outside it shows only while it
- // fades.
- const [votedIds, setVotedIds] = useState>(new Set());
- const { fading, startFading, cancelFading, cancelAll } = useFading();
- const prevMyVotesRef = useRef | null>(null);
-
- const onItemsAppended = useCallback((newItems: Dump[]) => {
- setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
- }, []);
-
- const { state, setState, setItems, sentinelRef } = useUserDumpFeed(
+ const { state, setItems, sentinelRef } = useUserDumpFeed(
username,
"votes",
`feed:user-upvoted-full:${username ?? ""}`,
- { onItemsAppended },
);
useDumpListSync(setItems);
-
- const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
-
- const [prevUsername, setPrevUsername] = useState(username);
- if (prevUsername !== username) {
- setPrevUsername(username);
- setVotedIds(new Set());
- }
- useEffect(() => {
- cancelAll();
- prevMyVotesRef.current = null;
- }, [username, cancelAll]);
-
- const [prevStateStatus, setPrevStateStatus] = useState(state.status);
- const [prevStateItems, setPrevStateItems] = useState(
- state.status === "loaded" ? state.items : null,
- );
- const currentItems = state.status === "loaded" ? state.items : null;
- if (
- prevStateStatus !== state.status ||
- prevStateItems !== currentItems
- ) {
- setPrevStateStatus(state.status);
- setPrevStateItems(currentItems);
- if (state.status === "loaded") {
- setVotedIds(new Set(state.items.map((d) => d.id)));
- }
- }
-
- useEffect(() => {
- if (!profileUserId || me?.id !== profileUserId) 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 (or the socket may never
- // connect), and taking it as the truth here would hide every dump.
- prevMyVotesRef.current = new Set(myVotes);
- return;
- }
- const prev = prevMyVotesRef.current;
- // 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);
- for (const id of myVotes) next.add(id);
- return next;
- });
- for (const id of prev) if (!myVotes.has(id)) startFading(id);
- for (const id of myVotes) if (!prev.has(id)) cancelFading(id);
- prevMyVotesRef.current = new Set(myVotes);
- }, [myVotes, me, profileUserId, startFading, cancelFading]);
-
- useEffect(() => {
- if (!lastVoteEvent || !profileUserId) return;
- const { dumpId, voterId, action } = lastVoteEvent;
- if (voterId !== profileUserId) return;
-
- if (action === "remove") {
- // 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);
- n.delete(dumpId);
- return n;
- });
- startFading(dumpId);
- } else {
- setVotedIds((prev) => new Set([...prev, dumpId]));
- cancelFading(dumpId);
- // Signed in, or a private dump the viewer owns can't be fetched.
- fetch(`${API_URL}/api/dumps/${dumpId}`, {
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- })
- .then((r) => r.json())
- .then((body) => {
- if (!body.success) return;
- const dump = deserializeDump(body.data);
- setState((s) => {
- if (s.status !== "loaded" || s.items.some((d) => d.id === dumpId)) {
- return s;
- }
- return { ...s, items: [dump, ...s.items] };
- });
- })
- .catch(() => {});
- }
- }, [
- lastVoteEvent,
- profileUserId,
- startFading,
- cancelFading,
- setState,
- token,
- ]);
+ const { cardClassName } = useUpvotedList({
+ profileUserId: state.status === "loaded" ? state.profileUser.id : null,
+ setItems,
+ });
if (state.status === "loading") {
return (
@@ -174,9 +65,6 @@ export function UserUpvoted(): React.JSX.Element {
}
const { profileUser, items: votes, hasMore, loadingMore } = state;
- const visibleDumps = votes.filter((d) =>
- votedIds.has(d.id) || d.id in fading
- );
return (
@@ -185,7 +73,7 @@ export function UserUpvoted(): React.JSX.Element {
profileUser={profileUser}
title={t`Upvoted`}
/>
- {visibleDumps.length === 0
+ {votes.length === 0
? (
Nothing here yet.
@@ -193,40 +81,12 @@ export function UserUpvoted(): React.JSX.Element {
)
: (
<>
-