v3: the profile page is split by one rule, and what it re-invented is shared across the app — UserPublicProfile was 1,949 lines because it held its data, six tabs, seven subcomponents and hand-rolled copies of things the app already had or repeated elsewhere. The page now only picks the tab and puts the pieces together (253 lines); useProfile holds the user and the three lists with their live sync, cache and scroll; each tab and header piece is one file under components/profile/; the followed and invitees tabs load on first open through the new useFetchState, keyed by username, which keeps them across tab switches. Every piece taken out replaces its copies: DumpCardList, which reads the votes from WSContext, is every list of dump cards (the index feeds, followed, search, related dumps, UserDumps, UserUpvoted, the profile), so Index no longer threads vote props into the feeds; PlaylistCardList every list of playlist cards; FeedFooter the eight sentinel/loading-more/end blocks; ListState the loading/error/empty chains; Segmented the settings toggles, RoleSwitcher and the playlist edit form, with SegmentedField built on it; LoadMoreButton the three load-more buttons; NewDumpButton UserDumps' and the profile's; useScrollRestore four copies of the scroll restore, and the profile uses useScrollSave instead of saving its whole cache on every scroll. useFading takes an onFaded callback, which replaces PlaylistDetail's copy of its timers; useUpvotedList is the vote tracking UserUpvoted and the profile each had, and a withdrawn upvote now leaves the list and the cache once faded (it was only hidden, and came back after a tab switch). usePlaylistMemberships is the membership load and toggle AddToPlaylistModal and DumpCreateModal each had. pagedList.ts (PagedList, firstPage, restoredList, fetchFirstPage, withFirst), fetchPublicUser and dataOr are shared by the profile, UserPlaylists and useUserDumpFeed; a refused side list still shows empty, as documented, and only the user fails the page. FollowedFeed's two sections are one local hook, and its readPage is expectOk. A card is its owner's when the item is the signed-in user's, everywhere: the profile's upvoted column marked every dump as the owner's on one's own profile. No message changed; the catalogs only move their references. 643 frontend tests pass (11 new for useFetchState, useFading's onFaded and pagedList), and tsc, eslint, doc:lint, deno check and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 48s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 20:50:49 +00:00
parent 80f3292c8f
commit 69d9bf899b
54 changed files with 3460 additions and 3601 deletions

View File

@@ -39,8 +39,8 @@ import { Avatar } from "../components/shared/Avatar.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { VoteButton } from "../components/social/VoteButton.tsx";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { Markdown } from "../components/editor/Markdown.tsx";
@@ -560,19 +560,7 @@ export function Dump(): React.JSX.Element {
<h2 className="related-section-title">
<Trans>Related</Trans>
</h2>
<ul className="dump-feed">
{relatedDumps.map((related) => (
<DumpCard
key={related.id}
dump={related}
voteCount={voteCounts[related.id] ?? related.voteCount}
voted={myVotes.has(related.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
/>
))}
</ul>
<DumpCardList dumps={relatedDumps} />
</section>
)}
@@ -581,11 +569,7 @@ export function Dump(): React.JSX.Element {
<h2 className="related-section-title">
<Trans>In collections</Trans>
</h2>
<ul className="dump-feed">
{collections.map((collection) => (
<PlaylistCard key={collection.id} playlist={collection} />
))}
</ul>
<PlaylistCardList playlists={collections} />
</section>
)}

View File

@@ -5,14 +5,7 @@
* @module
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/layout/AppHeader.tsx";
@@ -33,7 +26,10 @@ import type { PaginatedData, Wire } from "../../shared/api.ts";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
import {
useScrollRestore,
useScrollSave,
} from "../hooks/feed/useScrollSave.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useWS } from "../hooks/ws/useWS.ts";
@@ -41,22 +37,15 @@ import { useCategories } from "../hooks/category/useCategories.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
import {
DumpFeed,
type DumpFeedState,
type MainFeedProps,
} from "../components/feed/DumpFeed.tsx";
import { JournalFeed } from "../components/feed/JournalFeed.tsx";
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
import { NotFound } from "./NotFound.tsx";
type DumpsState =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
/**
* Passed to {@link Index} by the router, from the `:categorySlug` of the URL.
*/
@@ -83,15 +72,7 @@ export function Index(
?.deletedDumpId;
const { user, token } = useAuth();
const {
voteCounts,
myVotes,
recentDumps,
deletedDumpIds,
castVote,
removeVote,
connectionEpoch,
} = useWS();
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
const { categories, isLoaded: categoriesLoaded } = useCategories();
const category = categorySlug
@@ -130,7 +111,7 @@ export function Index(
`feed:index:${categorySlug ?? "all"}`,
hydrateDump,
);
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
const [dumpsState, setDumpsState] = useState<DumpFeedState>(() =>
cached
? {
status: "loaded",
@@ -264,23 +245,12 @@ export function Index(
}, [dumpsState, saveState]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (dumpsState.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [dumpsState.status, cached]);
useScrollRestore(cached?.scrollY, dumpsState.status === "loaded");
const loading = dumpsState.status === "loading";
const error = dumpsState.status === "error" ? dumpsState.error : null;
const dumps = useMemo(
() => dumpsState.status === "loaded" ? dumpsState.dumps : [],
[dumpsState],
);
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
const hasMore = dumpsState.status === "loaded" ? dumpsState.hasMore : false;
// A category's feed takes only the new dumps filed under it.
const categoryId = category?.id;
@@ -303,17 +273,9 @@ export function Index(
);
const mainFeedProps: MainFeedProps = {
state: dumpsState,
dumps: combined,
loading,
error,
hasMore,
loadingMore,
sentinelRef,
voteCounts,
myVotes,
user,
castVote,
removeVote,
};
if (categoryNotFound) return <NotFound />;
@@ -344,14 +306,7 @@ export function Index(
)}
{tab === "journal" && <JournalFeed {...mainFeedProps} />}
{tab === "followed" && user && (
<FollowedFeed
categorySlug={categorySlug}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
/>
<FollowedFeed categorySlug={categorySlug} />
)}
</div>
);

View File

@@ -17,6 +17,7 @@ import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx";
import { LoadMoreButton } from "../components/shared/LoadMoreButton.tsx";
import { useWS } from "../hooks/ws/useWS.ts";
import type {
CommentLikedData,
@@ -508,16 +509,7 @@ export function Notifications(): React.JSX.Element {
))}
{state.status === "loaded" && state.hasMore && (
<button
type="button"
className="load-more-btn"
onClick={loadMore}
disabled={state.loadingMore}
>
{state.loadingMore
? <Trans>Loading…</Trans>
: <Trans>Load more</Trans>}
</button>
<LoadMoreButton loadingMore={state.loadingMore} onClick={loadMore} />
)}
</div>
</PageShell>

View File

@@ -36,6 +36,7 @@ import { can } from "../utils/auth/permissions.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { fadeClassName, useFading } from "../hooks/feed/useFading.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { DumpCard } from "../components/dump/DumpCard.tsx";
@@ -48,6 +49,7 @@ import { Markdown } from "../components/editor/Markdown.tsx";
import { TextEditor } from "../components/editor/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/social/FollowButton.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx";
import { VisibilitySegmented } from "../components/form/SegmentedField.tsx";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import { Controller, FormProvider } from "react-hook-form";
import { FormError } from "../components/form/FormError.tsx";
@@ -98,11 +100,19 @@ export function PlaylistDetail(): React.JSX.Element {
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
// 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());
// slides out, as on the upvoted page, and leaves `state.playlist.dumps`.
const { fading, startFading, cancelFading, cancelAll } = useFading(
(id) =>
setState((prev) =>
prev.status !== "loaded" ? prev : {
...prev,
playlist: {
...prev.playlist,
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
},
}
),
);
// 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.
@@ -139,7 +149,6 @@ export function PlaylistDetail(): React.JSX.Element {
});
useEffect(() => () => {
cancels.current.forEach((c) => c());
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
}, []);
@@ -176,9 +185,7 @@ export function PlaylistDetail(): React.JSX.Element {
setActiveDumpIds(ids);
dumpOrderRef.current = order;
for (const id of ids) knownDumpIdsRef.current.add(id);
setFading({});
cancels.current.forEach((c) => c());
cancels.current.clear();
cancelAll();
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
dumpReorderTimerRef.current = null;
@@ -191,77 +198,13 @@ export function PlaylistDetail(): React.JSX.Element {
error: friendlyFetchError(err),
});
});
}, [playlistId, token]);
}, [playlistId, token, cancelAll]);
useEffect(() => {
fetchPlaylist();
return () => fetchAbortRef.current?.abort();
}, [fetchPlaylist]);
// 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;
let dead = false;
let kill = () => {};
kill = () => {
dead = true;
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (dead) return;
setState((prev) => {
if (prev.status !== "loaded") return prev;
return {
...prev,
playlist: {
...prev.playlist,
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
},
};
});
kill();
}, 350);
kill = () => {
dead = true;
clearTimeout(t2);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}, 2000);
kill = () => {
dead = true;
clearTimeout(t1);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
cancels.current.delete(id);
};
cancels.current.set(id, () => kill());
}
// The socket's changes to this playlist: its dumps, its details, its end.
useEffect(() => {
if (!lastPlaylistEvent || !playlistUUID) return;
@@ -283,7 +226,7 @@ export function PlaylistDetail(): React.JSX.Element {
n.delete(id);
return n;
});
startFade(id);
startFading(id);
}
}
@@ -291,7 +234,7 @@ export function PlaylistDetail(): React.JSX.Element {
// "loading", which unsets playlistUUID and runs this effect again.
for (const id of newIds) {
if (!prev.has(id)) {
cancels.current.get(id)?.();
cancelFading(id);
setActiveDumpIds((s) => new Set([...s, id]));
const orderedIds = ev.dumpIds!;
fetch(`${API_URL}/api/dumps/${id}`, {
@@ -366,7 +309,14 @@ export function PlaylistDetail(): React.JSX.Element {
} else if (ev.type === "deleted") {
navigate("/");
}
}, [lastPlaylistEvent, playlistUUID, navigate, token]);
}, [
lastPlaylistEvent,
playlistUUID,
navigate,
token,
startFading,
cancelFading,
]);
// A deleted dump leaves at once, without the removal's fade.
useEffect(() => {
@@ -497,7 +447,7 @@ export function PlaylistDetail(): React.JSX.Element {
).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}).catch(() => {
cancels.current.get(dumpId)?.();
cancelFading(dumpId);
knownDumpIdsRef.current.add(dumpId);
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// The fade may have taken it out of the list already.
@@ -516,12 +466,12 @@ export function PlaylistDetail(): React.JSX.Element {
n.delete(dumpId);
return n;
});
startFade(dumpId);
startFading(dumpId);
};
const handleCancelRemove = (dumpId: string) => {
if (!playlistId || state.status !== "loaded") return;
cancels.current.get(dumpId)?.();
cancelFading(dumpId);
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// A fading dump keeps its slot in `state.playlist.dumps`, so that order is
// the one to restore.
@@ -559,7 +509,7 @@ export function PlaylistDetail(): React.JSX.Element {
n.delete(dumpId);
return n;
});
startFade(dumpId);
startFading(dumpId);
setActionError(t`The dump couldn't be put back. Try again.`);
})
.finally(() => {
@@ -753,11 +703,6 @@ export function PlaylistDetail(): React.JSX.Element {
const phase = fading[dump.id];
// drag index is within the active-only list
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
const cardCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<div
@@ -786,7 +731,7 @@ export function PlaylistDetail(): React.JSX.Element {
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
className={cardCls}
className={fadeClassName(phase)}
isOwner={!!user && user.id === dump.userId}
/>
{canEdit && (isActive
@@ -980,22 +925,11 @@ function PlaylistEditForm(
name="isPublic"
control={form.control}
render={({ field }) => (
<div className="visibility-toggle playlist-edit-toggle">
<button
type="button"
className={field.value ? "active" : ""}
onClick={() => field.onChange(true)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={!field.value ? "active" : ""}
onClick={() => field.onChange(false)}
>
<Trans>Private</Trans>
</button>
</div>
<VisibilitySegmented
className="playlist-edit-toggle"
value={field.value}
onChange={field.onChange}
/>
)}
/>

View File

@@ -11,12 +11,12 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AppHeader } from "../components/layout/AppHeader.tsx";
import { TabBar } from "../components/shared/TabBar.tsx";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { useTabParam } from "../hooks/shared/useTabParam.ts";
import {
@@ -65,8 +65,7 @@ export function Search(): React.JSX.Element {
const q = searchParams.get("q") ?? "";
const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps");
const { token, user } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
const { token } = useAuth();
const [state, setState] = useState<SearchState>({ status: "idle" });
const abortRef = useRef<AbortController | null>(null);
@@ -246,32 +245,15 @@ export function Search(): React.JSX.Element {
{state.dumps.items.length === 0
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
: (
<ul className="dump-feed">
{state.dumps.items.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
<>
<DumpCardList dumps={state.dumps.items} />
<FeedFooter
hasMore={state.dumps.hasMore}
loadingMore={state.dumps.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
<div ref={sentinelRef} />
{state.dumps.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
@@ -306,13 +288,7 @@ export function Search(): React.JSX.Element {
{t`No playlists match "${q}".`}
</p>
)
: (
<ul className="dump-feed">
{state.playlists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
)
: <PlaylistCardList playlists={state.playlists} />
)}
</main>
</div>

View File

@@ -5,7 +5,6 @@
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
@@ -16,8 +15,9 @@ import { useWS } from "../hooks/ws/useWS.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts";
import { useUserDumpFeed } from "../hooks/dump/useUserDumpFeed.ts";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { DumpCreateModal } from "../components/dump/create/DumpCreateModal.tsx";
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
import { NewDumpButton } from "../components/dump/NewDumpButton.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";
@@ -31,7 +31,7 @@ import { PageError } from "../components/layout/PageError.tsx";
export function UserDumps(): React.JSX.Element {
const { username } = useParams();
const { user: me } = useAuth();
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
const { lastDumpEvent } = useWS();
useDocumentTitle(username ?? null);
@@ -41,8 +41,6 @@ export function UserDumps(): React.JSX.Element {
`feed:user-dumps-full:${username ?? ""}`,
);
const [createModalOpen, setCreateModalOpen] = useState(false);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId;
@@ -88,21 +86,8 @@ export function UserDumps(): React.JSX.Element {
username={username!}
profileUser={profileUser}
title={t`Dumps`}
actions={isOwnProfile && (
<button
type="button"
className="new-item-toggle"
onClick={() => setCreateModalOpen(true)}
>
+<span className="btn-new-label">
<Trans>New dump</Trans>
</span>
</button>
)}
actions={isOwnProfile && <NewDumpButton />}
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? (
<p className="empty-state">
@@ -111,31 +96,12 @@ export function UserDumps(): React.JSX.Element {
)
: (
<>
<ul className="dump-feed">
{dumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
/>
))}
</ul>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
<DumpCardList dumps={dumps} />
<FeedFooter
hasMore={hasMore}
loadingMore={loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</PageShell>

View File

@@ -5,13 +5,7 @@
* @module
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -23,7 +17,6 @@ import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts";
import {
deserializePlaylist,
deserializePublicUser,
hydratePlaylist,
} from "../utils/shared/deserialize.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
@@ -33,8 +26,20 @@ import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { useScrollSave } from "../hooks/feed/useScrollSave.ts";
import { PlaylistCard } from "../components/playlist/PlaylistCard.tsx";
import {
useScrollRestore,
useScrollSave,
} from "../hooks/feed/useScrollSave.ts";
import {
fetchFirstPage,
type PagedList,
restoredList,
withFirst,
} from "../utils/feed/pagedList.ts";
import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ProfileSection } from "../components/profile/ProfileSection.tsx";
import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx";
import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx";
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
@@ -42,28 +47,16 @@ import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
/** One of the page's two lists, as far as it is loaded. */
interface PlaylistFeed {
items: Playlist[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
created: PlaylistFeed;
followed: PlaylistFeed;
created: PagedList<Playlist>;
followed: PagedList<Playlist>;
};
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
return { items, hasMore, page: 1, loadingMore: false };
}
/**
* 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,
@@ -139,79 +132,29 @@ export function UserPlaylists(): React.JSX.Element {
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const { signal } = controller;
// 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())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
created: {
items: cachedCreated.items,
hasMore: cachedCreated.hasMore,
page: cachedCreated.page,
loadingMore: false,
},
followed: {
items: cachedFollowed.items,
hasMore: cachedFollowed.hasMore,
page: cachedFollowed.page,
loadingMore: false,
},
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const cached = cachedCreated && cachedFollowed;
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
fetchPublicUser(username, signal),
cached ? restoredList(cachedCreated) : fetchFirstPage(
`${API_URL}/api/users/${username}/playlists`,
deserializePlaylist,
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
),
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ signal: controller.signal },
cached ? restoredList(cachedFollowed) : fetchFirstPage(
`${API_URL}/api/users/${username}/followed-playlists`,
deserializePlaylist,
{ signal },
),
])
.then(([userRes, createdRes, followedRes]) =>
Promise.all([userRes.json(), createdRes.json(), followedRes.json()])
.then(([profileUser, created, followed]) =>
setState({ status: "loaded", profileUser, created, followed })
)
.then(([userBody, createdBody, followedBody]) => {
if (!userBody.success) throw new Error("User not found");
const createdData = createdBody.success
? createdBody.data as PaginatedData<Wire<Playlist>>
: { items: [], hasMore: false };
const followedData = followedBody.success
? followedBody.data as PaginatedData<Wire<Playlist>>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
created: initialFeed(
createdData.items.map(deserializePlaylist),
createdData.hasMore,
),
followed: initialFeed(
followedData.items.map(deserializePlaylist),
followedData.hasMore,
),
});
})
.catch((err) => {
if (err.name === "AbortError") return;
if (signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
@@ -326,14 +269,7 @@ export function UserPlaylists(): React.JSX.Element {
}, [state, saveCreated, saveFollowed]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cachedCreated?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cachedCreated.scrollY);
scrollRestored.current = true;
}
}, [state.status, cachedCreated]);
useScrollRestore(cachedCreated?.scrollY, state.status === "loaded");
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -348,17 +284,7 @@ export function UserPlaylists(): React.JSX.Element {
setDeleteError(friendlyFetchError(err));
return;
}
setState((s) =>
s.status === "loaded"
? {
...s,
created: {
...s.created,
items: s.created.items.filter((p) => p.id !== playlistId),
},
}
: s
);
setCreated((items) => items.filter((p) => p.id !== playlistId));
};
if (state.status === "loading") {
@@ -395,15 +321,7 @@ export function UserPlaylists(): React.JSX.Element {
actions={isOwnProfile && (
<NewPlaylistButton
toggleClassName="btn-primary"
onCreated={(p) =>
setState((s) => {
if (s.status !== "loaded") return s;
if (s.created.items.some((pl) => pl.id === p.id)) return s;
return {
...s,
created: { ...s.created, items: [p, ...s.created.items] },
};
})}
onCreated={(p) => setCreated((items) => withFirst(items, p))}
/>
)}
/>
@@ -412,15 +330,14 @@ export function UserPlaylists(): React.JSX.Element {
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Created ({created.items.length}
{created.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
<ProfileSection
title={
<Trans>
Created ({created.items.length}
{created.hasMore ? "+" : ""})
</Trans>
}
>
{created.items.length === 0
? (
<p className="empty-state">
@@ -428,41 +345,30 @@ export function UserPlaylists(): React.JSX.Element {
</p>
)
: (
<ul className="dump-feed">
{created.items.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
isOwner={isOwnProfile}
onDelete={isOwnProfile
? () => setConfirmDeleteId(p.id)
: undefined}
/>
))}
</ul>
<>
<PlaylistCardList
playlists={created.items}
onDelete={isOwnProfile
? (p) => setConfirmDeleteId(p.id)
: undefined}
/>
<FeedFooter
hasMore={created.hasMore}
loadingMore={created.loadingMore}
sentinelRef={createdSentinelRef}
/>
</>
)}
{created.hasMore && <div ref={createdSentinelRef} />}
{created.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!created.hasMore && created.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
</ProfileSection>
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
<ProfileSection
title={
<Trans>
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
</Trans>
}
>
{followed.items.length === 0
? (
<p className="empty-state">
@@ -470,24 +376,16 @@ export function UserPlaylists(): React.JSX.Element {
</p>
)
: (
<ul className="dump-feed">
{followed.items.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</ul>
<>
<PlaylistCardList playlists={followed.items} />
<FeedFooter
hasMore={followed.hasMore}
loadingMore={followed.loadingMore}
sentinelRef={followedSentinelRef}
/>
</>
)}
{followed.hasMore && <div ref={followedSentinelRef} />}
{followed.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!followed.hasMore && followed.items.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</section>
</ProfileSection>
{confirmDeleteId && (
<ConfirmModal

File diff suppressed because it is too large Load Diff

View File

@@ -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<Set<string>>(new Set());
const { fading, startFading, cancelFading, cancelAll } = useFading();
const prevMyVotesRef = useRef<Set<string> | 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 (
<PageShell>
@@ -185,7 +73,7 @@ export function UserUpvoted(): React.JSX.Element {
profileUser={profileUser}
title={t`Upvoted`}
/>
{visibleDumps.length === 0
{votes.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
@@ -193,40 +81,12 @@ export function UserUpvoted(): React.JSX.Element {
)
: (
<>
<ul className="dump-feed">
{visibleDumps.map((dump) => {
const phase = fading[dump.id];
const extraCls = phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
return (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!me}
castVote={castVote}
removeVote={removeVote}
className={extraCls}
isOwner={!!me && me.id === dump.userId}
/>
);
})}
</ul>
{hasMore && <div ref={sentinelRef} />}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
)}
{!hasMore && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
<DumpCardList dumps={votes} cardClassName={cardClassName} />
<FeedFooter
hasMore={hasMore}
loadingMore={loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</PageShell>