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

@@ -0,0 +1,51 @@
/**
* Renders a list of dumps as cards, wired to the live votes.
*
* @module
*/
import type { Dump } from "../../../shared/dump.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useWS } from "../../hooks/ws/useWS.ts";
import { DumpCard } from "./DumpCard.tsx";
/**
* Passed to {@link DumpCardList} by every list of dump cards: the index's
* "hot", "new" and "followed" feeds, the search results, a dump's related
* dumps, and a profile's dumps and upvotes.
*/
export interface DumpCardListProps {
dumps: Dump[];
/** A class for one card, as `fadeClassName` gives a fading one. */
cardClassName?: (dump: Dump) => string | undefined;
}
/**
* Renders `dumps` as a `.dump-feed` of {@link DumpCard}. The vote counts and
* calls come from `WSContext`, a dump missing from its counts showing its
* fetched `voteCount`. Voting takes a signed-in user, and a card is its
* owner's when it is the signed-in user's own dump.
*/
export function DumpCardList(
{ dumps, cardClassName }: DumpCardListProps,
): React.JSX.Element {
const { user } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS();
return (
<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={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={!!user && user.id === dump.userId}
className={cardClassName?.(dump)}
/>
))}
</ul>
);
}

View File

@@ -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 (
<>
<button
type="button"
className="new-item-toggle"
onClick={() => setOpen(true)}
>
+<span className="btn-new-label">
<Trans>New dump</Trans>
</span>
</button>
{open && <DumpCreateModal onClose={() => setOpen(false)} />}
</>
);
}

View File

@@ -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<PlaylistMembership[]>([]);
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<PlaylistMembership>[]).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(
<PlaylistMembershipPanel
dumpId={createdDump?.id ?? ""}
memberships={memberships}
loading={playlistsLoading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
memberships={playlists.memberships}
loading={playlists.loading}
onToggle={playlists.toggle}
onPlaylistCreated={playlists.add}
/>
<div className="form-actions">

View File

@@ -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<HTMLDivElement>;
/**
* Counts pushed by the socket. A dump missing here shows its fetched
* `voteCount`.
*/
voteCounts: Record<string, number>;
myVotes: Set<string>;
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 (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
<>
<ul className="dump-feed">
{sorted.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}
<ListState
state={state}
isEmpty={() => sorted.length === 0}
empty={
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
}
>
{({ hasMore, loadingMore }) => (
<>
<DumpCardList dumps={sorted} />
<FeedFooter
hasMore={hasMore}
loadingMore={loadingMore}
sentinelRef={sentinelRef}
/>
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
</>
)}
{!hasMore && sorted.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
</ListState>
);
}

View File

@@ -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<HTMLDivElement>;
}
/**
* 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 && <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>
)}
</>
);
}

View File

@@ -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<PaginatedData<Wire<Dump>>> {
const body = await res.json();
if (!body.success) {
throw new Error(body.error?.message ?? `HTTP ${res.status}`);
}
return body.data as PaginatedData<Wire<Dump>>;
}
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/<section>`, 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<string, number>;
myVotes: Set<string>;
/** `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<Dump>(
`feed:followed-${section}:${categorySlug ?? "all"}`,
hydrateDump,
);
const [state, setState] = useState<DumpFeedState>(() =>
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<PaginatedData<Wire<Dump>>>)
.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<PaginatedData<Wire<Dump>>>)
.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<string, number>;
myVotes: Set<string>;
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 (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (state.status === "error") {
return <ErrorCard title={t`Failed to load`} message={state.error} />;
}
if (state.dumps.length === 0) {
return <p className="index-status">{emptyMessage}</p>;
}
function FollowedSubFeed(
{ state, emptyMessage, onLoadMore }: FollowedSubFeedProps,
) {
const sentinelRef = useInfiniteScroll(
onLoadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
return (
<>
<ul className="dump-feed">
{state.dumps.map((dump) => (
<DumpCard
key={dump.id}
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote
castVote={castVote}
removeVote={removeVote}
isOwner={user.id === dump.userId}
<ListState
state={state}
isEmpty={({ dumps }) => dumps.length === 0}
empty={<p className="index-status">{emptyMessage}</p>}
>
{({ dumps, hasMore, loadingMore }) => (
<>
<DumpCardList dumps={dumps} />
<FeedFooter
hasMore={hasMore}
loadingMore={loadingMore}
sentinelRef={sentinelRef}
/>
))}
</ul>
{state.hasMore && <div ref={sentinelRef} />}
{state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
</>
)}
{!state.hasMore && state.dumps.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
</ListState>
);
}
/**
* Passed to {@link FollowedFeed} by `Index`, which renders the tab for a
* signed-in user only. Unlike the other tabs, it loads its own dumps.
*/
export interface FollowedFeedProps {
/** Scopes both sections to a category. */
categorySlug?: string;
}
/**
* Renders the "followed" tab in two sections, "from people" and "from
* playlists", from `GET /api/follows/feed/users` and
* `GET /api/follows/feed/playlists`. Both load at once, and each keeps its own
* pages, cached like the index's feed.
*/
export function FollowedFeed({
categorySlug,
voteCounts,
myVotes,
user,
castVote,
removeVote,
}: FollowedFeedProps): React.JSX.Element {
const { token } = useAuth();
const scope = categorySlug ?? "all";
const catQuery = categorySlug
? `&category=${encodeURIComponent(categorySlug)}`
: "";
const { cached: cachedUsers, saveState: saveUsers } = useFeedCache<Dump>(
`feed:followed-users:${scope}`,
hydrateDump,
);
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
Dump
>(
`feed:followed-playlists:${scope}`,
hydrateDump,
);
const [usersState, setUsersState] = useState<FeedState>(() =>
cachedUsers
? {
status: "loaded",
dumps: cachedUsers.items,
hasMore: cachedUsers.hasMore,
page: cachedUsers.page,
loadingMore: false,
}
: { status: "loading" }
);
const [playlistsState, setPlaylistsState] = useState<FeedState>(() =>
cachedPlaylists
? {
status: "loaded",
dumps: cachedPlaylists.items,
hasMore: cachedPlaylists.hasMore,
page: cachedPlaylists.page,
loadingMore: false,
}
: { status: "loading" }
);
export function FollowedFeed(
{ categorySlug }: FollowedFeedProps,
): React.JSX.Element {
const users = useFollowedSection("users", categorySlug);
const playlists = useFollowedSection("playlists", categorySlug);
const [section, setSection] = useState<FollowedSection>("users");
const setUsersItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setUsersState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setUsersItems);
const setPlaylistsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setPlaylistsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setPlaylistsItems);
// A cached section starts loaded, so only the others are fetched.
useEffect(() => {
if (!token) return;
if (usersState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setUsersState({ status: "error", error: friendlyFetchError(err) })
);
}
if (playlistsState.status === "loading") {
fetch(
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) =>
setPlaylistsState({
status: "error",
error: friendlyFetchError(err),
})
);
}
}, [token, usersState.status, playlistsState.status, catQuery]);
// Only the lists are kept here: `Index` saves and restores the page's scroll
// for every tab, this one included.
useScrollSave(
usersState.status === "loaded",
useCallback(() => {
if (usersState.status !== "loaded") return;
saveUsers(usersState.dumps, usersState.page, usersState.hasMore, 0);
}, [usersState, saveUsers]),
);
useScrollSave(
playlistsState.status === "loaded",
useCallback(() => {
if (playlistsState.status !== "loaded") return;
savePlaylists(
playlistsState.dumps,
playlistsState.page,
playlistsState.hasMore,
0,
);
}, [playlistsState, savePlaylists]),
);
const loadMoreUsers = useCallback(() => {
if (
usersState.status !== "loaded" || !usersState.hasMore ||
usersState.loadingMore || !token
) return;
const nextPage = usersState.page + 1;
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setUsersState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setUsersState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [usersState, token, catQuery]);
const loadMorePlaylists = useCallback(() => {
if (
playlistsState.status !== "loaded" || !playlistsState.hasMore ||
playlistsState.loadingMore || !token
) return;
const nextPage = playlistsState.page + 1;
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}${catQuery}`,
{ headers: { Authorization: `Bearer ${token}` } },
)
.then(readPage)
.then(({ items, hasMore }) => {
setPlaylistsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setPlaylistsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [playlistsState, token, catQuery]);
return (
<div className="followed-feed">
<TabBar
@@ -366,27 +203,17 @@ export function FollowedFeed({
{section === "users" && (
<FollowedSubFeed
state={usersState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
state={users.state}
emptyMessage={t`Follow some users to see their dumps here.`}
onLoadMore={loadMoreUsers}
onLoadMore={users.loadMore}
/>
)}
{section === "playlists" && (
<FollowedSubFeed
state={playlistsState}
voteCounts={voteCounts}
myVotes={myVotes}
user={user}
castVote={castVote}
removeVote={removeVote}
state={playlists.state}
emptyMessage={t`Follow some public playlists to see their dumps here.`}
onLoadMore={loadMorePlaylists}
onLoadMore={playlists.loadMore}
/>
)}
</div>

View File

@@ -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 (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (blocks.length === 0) {
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
<>
{
/* Each block is its own grid, with a themable <hr> between them. Inside
a grid the <hr> would be stretched to a 9rem row, while out here a
theme can shrink it to nothing. */
<ListState
state={state}
isEmpty={() => blocks.length === 0}
empty={
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
}
<div className="journal-feed">
{blocks.map((block, index) => (
<Fragment key={block.key}>
{index > 0 && <hr className="journal-break" />}
<ul className="journal-grid">
{block.entries.map(({ dump, shape }) => (
<JournalCard
key={dump.id}
dump={dump}
shape={shape}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
</Fragment>
))}
</div>
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more…</Trans>
</p>
>
{({ hasMore, loadingMore }) => (
<>
{
/* Each block is its own grid, with a themable <hr> between them.
Inside a grid the <hr> would be stretched to a 9rem row, while out
here a theme can shrink it to nothing. */
}
<div className="journal-feed">
{blocks.map((block, index) => (
<Fragment key={block.key}>
{index > 0 && <hr className="journal-break" />}
<ul className="journal-grid">
{block.entries.map(({ dump, shape }) => (
<JournalCard
key={dump.id}
dump={dump}
shape={shape}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
isOwner={user?.id === dump.userId}
/>
))}
</ul>
</Fragment>
))}
</div>
<FeedFooter
hasMore={hasMore}
loadingMore={loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
{!hasMore && blocks.length > 0 && (
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
</ListState>
);
}

View File

@@ -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<V> {
value: V;
label: ReactNode;
}
/**
* Passed to {@link SegmentedField} by a form, or by {@link VisibilityToggle}.
*/
@@ -34,9 +29,8 @@ export interface SegmentedFieldProps<T extends FieldValues, V> {
}
/**
* 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<T extends FieldValues, V>({
name,
@@ -53,25 +47,24 @@ export function SegmentedField<T extends FieldValues, V>({
name={name}
control={control}
render={({ field }) => (
<div className="visibility-toggle">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
className={field.value === opt.value ? "active" : ""}
disabled={disabled}
onClick={() => field.onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
<Segmented
options={options}
value={field.value}
onChange={field.onChange}
disabled={disabled}
/>
)}
/>
</div>
);
}
/** The Public/Private options, true for public. */
const VISIBILITY_OPTIONS: SegmentedOption<boolean>[] = [
{ value: true, label: <Trans>Public</Trans> },
{ value: false, label: <Trans>Private</Trans> },
];
/** Passed to {@link VisibilityToggle} by a dump or playlist form. */
export interface VisibilityToggleProps<T extends FieldValues> {
/** A boolean field, true for public. */
@@ -95,10 +88,18 @@ export function VisibilityToggle<T extends FieldValues>({
name={name}
label={label}
disabled={disabled}
options={[
{ value: true, label: <Trans>Public</Trans> },
{ value: false, label: <Trans>Private</Trans> },
]}
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<SegmentedProps<boolean>, "options">,
): React.JSX.Element {
return <Segmented options={VISIBILITY_OPTIONS} {...props} />;
}

View File

@@ -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<PlaylistMembership[]>([]);
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<PlaylistMembership>[]).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 (
<Modal title={t`Add to playlist`} onClose={onClose}>
@@ -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}
/>
</Modal>
);

View File

@@ -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 (
<ul className="dump-feed">
{playlists.map((p) => (
<PlaylistCard
key={p.id}
playlist={p}
isOwner={!!user && user.id === p.userId}
onDelete={onDelete && (() => onDelete(p))}
/>
))}
</ul>
);
}

View File

@@ -0,0 +1,103 @@
/**
* Renders the inline editor of the signed-in user's description, under their
* profile's header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Controller, FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import { VALIDATION } from "../../../shared/validation.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { TextEditor } from "../editor/TextEditor.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link DescriptionEditor} by `ProfileDescription`, which shows it
* in place of the description while editing.
*/
export interface DescriptionEditorProps {
initialDescription: string;
onCancel: () => void;
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description's Markdown editor, saved to `PATCH /api/users/me`.
* Other browsers see the change through the socket's `user_updated` event.
*/
export function DescriptionEditor(
{ initialDescription, onCancel, onSaved }: DescriptionEditorProps,
): React.JSX.Element {
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<Wire<PublicUser>>(
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 (
<FormProvider {...form}>
<form
className="profile-description-editor animate-in"
onSubmit={onSubmit}
>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="comment-reply-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={submitting ? undefined : onCancel}
placeholder={t`Who am I?`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
)}
/>
<div className="profile-description-actions">
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={description.length > VALIDATION.USER_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="form-cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
<FormError title={t`Failed to save`} />
</div>
</form>
</FormProvider>
);
}

View File

@@ -0,0 +1,90 @@
/**
* Renders the inline editor of the signed-in user's email, in their profile's
* header.
*
* @module
*/
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { FormProvider } from "react-hook-form";
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser, UpdateUserRequest } from "../../../shared/user.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/**
* Passed to {@link EmailEditor} by `ProfileHeader`, which shows it in place of
* the email while editing.
*/
export interface EmailEditorProps {
initialEmail: string;
onCancel: () => void;
onSaved: (email: string) => void;
}
/**
* Renders the email field, saved to `PATCH /api/users/me`. Escape cancels.
*/
export function EmailEditor(
{ initialEmail, onCancel, onSaved }: EmailEditorProps,
): React.JSX.Element {
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<Wire<PublicUser>>(
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 (
<FormProvider {...form}>
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
{...form.register("email")}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
disabled={submitting}
autoFocus
/>
<div className="profile-email-actions">
<SubmitButton
className="profile-email-btn profile-email-btn--save"
pendingLabel={<Trans>Saving…</Trans>}
disabled={!email.trim()}
>
<Trans>Save</Trans>
</SubmitButton>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={onCancel}
disabled={submitting}
>
<Trans>Cancel</Trans>
</button>
</div>
<FormError title={t`Failed to save`} />
</form>
</FormProvider>
);
}

View File

@@ -0,0 +1,91 @@
/**
* Renders a profile's description, which the signed-in user edits in place on
* their own profile.
*
* @module
*/
import { useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Markdown } from "../editor/Markdown.tsx";
import { DescriptionEditor } from "./DescriptionEditor.tsx";
/** Passed to {@link ProfileDescription} by `UserPublicProfile`. */
export interface ProfileDescriptionProps {
description: string | undefined;
isOwnProfile: boolean;
/** Called with the saved description, undefined once cleared. */
onSaved: (description: string | undefined) => void;
}
/**
* Renders the description in Markdown, or nothing when there is none. On
* their own profile, users get a "Who am I?" placeholder instead, and a click
* or Enter swaps in {@link DescriptionEditor}.
*/
export function ProfileDescription(
{ description, isOwnProfile, onSaved }: ProfileDescriptionProps,
): React.JSX.Element | null {
const [editing, setEditing] = useState(false);
const ref = useHeightTransition<HTMLDivElement>(editing);
if (!description && !isOwnProfile) return null;
return (
<div className="profile-description" ref={ref}>
{editing
? (
<DescriptionEditor
initialDescription={description ?? ""}
onCancel={() => setEditing(false)}
onSaved={(saved) => {
onSaved(saved);
setEditing(false);
}}
/>
)
: (
<div
className={`profile-description-view${
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile ? () => 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
? (
<Markdown className="profile-description-text">
{description}
</Markdown>
)
: (
<div className="profile-description-empty">
<Trans>Who am I?</Trans>
</div>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -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<Dump>;
votes: PagedList<Dump>;
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 (
<div className="profile-columns">
<ProfileSection
title={t`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
actions={isOwnProfile && <NewDumpButton />}
viewAllHref={dumps.items.length > 0
? `/users/${username}/dumps`
: undefined}
>
{dumps.items.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: <DumpCardList dumps={dumps.items} />}
</ProfileSection>
<ProfileSection
title={t`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
viewAllHref={votes.items.length > 0
? `/users/${username}/upvoted`
: undefined}
>
{votes.items.length === 0
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<DumpCardList
dumps={votes.items}
cardClassName={upvotedCardClassName}
/>
)}
</ProfileSection>
</div>
);
}

View File

@@ -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<PublicUser>;
playlists: PagedList<Playlist>;
}
/** Passed to {@link ProfileFollowedTab} by `UserPublicProfile`. */
export interface ProfileFollowedTabProps {
username: string;
state: FetchState<ProfileFollowed>;
onLoadMoreUsers: () => void;
}
/** Renders a followed user, with a button to follow or unfollow them. */
function FollowedUserCard({ user }: { user: PublicUser }) {
return (
<li className="followed-user-card">
<Link to={`/users/${user.username}`} className="followed-user-card-link">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={36}
version={user.updatedAt?.getTime()}
/>
<span className="followed-user-card-name">@{user.username}</span>
</Link>
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
</li>
);
}
/**
* 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 (
<div className="profile-columns">
<ProfileSection title={<Trans>Following</Trans>}>
<ListState
state={state}
isEmpty={({ data }) => data.users.items.length === 0}
empty={
<p className="empty-state">
<Trans>Not following anyone yet.</Trans>
</p>
}
>
{({ data: { users } }) => (
<>
<ul className="followed-user-list">
{users.items.map((u) => (
<FollowedUserCard
key={u.id}
user={u}
/>
))}
</ul>
{users.hasMore && (
<LoadMoreButton
loadingMore={users.loadingMore}
onClick={onLoadMoreUsers}
/>
)}
</>
)}
</ListState>
</ProfileSection>
<ProfileSection
title={<Trans>Followed playlists</Trans>}
viewAllHref={state.status === "loaded" && state.data.playlists.hasMore
? `/users/${username}/playlists`
: undefined}
>
<ListState
state={state}
isEmpty={({ data }) => data.playlists.items.length === 0}
empty={
<p className="empty-state">
<Trans>No followed playlists yet.</Trans>
</p>
}
>
{({ data }) => <PlaylistCardList playlists={data.playlists.items} />}
</ListState>
</ProfileSection>
</div>
);
}

View File

@@ -0,0 +1,182 @@
/**
* Renders the header of a user's profile, where the signed-in user also edits
* their own avatar and email.
*
* @module
*/
import { useRef, useState } from "react";
import { Link } from "react-router";
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, SelfUser } from "../../../shared/user.ts";
import { parseAPIResponse } from "../../utils/shared/api.ts";
import { deserializeSelfUser } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useHeightTransition } from "../../hooks/shared/useHeightTransition.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
import { FollowUserButton } from "../social/FollowButton.tsx";
import { EmailEditor } from "./EmailEditor.tsx";
import { ProfileOwnActions } from "./ProfileOwnActions.tsx";
import { RoleChip } from "./RoleSwitcher.tsx";
/** Passed to {@link ProfileHeader} by `UserPublicProfile`, once loaded. */
export interface ProfileHeaderProps {
user: PublicUser;
isOwnProfile: boolean;
/** Called with the user as a new avatar left them. */
onUserChange: (user: PublicUser) => void;
}
/**
* Renders the avatar, username, role and inviter. Visitors get a follow
* button. On their own profile, users change their avatar
* (`POST /api/avatars/me`) and email, invite someone and log out.
*/
export function ProfileHeader(
{ user, isOwnProfile, onUserChange }: ProfileHeaderProps,
): React.JSX.Element {
const { user: me, authFetch, updateSession, logout } = useAuth();
const [uploading, setUploading] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarError(null);
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const res = await authFetch(`${API_URL}/api/avatars/me`, {
method: "POST",
body: formData,
});
const body = parseAPIResponse<Wire<SelfUser>>(await res.json());
if (!body.success) {
setAvatarError(body.error.message);
return;
}
const self = deserializeSelfUser(body.data);
updateSession({ user: self });
onUserChange(self);
} catch {
setAvatarError(t`Upload failed`);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
const handleEmailSaved = (email: string) => {
if (me) updateSession({ user: { ...me, email } });
setEmailEditing(false);
};
return (
<div className="profile-header">
<div className="profile-avatar-wrapper">
<Avatar
userId={user.id}
username={user.username}
hasAvatar={!!user.avatarMime}
size={128}
version={user.updatedAt?.getTime()}
/>
{isOwnProfile && (
<label className="avatar-change-overlay" title={t`Change avatar`}>
{uploading ? "…" : "✎"}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarUpload}
disabled={uploading}
style={{ display: "none" }}
/>
</label>
)}
</div>
<div className="profile-info" ref={infoRef}>
<div className="profile-info-scroll">
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
<RoleChip role={user.role} />
</div>
{user.invitedByUsername
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${user.invitedByUsername}`}
className="profile-invited-by-link"
>
@{user.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
? (
<EmailEditor
initialEmail={me?.email ?? ""}
onCancel={() => setEmailEditing(false)}
onSaved={handleEmailSaved}
/>
)
: (
<p
className="profile-email-display"
onClick={() => setEmailEditing(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setEmailEditing(true);
}
}}
tabIndex={0}
role="button"
title={t`Edit email`}
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
✎
</span>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={user.id}
targetUsername={user.username}
/>
)}
{isOwnProfile && (
<ProfileOwnActions>
<button type="button" className="btn-border" onClick={logout}>
<Trans>Log out</Trans>
</button>
</ProfileOwnActions>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
/**
* The "invitees" tab of a profile: whom the user invited, and whom they
* invited in turn.
*
* @module
*/
import { useMemo } from "react";
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import type { Invitee } from "../../../shared/user.ts";
import type { FetchState } from "../../hooks/shared/useFetchState.ts";
import { Avatar } from "../shared/Avatar.tsx";
import { ListState } from "../shared/ListState.tsx";
import { ProfileSection } from "./ProfileSection.tsx";
type InviteTreeNode = Invitee & { children: InviteTreeNode[] };
/**
* Returns the invitees of `parentId` among `entries`, each with theirs. The
* server answers the whole tree as a flat list.
*/
function buildInviteTree(
entries: Invitee[],
parentId: string,
): InviteTreeNode[] {
return entries
.filter((e) => e.invitedById === parentId)
.map((e) => ({ ...e, children: buildInviteTree(entries, e.id) }));
}
/** Renders the invite tree, each invitee nested under who invited them. */
function InviteTreeList({ nodes }: { nodes: InviteTreeNode[] }) {
return (
<ul className="invite-tree">
{nodes.map((node) => (
<li key={node.id} className="invite-tree-node">
<Link
to={`/users/${node.username}`}
className="invite-tree-user"
>
<Avatar
userId={node.id}
username={node.username}
hasAvatar={!!node.avatarMime}
size={24}
/>
@{node.username}
</Link>
{node.children.length > 0 && <InviteTreeList nodes={node.children} />}
</li>
))}
</ul>
);
}
/** 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<Invitee[]>;
}
/** 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 (
<ProfileSection title={<Trans>Invitees</Trans>}>
<ListState
state={state}
isEmpty={({ data }) => data.length === 0}
empty={
<p className="empty-state invite-tree-empty">
<Trans>No invitees yet.</Trans>
</p>
}
>
{() => <InviteTreeList nodes={nodes} />}
</ListState>
</ProfileSection>
);
}

View File

@@ -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 && (
<ProfileSection title={<Trans>User management</Trans>}>
<div className="profile-appearance-grid">
<RoleSwitcher
userId={user.id}
currentRole={user.role}
onSaved={onRoleSaved}
/>
</div>
</ProfileSection>
)}
{isOwnProfile && canManageCategories && (
<ProfileSection title={<Trans>Categories</Trans>}>
<CategoryManager />
</ProfileSection>
)}
</>
);
}

View File

@@ -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<string | null>(null);
const [pending, setPending] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const ref = useHeightTransition<HTMLDivElement>(
(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 (
<div className="profile-own-actions" ref={ref}>
<div className="profile-own-actions-row">
<button
type="button"
className="invite-btn"
onClick={toggle}
disabled={pending}
aria-expanded={inviteUrl !== null}
>
<Trans>+ Invite someone</Trans>
</button>
{children}
</div>
{inviteUrl && (
<div
className="invite-panel animate-in"
role="group"
aria-label={t`Invite link`}
>
<input
className="invite-url"
type="text"
readOnly
value={inviteUrl}
aria-label={t`Invite link`}
onFocus={(e) => e.currentTarget.select()}
/>
<button
type="button"
className={`invite-copy-btn${
copied ? " invite-copy-btn--done" : ""
}`}
onClick={copy}
>
{copied ? t`Copied!` : t`Copy`}
</button>
<span className="invite-panel-hint">
<Trans>Single use, valid for 7 days.</Trans>
</span>
</div>
)}
{error && (
<div className="invite-panel invite-panel--error animate-in">
<ErrorCard title={t`Failed to generate invite`} message={error} />
</div>
)}
</div>
);
}

View File

@@ -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<Playlist>;
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 (
<ProfileSection
title={
<Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
}
actions={isOwnProfile && (
<NewPlaylistButton
onCreated={(p) => setPlaylists((items) => withFirst(items, p))}
/>
)}
viewAllHref={playlists.items.length > 0
? `/users/${username}/playlists`
: undefined}
>
{playlists.items.length === 0
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: <PlaylistCardList playlists={playlists.items} />}
</ProfileSection>
);
}

View File

@@ -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 (
<section className="profile-section">
<div className="profile-section-header">
<h2 className="profile-section-title">{title}</h2>
{actions}
</div>
{children}
{viewAllHref && (
<Link to={viewAllHref} className="profile-view-all">
<Trans>View all →</Trans>
</Link>
)}
</section>
);
}

View File

@@ -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<StyleName>[] = [
{ 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<ColorScheme>[] = [
{ value: "auto", label: <Trans>Auto</Trans> },
{ value: "light", label: <Trans>Light</Trans> },
{ value: "dark", label: <Trans>Dark</Trans> },
];
const FEED_TAB_OPTIONS: SegmentedOption<FeedTab>[] = [
{ value: "hot", label: <Trans>Hot</Trans> },
{ value: "new", label: <Trans>New</Trans> },
{ value: "journal", label: <Trans>Journal</Trans> },
{ value: "followed", label: <Trans>Followed</Trans> },
];
const ON_OFF_OPTIONS: SegmentedOption<boolean>[] = [
{ value: true, label: <Trans>On</Trans> },
{ value: false, label: <Trans>Off</Trans> },
];
/**
* 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 (
<>
<ProfileSection title={<Trans>Account</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Password</Trans>}>
<button
type="button"
className="btn btn--ghost"
onClick={() => setChangePasswordOpen(true)}
>
<Trans>Change password…</Trans>
</button>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Appearance</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Style</Trans>}>
<Segmented
options={STYLE_OPTIONS}
value={style}
onChange={setStyle}
/>
</SettingRow>
<SettingRow label={<Trans>Color scheme</Trans>}>
<Segmented
options={COLOR_SCHEME_OPTIONS}
value={colorScheme}
onChange={setColorScheme}
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Feeds</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Default tab</Trans>}>
<Segmented
options={FEED_TAB_OPTIONS}
value={defaultFeedTab}
onChange={setDefaultFeedTab}
/>
</SettingRow>
</div>
</ProfileSection>
<ProfileSection title={<Trans>Notifications</Trans>}>
<div className="profile-appearance-grid">
<SettingRow label={<Trans>Sounds</Trans>}>
<Segmented
options={ON_OFF_OPTIONS}
value={soundsEnabled}
onChange={(on) => {
setSoundsEnabled(on);
// Let the user hear what they just turned on.
if (on) previewChime("notify");
}}
/>
</SettingRow>
</div>
</ProfileSection>
{changePasswordOpen && (
<ChangePasswordModal onClose={() => setChangePasswordOpen(false)} />
)}
</>
);
}

View File

@@ -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<Role>[] = [
{ value: "user", label: <Trans>User</Trans> },
{ value: "moderator", label: <Trans>Moderator</Trans> },
{ value: "admin", label: <Trans>Admin</Trans> },
];
/**
* 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 (
<span className={`profile-role-chip profile-role-chip--${role}`}>
{label}
</span>
);
}
/**
* 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<string | null>(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<Wire<PublicUser>>(res)).role,
);
} catch (err) {
setError(friendlyFetchError(err));
} finally {
setSaving(false);
}
}
return (
<>
<SettingRow label={<Trans>Role</Trans>}>
<Segmented
options={ROLE_OPTIONS}
value={currentRole}
onChange={changeRole}
disabled={saving}
/>
</SettingRow>
{error && <ErrorCard title={t`Failed to update role`} message={error} />}
</>
);
}

View File

@@ -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 (
<div className="profile-appearance-row">
<span className="profile-appearance-label">{label}</span>
{children}
</div>
);
}

View File

@@ -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<L extends object> {
state: LoadState<L>;
/** 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<L extends object>(
{ state, isEmpty, empty, children }: ListStateProps<L>,
): ReactNode {
if (state.status === "loading") {
return (
<p className="index-status">
<Trans>Loading…</Trans>
</p>
);
}
if (state.status === "error") {
return <ErrorCard title={t`Failed to load`} message={state.error} />;
}
return isEmpty(state) ? empty : children(state);
}

View File

@@ -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 (
<button
type="button"
className={className}
onClick={onClick}
disabled={loadingMore}
>
{loadingMore ? <Trans>Loading…</Trans> : <Trans>Load more</Trans>}
</button>
);
}

View File

@@ -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<V> {
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<V> {
options: SegmentedOption<V>[];
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<V>(
{ options, value, onChange, disabled, className }: SegmentedProps<V>,
): React.JSX.Element {
return (
<div className={`visibility-toggle${className ? ` ${className}` : ""}`}>
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
className={value === opt.value ? "active" : ""}
disabled={disabled}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}

View File

@@ -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(
</Link>
))}
{state.hasMore && (
<button
type="button"
<LoadMoreButton
className="user-list-popover-load-more"
loadingMore={state.loadingMore}
onClick={handleLoadMore}
disabled={state.loadingMore}
>
{state.loadingMore
? <Trans>Loading…</Trans>
: <Trans>Load more</Trans>}
</button>
/>
)}
</>
)}

View File

@@ -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;

View File

@@ -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<Set<string>>(new Set());
const prevMyVotesRef = useRef<Set<string> | null>(null);
// A ref, so that a new first page doesn't count as new votes.
const sourceRef = useRef(source);
useLayoutEffect(() => {
sourceRef.current = source;
});
const { fading, startFading, cancelFading, cancelAll } = useFading((id) =>
setItems((items) => items.filter((d) => d.id !== id))
);
const withdraw = useCallback((id: string) => {
withdrawnRef.current.add(id);
startFading(id);
}, [startFading]);
const recast = useCallback((id: string) => {
withdrawnRef.current.delete(id);
cancelFading(id);
}, [cancelFading]);
// Another user's list starts afresh. This runs before the effect below that
// diffs the votes, so its baseline resets in time.
useEffect(() => {
withdrawnRef.current = new Set();
prevMyVotesRef.current = null;
cancelAll();
}, [profileUserId, cancelAll]);
useEffect(() => {
if (!isOwn) return;
const prev = prevMyVotesRef.current;
prevMyVotesRef.current = new Set(myVotes);
// 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<Wire<Dump>>)
.then((wire) => {
const dump = deserializeDump(wire);
setItems((items) =>
// A withdrawn vote cast again before its dump left the list.
items.some((d) => d.id === dumpId)
? items.map((d) => d.id === dumpId ? dump : d)
: [dump, ...items]
);
})
.catch(() => {});
}, [lastVoteEvent, profileUserId, token, withdraw, recast, setItems]);
const cardClassName = useCallback(
(dump: Dump) => fadeClassName(fading[dump.id]),
[fading],
);
const isWithdrawn = useCallback(
(id: string) => withdrawnRef.current.has(id),
[],
);
return { cardClassName, isWithdrawn };
}

View File

@@ -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<React.SetStateAction<UserDumpFeedState>>;
/** 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<HTMLDivElement>;
@@ -75,7 +61,6 @@ export function useUserDumpFeed(
username: string | undefined,
endpoint: string,
cacheKey: string,
options?: UseUserDumpFeedOptions,
): UseUserDumpFeedResult {
const { token } = useAuth();
const { cached, saveState } = useFeedCache<Dump>(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<Wire<Dump>>
: { 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 };
}

View File

@@ -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<string, FadeState>;
/** 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<Record<string, FadeState>>({});
const cancels = useRef<Map<string, () => void>>(new Map());
// The running timer of each fading item, whichever phase it is in.
const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
// A ref, so that the calls below stay stable for their callers' effects.
const onFadedRef = useRef(onFaded);
useLayoutEffect(() => {
onFadedRef.current = onFaded;
});
useEffect(() => () => {
cancels.current.forEach((c) => c());
useEffect(() => {
const running = timers.current;
return () => running.forEach(clearTimeout);
}, []);
const drop = useCallback((id: string) => {
timers.current.delete(id);
setFading((f) => {
const n = { ...f };
delete n[id];
return n;
});
}, []);
const startFading = useCallback((id: string) => {
if (cancels.current.has(id)) return;
// `kill` is replaced as each timer is set, so it always clears the one
// running; `dead` stops a timer that fired before it was cleared.
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());
if (timers.current.has(id)) return;
setFading((f) => ({ ...f, [id]: "cooldown" }));
const t1 = setTimeout(() => {
if (dead) return;
setFading((f) => ({ ...f, [id]: "dismissing" }));
const t2 = setTimeout(() => {
if (!dead) 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());
}, []);
timers.current.set(
id,
setTimeout(() => {
setFading((f) => ({ ...f, [id]: "dismissing" }));
timers.current.set(
id,
setTimeout(() => {
drop(id);
onFadedRef.current?.(id);
}, DISMISS_MS),
);
}, COOLDOWN_MS),
);
}, [drop]);
const cancelFading = useCallback((id: string) => {
cancels.current.get(id)?.();
}, []);
const timer = timers.current.get(id);
if (timer === undefined) return;
clearTimeout(timer);
drop(id);
}, [drop]);
const cancelAll = useCallback(() => {
cancels.current.forEach((c) => c());
}, []);
for (const id of [...timers.current.keys()]) cancelFading(id);
}, [cancelFading]);
return { fading, startFading, cancelFading, cancelAll };
}
/**
* Returns the class a fading card takes in `phase`, for `DumpCard`'s
* `className`, or undefined for a card that isn't fading.
*/
export function fadeClassName(
phase: FadeState | undefined,
): string | undefined {
return phase === "cooldown"
? "dump-card--fading"
: phase === "dismissing"
? "dump-card--dismissing"
: undefined;
}

View File

@@ -1,5 +1,6 @@
/**
* Saves a feed page's state, with its scroll position, as the page is left.
* Saves a feed page's state, with its scroll position, as the page is left,
* and restores the position when the page is back.
*
* @module
*/
@@ -42,3 +43,21 @@ export function useScrollSave(
};
}, [enabled]);
}
/**
* Scrolls the window to `scrollY` once `loaded`, and never again, before
* paint so that the restored page doesn't flash at the top. Feed pages pass
* `useFeedCache`'s `cached?.scrollY`, which is undefined when nothing was
* restored.
*/
export function useScrollRestore(
scrollY: number | null | undefined,
loaded: boolean,
): void {
const restored = useRef(false);
useLayoutEffect(() => {
if (scrollY == null || restored.current || !loaded) return;
globalThis.scrollTo(0, scrollY);
restored.current = true;
}, [scrollY, loaded]);
}

View File

@@ -0,0 +1,84 @@
/**
* Loads which of the signed-in user's playlists hold a dump, and adds the dump
* to them or takes it out.
*
* @module
*/
import { useCallback } from "react";
import { API_URL } from "../../config/api.ts";
import type { PlaylistMembership } from "../../../shared/playlist.ts";
import type { Wire } from "../../../shared/api.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { deserializePlaylistMembership } from "../../utils/shared/deserialize.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useFetchState } from "../shared/useFetchState.ts";
/**
* Returned by {@link usePlaylistMemberships} to `AddToPlaylistModal` and
* `DumpCreateModal`, for their `PlaylistMembershipPanel`.
*/
export interface UsePlaylistMembershipsResult {
/** Empty while loading, and when the load failed. */
memberships: PlaylistMembership[];
loading: boolean;
/**
* Adds the dump to the playlist or takes it out on the server, and shows it
* only once the server agrees. A refusal or a dropped connection changes
* nothing.
*/
toggle: (membership: PlaylistMembership) => Promise<void>;
/** Puts first a playlist the panel just created with the dump in it. */
add: (membership: PlaylistMembership) => void;
}
/**
* Returns the user's playlists with whether each holds `dumpId`, from
* `GET /api/playlists/by-dump/:dumpId/memberships`. A null `dumpId`, as before
* `DumpCreateModal` has posted its dump, loads nothing.
*/
export function usePlaylistMemberships(
dumpId: string | null,
): UsePlaylistMembershipsResult {
const { authFetch } = useAuth();
const { state, setData } = useFetchState(
dumpId ?? undefined,
(signal) =>
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`, {
signal,
})
.then(expectOk<Wire<PlaylistMembership>[]>)
.then((items) => items.map(deserializePlaylistMembership)),
);
const toggle = useCallback(async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership;
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: hasDump ? "DELETE" : "POST" },
);
if (!res.ok) return;
} catch {
return;
}
setData((memberships) =>
memberships.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
}, [authFetch, dumpId, setData]);
const add = useCallback(
(membership: PlaylistMembership) =>
setData((memberships) => [membership, ...memberships]),
[setData],
);
return {
memberships: state.status === "loaded" ? state.data : [],
loading: state.status === "loading",
toggle,
add,
};
}

View File

@@ -0,0 +1,233 @@
/**
* Loads a profile's user with the first page of their dumps, upvotes and
* playlists, and keeps them live.
*
* @module
*/
import { useCallback, useEffect, useState } from "react";
import { API_URL } from "../../config/api.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import type { Wire } from "../../../shared/api.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
import {
deserializeDump,
deserializePlaylist,
hydrateDump,
hydratePlaylist,
} from "../../utils/shared/deserialize.ts";
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
import type { LoadState } from "../../utils/shared/loadState.ts";
import {
fetchFirstPage,
type PagedList,
restoredList,
} from "../../utils/feed/pagedList.ts";
import { useAuth } from "../auth/useAuth.ts";
import { useWS } from "../ws/useWS.ts";
import { useFeedCache } from "../feed/useFeedCache.ts";
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
import { usePositionAwareSync } from "../feed/usePositionAwareSync.ts";
import { useDumpListSync } from "../dump/useDumpListSync.ts";
import { useUpvotedList } from "../dump/useUpvotedList.ts";
import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts";
/**
* The profile's lists, each holding its first page only: each has its own page
* for the rest.
*/
export interface ProfileData {
user: PublicUser;
dumps: PagedList<Dump>;
votes: PagedList<Dump>;
playlists: PagedList<Playlist>;
}
/** Returned by {@link useProfile} to `UserPublicProfile`. */
export interface UseProfileResult {
state: LoadState<ProfileData>;
/** Whether the signed-in user is the profile's. False while it loads. */
isOwnProfile: boolean;
/** For the header's edits, which the server echoes to other browsers. */
setUser: (fn: (user: PublicUser) => PublicUser) => void;
/** For `NewPlaylistButton`, whose playlist the socket echoes later. */
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
/** From `useUpvotedList`, for the upvoted list's `DumpCardList`. */
upvotedCardClassName: (dump: Dump) => string | undefined;
}
type ListKey = "dumps" | "votes" | "playlists";
/** Returns `s` with `fn` applied to the items of its list `key`. */
function updateList<K extends ListKey>(
s: LoadState<ProfileData>,
key: K,
fn: (items: ProfileData[K]["items"]) => ProfileData[K]["items"],
): LoadState<ProfileData> {
if (s.status !== "loaded") return s;
return { ...s, [key]: { ...s[key], items: fn(s[key].items) } };
}
/**
* Returns the profile of `username`, from `GET /api/users/:username`, with the
* first pages of `…/dumps`, `…/votes` and `…/playlists`, fetched signed in so
* that users see their own private items. A list the server refuses shows
* empty; the page fails only with the user. The socket's events keep the lists
* live, and `useUpvotedList` the upvoted one. The lists and the scroll
* position are saved as the page is left, and going back restores them,
* refetching only the user.
*/
export function useProfile(username: string | undefined): UseProfileResult {
const { user: me, token } = useAuth();
const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS();
const dumpsCache = useFeedCache<Dump>(
`feed:profile-dumps:${username ?? ""}`,
hydrateDump,
);
const votesCache = useFeedCache<Dump>(
`feed:profile-votes:${username ?? ""}`,
hydrateDump,
);
const playlistsCache = useFeedCache<Playlist>(
`feed:profile-playlists:${username ?? ""}`,
hydratePlaylist,
);
const [state, setState] = useState<LoadState<ProfileData>>({
status: "loading",
});
const [prevUsername, setPrevUsername] = useState(username);
if (prevUsername !== username) {
setPrevUsername(username);
setState({ status: "loading" });
}
const profileUserId = state.status === "loaded" ? state.user.id : null;
const isOwnProfile = !!me && me.id === profileUserId;
const setUser = useCallback((fn: (user: PublicUser) => PublicUser) => {
setState((s) => s.status === "loaded" ? { ...s, user: fn(s.user) } : s);
}, []);
const setDumps = useCallback((fn: (items: Dump[]) => Dump[]) => {
setState((s) => updateList(s, "dumps", fn));
}, []);
const setVotes = useCallback((fn: (items: Dump[]) => Dump[]) => {
setState((s) => updateList(s, "votes", fn));
}, []);
const setPlaylists = useCallback((fn: (items: Playlist[]) => Playlist[]) => {
setState((s) => updateList(s, "playlists", fn));
}, []);
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
usePositionAwareSync(
dumpItems,
setDumps,
lastDumpEvent,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, { isOwner: isOwnProfile });
const upvoted = useUpvotedList({
profileUserId,
setItems: setVotes,
source: dumpItems,
});
const voteItems = state.status === "loaded" ? state.votes.items : [];
usePositionAwareSync(
voteItems,
setVotes,
lastDumpEvent,
(d) => !d.isPrivate && !upvoted.isWithdrawn(d.id),
);
useDumpListSync(setVotes);
const playlistItems = state.status === "loaded" ? state.playlists.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null)
: null;
usePositionAwareSync(
playlistItems,
setPlaylists,
lastPlaylistItem,
(p) => p.isPublic && p.userId === profileUserId,
);
usePlaylistListSync(setPlaylists, {
isOwner: isOwnProfile,
ownerId: profileUserId ?? undefined,
});
useEffect(() => {
if (!lastUserEvent) return;
// Mirrors a socket event into state, which can only happen in an effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setUser((u) => u.id === lastUserEvent.user.id ? lastUserEvent.user : u);
}, [lastUserEvent, setUser]);
const cachedDumps = dumpsCache.cached;
const cachedVotes = votesCache.cached;
const cachedPlaylists = playlistsCache.cached;
useEffect(() => {
if (!username) return;
const controller = new AbortController();
const { signal } = controller;
const headers: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const firstPageOf = <T>(list: string, deserialize: (w: Wire<T>) => T) =>
fetchFirstPage(`${API_URL}/api/users/${username}/${list}`, deserialize, {
headers,
signal,
});
// Back on the page, the lists come from the cache and only the user is
// fetched again.
const cached = cachedDumps && cachedVotes && cachedPlaylists;
Promise.all([
fetchPublicUser(username, signal),
cached
? restoredList(cachedDumps)
: firstPageOf("dumps", deserializeDump),
cached
? restoredList(cachedVotes)
: firstPageOf("votes", deserializeDump),
cached
? restoredList(cachedPlaylists)
: firstPageOf("playlists", deserializePlaylist),
])
.then(([user, dumps, votes, playlists]) =>
setState({ status: "loaded", user, dumps, votes, playlists })
)
.catch((err) => {
if (signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]);
const saveDumps = dumpsCache.saveState;
const saveVotes = votesCache.saveState;
const savePlaylists = playlistsCache.saveState;
useScrollSave(
state.status === "loaded",
useCallback((y) => {
if (state.status !== "loaded") return;
const { dumps, votes, playlists } = state;
saveDumps(dumps.items, dumps.page, dumps.hasMore, y);
saveVotes(votes.items, votes.page, votes.hasMore, y);
savePlaylists(playlists.items, playlists.page, playlists.hasMore, y);
}, [state, saveDumps, saveVotes, savePlaylists]),
);
useScrollRestore(cachedDumps?.scrollY, state.status === "loaded");
return {
state,
isOwnProfile,
setUser,
setPlaylists,
upvotedCardClassName: upvoted.cardClassName,
};
}

View File

@@ -0,0 +1,81 @@
/**
* Loads one thing from the API, once per key, when first asked to.
*
* @module
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import type { LoadState } from "../../utils/shared/loadState.ts";
import { friendlyFetchError } from "../../utils/shared/api.ts";
/** Held by {@link useFetchState}, and passed on to `ListState`. */
export type FetchState<T> = LoadState<{ data: T }>;
/** Returned by {@link useFetchState}. */
export interface UseFetchStateResult<T> {
state: FetchState<T>;
/** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */
setData: (fn: (data: T) => T) => void;
}
/**
* Returns what `load` resolves to for `key`, loaded the first time `enabled`
* is true, and kept afterwards. Turning `enabled` off doesn't abort the load,
* so the profile's tabs, which pass "this tab is open", finish loading in the
* background. A new `key` starts over from loading and aborts the load of the
* last one, as leaving the page does; an undefined key loads nothing. `load`
* must reject with an `Error`, as `expectOk` does, whose message it shows.
*/
export function useFetchState<T>(
key: string | undefined,
load: (signal: AbortSignal) => Promise<T>,
enabled = true,
): UseFetchStateResult<T> {
const [state, setState] = useState<FetchState<T>>({ status: "loading" });
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setState({ status: "loading" });
}
// A ref, so that a new `load` on every render doesn't load again.
const loadRef = useRef(load);
useLayoutEffect(() => {
loadRef.current = load;
});
// The key whose load has started, and the controller that aborts it.
const startedRef = useRef<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => () => {
controllerRef.current?.abort();
startedRef.current = null;
}, [key]);
useEffect(() => {
if (!enabled || key === undefined || startedRef.current === key) return;
startedRef.current = key;
const controller = new AbortController();
controllerRef.current = controller;
loadRef.current(controller.signal)
.then((data) => setState({ status: "loaded", data }))
.catch((err) => {
if (controller.signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) });
});
}, [enabled, key]);
const setData = useCallback((fn: (data: T) => T) => {
setState((s) =>
s.status === "loaded" ? { status: "loaded", data: fn(s.data) } : s
);
}, []);
return { state, setData };
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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>

View File

@@ -52,4 +52,17 @@ describe("useFading", () => {
act(() => result.current.cancelAll());
expect(result.current.fading).toEqual({});
});
test("onFaded hears of a fade that ended, not of a cancelled one", () => {
const onFaded = vi.fn();
const { result } = renderHook(() => useFading(onFaded));
act(() => {
result.current.startFading("a");
result.current.startFading("b");
});
act(() => vi.advanceTimersByTime(2100));
act(() => result.current.cancelFading("b"));
act(() => vi.advanceTimersByTime(250));
expect(onFaded.mock.calls).toEqual([["a"]]);
});
});

View File

@@ -0,0 +1,82 @@
import { describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useFetchState } from "../../../hooks/shared/useFetchState.ts";
import { deferred } from "../../fetch.ts";
describe("useFetchState", () => {
test("loads once enabled, and only once per key", async () => {
const load = vi.fn(() => Promise.resolve(1));
const { result, rerender } = renderHook(
({ enabled }) => useFetchState("a", load, enabled),
{ initialProps: { enabled: false } },
);
expect(load).not.toHaveBeenCalled();
expect(result.current.state).toEqual({ status: "loading" });
rerender({ enabled: true });
await waitFor(() =>
expect(result.current.state).toEqual({ status: "loaded", data: 1 })
);
rerender({ enabled: false });
rerender({ enabled: true });
expect(load).toHaveBeenCalledTimes(1);
expect(result.current.state).toEqual({ status: "loaded", data: 1 });
});
test("turning it off doesn't stop a load under way", async () => {
const d = deferred<number>();
const { result, rerender } = renderHook(
({ enabled }) => useFetchState("a", () => d.promise, enabled),
{ initialProps: { enabled: true } },
);
rerender({ enabled: false });
act(() => d.resolve(2));
await waitFor(() =>
expect(result.current.state).toEqual({ status: "loaded", data: 2 })
);
});
test("a new key starts over and aborts the last key's load", async () => {
const signals: AbortSignal[] = [];
const load = vi.fn((signal: AbortSignal) => {
signals.push(signal);
return signals.length === 1
? new Promise<string>(() => {})
: Promise.resolve("b");
});
const { result, rerender } = renderHook(
({ key }) => useFetchState(key, load),
{ initialProps: { key: "a" } },
);
rerender({ key: "b" });
expect(signals[0].aborted).toBe(true);
await waitFor(() =>
expect(result.current.state).toEqual({ status: "loaded", data: "b" })
);
});
test("an undefined key loads nothing", () => {
const load = vi.fn(() => Promise.resolve(1));
renderHook(() => useFetchState(undefined, load));
expect(load).not.toHaveBeenCalled();
});
test("a failed load shows its message", async () => {
const { result } = renderHook(() =>
useFetchState("a", () => Promise.reject(new Error("Nope")))
);
await waitFor(() =>
expect(result.current.state).toEqual({ status: "error", error: "Nope" })
);
});
test("setData edits the loaded data, and nothing before", async () => {
const d = deferred<number[]>();
const { result } = renderHook(() => useFetchState("a", () => d.promise));
act(() => result.current.setData((xs) => [...xs, 9]));
expect(result.current.state).toEqual({ status: "loading" });
act(() => d.resolve([1]));
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.setData((xs) => [...xs, 2]));
expect(result.current.state).toEqual({ status: "loaded", data: [1, 2] });
});
});

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from "vitest";
import { API_URL } from "../../../config/api.ts";
import {
fetchFirstPage,
firstPage,
restoredList,
withFirst,
} from "../../../utils/feed/pagedList.ts";
import { calls, fail, route } from "../../fetch.ts";
import { page } from "../../fixtures.ts";
describe("pagedList", () => {
test("fetchFirstPage asks for page 1 and deserializes it", async () => {
route("GET /api/things", () => page([{ id: "a" }], true));
const list = await fetchFirstPage(
`${API_URL}/api/things`,
(w: { id: string }) => ({ ...w, seen: true }),
);
expect(list).toEqual({
items: [{ id: "a", seen: true }],
hasMore: true,
page: 1,
loadingMore: false,
});
expect(calls("GET /api/things")[0].url.searchParams.get("page")).toBe("1");
});
test("fetchFirstPage makes a refused list empty, but not a lost one", async () => {
route("GET /api/refused", () => fail("No", 403));
expect(await fetchFirstPage(`${API_URL}/api/refused`, (w) => w))
.toEqual(firstPage([], false));
route("GET /api/lost", () => new TypeError("Failed to fetch"));
await expect(fetchFirstPage(`${API_URL}/api/lost`, (w) => w)).rejects
.toThrow(TypeError);
});
test("restoredList carries on from the cached page", () => {
expect(restoredList({ items: [1], hasMore: true, page: 3 })).toEqual({
items: [1],
hasMore: true,
page: 3,
loadingMore: false,
});
});
test("withFirst puts a new item first, once", () => {
const a = { id: "a" };
const b = { id: "b" };
expect(withFirst([a], b)).toEqual([b, a]);
const items = [a, b];
expect(withFirst(items, { id: "b" })).toBe(items);
});
});

View File

@@ -0,0 +1,64 @@
/**
* A list of which the first pages are loaded, as the pages holding several
* such lists keep each one.
*
* @module
*/
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
import { dataOr } from "../shared/api.ts";
/**
* Held for each of its lists by `UserPlaylists` and the profile, whose lists
* load together with the user and then a page at a time.
*/
export interface PagedList<T> {
items: T[];
hasMore: boolean;
/** The last page loaded, from which loading more carries on. */
page: number;
loadingMore: boolean;
}
/** Returns the list holding only its first page, `items`. */
export function firstPage<T>(items: T[], hasMore: boolean): PagedList<T> {
return { items, hasMore, page: 1, loadingMore: false };
}
/** Returns the list as `useFeedCache`'s `cached` restores it. */
export function restoredList<T>(
{ items, hasMore, page }: { items: T[]; hasMore: boolean; page: number },
): PagedList<T> {
return { items, hasMore, page, loadingMore: false };
}
const EMPTY_PAGE: PaginatedData<never> = { items: [], hasMore: false };
/**
* Returns the first page of the list at `url`, which takes `page` and `limit`
* in its query, or an empty list when the server refuses it. The profile pages
* load their lists with it beside the user, whose failure alone fails the
* page.
*
* @throws {Error} Offline, when aborted, and when the body isn't an envelope.
*/
export async function fetchFirstPage<T>(
url: string,
deserialize: (wire: Wire<T>) => T,
init?: RequestInit,
): Promise<PagedList<T>> {
const { items, hasMore } = await dataOr<PaginatedData<Wire<T>>>(
await fetch(`${url}?page=1&limit=${DEFAULT_PAGE_SIZE}`, init),
EMPTY_PAGE,
);
return firstPage(items.map(deserialize), hasMore);
}
/**
* Returns `items` with `item` first, or `items` as they are when it is already
* there, as when a list's own echo of a new item arrives after it.
*/
export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
return items.some((i) => i.id === item.id) ? items : [item, ...items];
}

View File

@@ -46,3 +46,16 @@ export async function expectOk<T>(res: Response): Promise<T> {
if (!body.success) throw new Error(body.error.message);
return body.data;
}
/**
* Returns the payload of `res`, or `fallback` when the response says
* `success: false`. The profile pages read their side lists with it, as
* `{ items: [], hasMore: false }` when refused, so that a list the server
* refuses shows empty rather than failing the page.
*
* @throws {Error} When the body isn't JSON or isn't an envelope.
*/
export async function dataOr<T>(res: Response, fallback: T): Promise<T> {
const body = parseAPIResponse<T>(await res.json());
return body.success ? body.data : fallback;
}

View File

@@ -0,0 +1,28 @@
/**
* Fetches the user a profile page is about.
*
* @module
*/
import { API_URL } from "../../config/api.ts";
import type { Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
import { expectOk } from "./api.ts";
import { deserializePublicUser } from "./deserialize.ts";
/**
* Returns `GET /api/users/:username`, for the profile and its full lists,
* which fail as a whole when it does. It is fetched afresh even when their
* lists come from `useFeedCache`.
*
* @throws {Error} "User not found" on a 404, the server's message on another
* refusal, and the fetch's own error offline or when aborted.
*/
export async function fetchPublicUser(
username: string,
signal: AbortSignal,
): Promise<PublicUser> {
const res = await fetch(`${API_URL}/api/users/${username}`, { signal });
if (res.status === 404) throw new Error("User not found");
return deserializePublicUser(await expectOk<Wire<PublicUser>>(res));
}

View File

@@ -0,0 +1,16 @@
/**
* The state of something a component loads from the API, which `ListState`
* renders while it isn't loaded.
*
* @module
*/
/**
* Held by a page or hook for what it fetches, as `useFetchState` returns it,
* and passed to `ListState`. `L` is what the loaded state carries besides its
* status, as `{ data: T }` for `useFetchState` or a feed's page fields.
*/
export type LoadState<L extends object> =
| { status: "loading" }
| { status: "error"; error: string }
| ({ status: "loaded" } & L);