v3: every paged list loads its pages one way, PlaylistDetail is split like the profile, and the comments keep the README's rules — Index, FollowedFeed, useUserDumpFeed, UserPlaylists, UserListPopover, Notifications, Search and the profile's followed users each hand-wrote "fetch the next page, append it, clear loadingMore on failure". A list the API pages is now a PagedList whose next page useLoadMore appends, and a list that loads on its own goes through usePagedList, which is useFetchState (now taking an initial value, as a restored cache, that starts loaded) plus useLoadMore. Pages are read by pageUrl with readPage (a refusal throws) or readFirstPage (a refusal is empty, as fetchFirstPage was), signed-in reads go through authFetch, and useFeedCache's saveState takes the list. Notifications keeps the socket's arrivals as their own list merged at render, as Index does with recentDumps, and marks them read in one effect. PlaylistDetail (963 lines) only puts the pieces together (146): usePlaylist holds the playlist, its live sync and its edits, PlaylistHeader, PlaylistEditForm and PlaylistDumpList are one file each, and LiveDumpCard is the vote wiring DumpCardList and the playlist both use. What it re-invented is shared: the load is useFetchState, a dump going private and back is usePositionAwareSync, deletes and edits are useDumpListSync (which also keeps comment counts, which the page didn't). A removal is sent when its fade ends, or as the page is left, so an undo sends nothing: the removal/re-add/reorder dance, the pending-undo order and the 80 ms debounce of its echoes are gone. A refused reorder goes back to the server's last order and says so, instead of reloading the page. The comments written since the documentation pass broke its rules in places: module docs as a label and a colon ("The "dumps" tab of a profile: …"), noun-phrase fragments on types, fields and constants, stale lists of callers, over-long lines; they are sentences now, and the ones the refactor made wrong (PlaylistDetail as useFading's and FollowPlaylistButton's caller, "the profiles" as usePositionAwareSync's only users) say what is true. 650 frontend and 214 backend tests pass (new: usePagedList, useFetchState's initial, pageUrl/readPage, the deferred removal, a dump returning at its place in a playlist), and tsc, eslint, doc:lint, deno check, deno lint and deno fmt are clean.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
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 21:19:33 +00:00
parent 69d9bf899b
commit 07ac4bc629
81 changed files with 2016 additions and 2210 deletions

View File

@@ -55,10 +55,10 @@ export const PUBLIC_URL: string =
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
`http://localhost:${PORT}`; `http://localhost:${PORT}`;
/** /**
* The URL the frontend is served from (`GERBEUR_FRONTEND_URL`), when it isn't * Holds the URL the frontend is served from (`GERBEUR_FRONTEND_URL`), without
* {@link PUBLIC_URL}, as with a separate Vite server, without a trailing slash. * a trailing slash, when it isn't {@link PUBLIC_URL}, as with a separate Vite
* It is an allowed origin, and password reset links and the Open Graph tags' * server. It is an allowed origin, and password reset links and the Open Graph
* page URLs point to it. * tags' page URLs point to it.
*/ */
export const FRONTEND_URL: string = export const FRONTEND_URL: string =
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") || Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") ||
@@ -95,7 +95,7 @@ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
/** /**
* The largest image upload accepted, in bytes, for avatars, covers, thumbnails * Caps the size of an image upload, in bytes, for avatars, covers, thumbnails
* and attachments. lib/upload.ts states it in megabytes in its error. * and attachments. lib/upload.ts states it in megabytes in its error.
*/ */
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;

View File

@@ -15,8 +15,8 @@ import { runStartupTasks } from "./startup.ts";
const isNew = await Deno.stat(DB_PATH).then(() => false, () => true); const isNew = await Deno.stat(DB_PATH).then(() => false, () => true);
/** /**
* The server's only connection. Its queries are synchronous, so each one * Serves every query of the server, as its only connection. Its queries are
* blocks the event loop while it runs. * synchronous, so each one blocks the event loop while it runs.
*/ */
export const db: DatabaseSync = new DatabaseSync(DB_PATH); export const db: DatabaseSync = new DatabaseSync(DB_PATH);
// schema.sql holds every migration's change, so on a new database each // schema.sql holds every migration's change, so on a new database each

View File

@@ -32,10 +32,10 @@ interface Migration {
} }
/** /**
* Every migration, in the order they run. A new one goes at the end, along * Lists every migration, in the order they run. A new one goes at the end,
* with the same change to schema.sql, and a shipped one is never edited or * along with the same change to schema.sql, and a shipped one is never edited
* moved. A database connection.ts made from schema.sql records none of them, * or moved. A database that connection.ts made from schema.sql records none of
* so each `up` must find its change already made and leave it be. * them, so each `up` must find its change already made and leave it be.
*/ */
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0001_comment_likes", up: up0001CommentLikes },

View File

@@ -13,7 +13,9 @@ import {
} from "../config.ts"; } from "../config.ts";
import { hashPassword } from "../lib/password.ts"; import { hashPassword } from "../lib/password.ts";
/** Returns the time `ms` ago as an ISO string, the format dates are stored in. */ /**
* Returns the time `ms` ago as an ISO string, the format dates are stored in.
*/
function isoAgo(ms: number): string { function isoAgo(ms: number): string {
return new Date(Date.now() - ms).toISOString(); return new Date(Date.now() - ms).toISOString();
} }
@@ -21,8 +23,9 @@ function isoAgo(ms: number): string {
/** /**
* Runs after the migrations, each time the server starts. It deletes used or * Runs after the migrations, each time the server starts. It deletes used or
* expired reset tokens, invites past their validity, and attachments nothing * expired reset tokens, invites past their validity, and attachments nothing
* links to or whose resource is gone. On an instance with no users, it creates the user `admin` with the * links to or whose resource is gone. On an instance with no users, it creates
* password `admin`, which whoever installs it must change. * the user `admin` with the password `admin`, which whoever installs it must
* change.
*/ */
export async function runStartupTasks(db: DatabaseSync): Promise<void> { export async function runStartupTasks(db: DatabaseSync): Promise<void> {
// Dates are stored as ISO strings and compared as text, so the cutoffs are // Dates are stored as ISO strings and compared as text, so the cutoffs are

View File

@@ -7,11 +7,11 @@
*/ */
/** /**
* The top-level path segments a category slug may not take. They are the SPA's * Lists the top-level path segments a category slug may not take. They are
* own routes (`src/App.tsx`); `api`, `ws`, `assets` and `emojibase`, which the * the SPA's own routes (`src/App.tsx`); `api`, `ws`, `assets` and
* server answers before the SPA; and `manage`, a former route whose old links * `emojibase`, which the server answers before the SPA; and `manage`, a
* may still be about. Keep it in step with the route table and the build's * former route whose old links may still be about. Keep it in step with the
* top-level folders. * route table and the build's top-level folders.
*/ */
export const RESERVED_SLUGS: readonly string[] = [ export const RESERVED_SLUGS: readonly string[] = [
"~", "~",

View File

@@ -38,7 +38,7 @@ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
export function routeStaticFilesFrom( export function routeStaticFilesFrom(
staticPaths: string[], staticPaths: string[],
): (context: Context<Record<string, object>>, next: Next) => Promise<void> { ): (context: Context<Record<string, object>>, next: Next) => Promise<void> {
// The last middleware: it always answers, so it never calls `next`. // It is the last middleware and always answers, so it never calls `next`.
return async (context: Context<Record<string, object>>, _next: Next) => { return async (context: Context<Record<string, object>>, _next: Next) => {
const pathname = context.request.url.pathname; const pathname = context.request.url.pathname;

View File

@@ -183,7 +183,7 @@ export async function readTextCapped(
try { try {
return new TextDecoder(charset).decode(bytes); return new TextDecoder(charset).decode(bytes);
} catch { } catch {
// An unknown label: TextDecoder throws rather than guess. // TextDecoder throws on a label it doesn't know rather than guess.
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
} }

View File

@@ -12,9 +12,10 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts"; import { BANDCAMP_HOST_RE } from "../bandcamp-tralbum.ts";
/** /**
* Claims bandcamp.com and its subdomains, where artists have their pages. Takes * Claims bandcamp.com and its subdomains, where artists have their pages, and
* the embed player's URL from the page's `og:video`. The native player fetches the page's streams * takes the embed player's URL from the page's `og:video`. The native player
* itself at play time, through `GET /api/bandcamp/tracks`. * fetches the page's streams itself at play time, through
* `GET /api/bandcamp/tracks`.
*/ */
export const bandcampProvider: RichContentProvider = { export const bandcampProvider: RichContentProvider = {
name: "bandcamp", name: "bandcamp",

View File

@@ -20,7 +20,10 @@ export function createAttachment(id: string, mime: string): void {
).run(id, mime, new Date().toISOString()); ).run(id, mime, new Date().toISOString());
} }
/** The kinds of resource whose saved text can link an attachment. */ /**
* Stored in `attachments.resource_type` by {@link bindAttachments}, it names
* the kind of resource whose saved text links the attachment.
*/
export type AttachmentResourceType = export type AttachmentResourceType =
| "dump" | "dump"
| "comment" | "comment"

View File

@@ -42,8 +42,9 @@ function resolveDumpPathId(idOrSlug: string): string | null {
* Replaces the backlinks from one text, a dump's note (`dump_comment`) or one * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
* of its comments (`comment`), with the links its current `text` holds. A link * of its comments (`comment`), with the links its current `text` holds. A link
* counts when it is a dump's page on this site, or when it is the URL a dump * counts when it is a dump's page on this site, or when it is the URL a dump
* was posted with, compared by `canonicalizeUrl` as reposts are. The dump and comment services call it on every save, and with * was posted with, compared by `canonicalizeUrl` as reposts are. The dump and
* an empty `text` when a comment is deleted. * comment services call it on every save, and with an empty `text` when a
* comment is deleted.
*/ */
export function relinkBacklinks( export function relinkBacklinks(
sourceType: "dump_comment" | "comment", sourceType: "dump_comment" | "comment",

View File

@@ -160,8 +160,8 @@ export function broadcastPresence(): void {
* Tells the clients that may see a dump how it changed, for the dump service * Tells the clients that may see a dump how it changed, for the dump service
* after every write. `before` is null for a new dump, `after` for a deleted * after every write. `before` is null for a new dump, `after` for a deleted
* one. A private dump's changes reach its owner's sockets alone. When a dump * one. A private dump's changes reach its owner's sockets alone. When a dump
* goes private, everyone else gets `dump_deleted`; when it goes public, they get * goes private, everyone else gets `dump_deleted`; when it goes public, they
* `dump_created`, since to them it is new. * get `dump_created`, since to them it is new.
*/ */
export function broadcastDumpChange( export function broadcastDumpChange(
before: Dump | null, before: Dump | null,

View File

@@ -7,9 +7,9 @@
*/ */
/** /**
* The codes an {@link APIError} may carry, one per HTTP status, which the * Lists the codes an {@link APIError} carries, one per HTTP status. The
* backend's `APIException` derives from the status it is given. The frontend * backend's `APIException` derives its code from the status it is given, and
* shows the message and doesn't read the code. * the frontend shows the message without reading the code.
*/ */
export const APIErrorCode = { export const APIErrorCode = {
BAD_REQUEST: "BAD_REQUEST", BAD_REQUEST: "BAD_REQUEST",

View File

@@ -29,8 +29,8 @@ export const VALIDATION = {
} as const; } as const;
/** /**
* Matches a valid username: letters, digits and underscores, within the * Matches a valid username, made of letters, digits and underscores within the
* `VALIDATION` limits. Sign-up checks it on both sides; it is the same `\w` * `VALIDATION` limits. Sign-up checks it on both sides. It uses the same `\w`
* that a mention's pattern reads a name with. * that a mention's pattern reads a name with.
*/ */
export const USERNAME_RE: RegExp = new RegExp( export const USERNAME_RE: RegExp = new RegExp(

View File

@@ -249,10 +249,10 @@ export interface CommentLikeAckMessage {
} }
/** /**
* Sent by the server when a dump is posted: to every socket if it is public, * Sent by the server when a dump is posted, to every socket if it is public
* to its poster's alone if private. Also sent to everyone but the poster when * and to its poster's alone if it is private. It also goes to everyone but the
* a private dump goes public, since to them it is new. `WSProvider` puts it at * poster when a private dump goes public, since to them it is new.
* the top of its recent dumps. * `WSProvider` puts it at the top of its recent dumps.
*/ */
export interface DumpCreatedMessage { export interface DumpCreatedMessage {
type: "dump_created"; type: "dump_created";
@@ -260,8 +260,8 @@ export interface DumpCreatedMessage {
} }
/** /**
* Sent by the server when a dump changes: to every socket if it is public, to * Sent by the server when a dump changes, to every socket if it is public and
* its poster's alone if private, as when it has just gone private. * to its poster's alone if it is private, as when it has just gone private.
* `WSProvider` refreshes it in place, without moving it up the feeds. * `WSProvider` refreshes it in place, without moving it up the feeds.
*/ */
export interface DumpUpdatedMessage { export interface DumpUpdatedMessage {
@@ -270,9 +270,9 @@ export interface DumpUpdatedMessage {
} }
/** /**
* Sent by the server when a dump is deleted: to every socket if it was * Sent by the server when a dump is deleted, to every socket if it was public
* public, to its poster's alone if private. Also sent to everyone but the * and to its poster's alone if it was private. It also goes to everyone but
* poster when a public dump goes private. `WSProvider` drops it from every * the poster when a public dump goes private. `WSProvider` drops it from every
* list. * list.
*/ */
export interface DumpDeletedMessage { export interface DumpDeletedMessage {

View File

@@ -1,5 +1,5 @@
/** /**
* Renders a list of dumps as cards, wired to the live votes. * Renders dumps as cards wired to the live votes, one at a time or as a list.
* *
* @module * @module
*/ */
@@ -10,39 +10,57 @@ import { useWS } from "../../hooks/ws/useWS.ts";
import { DumpCard } from "./DumpCard.tsx"; import { DumpCard } from "./DumpCard.tsx";
/** /**
* Passed to {@link DumpCardList} by every list of dump cards: the index's * Passed to {@link LiveDumpCard} by {@link DumpCardList}, and by
* "hot", "new" and "followed" feeds, the search results, a dump's related * `PlaylistDumpList`, which wraps each card in its drag handle and buttons.
* dumps, and a profile's dumps and upvotes.
*/ */
export interface DumpCardListProps { export interface LiveDumpCardProps {
dumps: Dump[]; dump: Dump;
/** A class for one card, as `fadeClassName` gives a fading one. */ /** It comes from `fadeClassName` for a fading card. */
cardClassName?: (dump: Dump) => string | undefined; className?: string;
} }
/** /**
* Renders `dumps` as a `.dump-feed` of {@link DumpCard}. The vote counts and * Renders `dump` as a {@link DumpCard}, an `<li>`. The vote counts and calls
* calls come from `WSContext`, a dump missing from its counts showing its * come from `WSContext`, a dump missing from its counts showing its fetched
* fetched `voteCount`. Voting takes a signed-in user, and a card is its * `voteCount`. Voting takes a signed-in user, and a card is its owner's when
* owner's when it is the signed-in user's own dump. * it is the signed-in user's own dump.
*/ */
export function DumpCardList( export function LiveDumpCard(
{ dumps, cardClassName }: DumpCardListProps, { dump, className }: LiveDumpCardProps,
): React.JSX.Element { ): React.JSX.Element {
const { user } = useAuth(); const { user } = useAuth();
const { voteCounts, myVotes, castVote, removeVote } = useWS(); const { voteCounts, myVotes, castVote, removeVote } = useWS();
return (
<DumpCard
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={className}
/>
);
}
/** Passed to {@link DumpCardList} by every plain list of dump cards. */
export interface DumpCardListProps {
dumps: Dump[];
/** Gives a card a class, as `fadeClassName` gives a fading one. */
cardClassName?: (dump: Dump) => string | undefined;
}
/** Renders `dumps` as a `.dump-feed` of {@link LiveDumpCard}. */
export function DumpCardList(
{ dumps, cardClassName }: DumpCardListProps,
): React.JSX.Element {
return ( return (
<ul className="dump-feed"> <ul className="dump-feed">
{dumps.map((dump) => ( {dumps.map((dump) => (
<DumpCard <LiveDumpCard
key={dump.id} key={dump.id}
dump={dump} 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)} className={cardClassName?.(dump)}
/> />
))} ))}

View File

@@ -1,6 +1,6 @@
/** /**
* The "hot" and "new" tabs of the index page. `Index` fetches the feed and * Renders the "hot" and "new" tabs of the index page, from the feed `Index`
* passes it down as {@link MainFeedProps}. * loads and passes down as {@link MainFeedProps}.
* *
* @module * @module
*/ */
@@ -11,30 +11,32 @@ import { DumpCardList } from "../dump/DumpCardList.tsx";
import { ListState } from "../shared/ListState.tsx"; import { ListState } from "../shared/ListState.tsx";
import { FeedFooter } from "./FeedFooter.tsx"; import { FeedFooter } from "./FeedFooter.tsx";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import type { LoadState } from "../../utils/shared/loadState.ts"; import type { PagedList } from "../../utils/feed/pagedList.ts";
import type { FetchState } from "../../hooks/shared/useFetchState.ts";
import { hotScore } from "../../utils/feed/hotScore.ts"; import { hotScore } from "../../utils/feed/hotScore.ts";
/** /**
* Held by `Index` for its feed, and by `FollowedFeed` for each of its * Held by `usePagedList` for `Index`'s feed and for each section of
* sections: the pages of dumps loaded so far. * `FollowedFeed`.
*/ */
export type DumpFeedState = LoadState<{ export type DumpFeedState = FetchState<PagedList<Dump>>;
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
}>;
/** /**
* Passed by `Index` to whichever of the "hot", "new" and "journal" tabs is * 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. * showing. The tabs share this one state, so switching tabs doesn't refetch.
*/ */
export interface MainFeedProps { export interface MainFeedProps {
/** The feed's status and paging. Its own dumps are in `dumps`. */ /**
* The tabs read its status and paging, and show `dumps` instead of its
* items.
*/
state: DumpFeedState; state: DumpFeedState;
/** The loaded dumps, with those `WSContext` added since and less deleted. */ /**
* It holds the loaded dumps, with those `WSContext` added since and without
* the deleted ones.
*/
dumps: Dump[]; dumps: Dump[];
/** From `useInfiniteScroll`. The feed places it after the last card. */ /** It comes from `useInfiniteScroll`, and goes after the last card. */
sentinelRef: Ref<HTMLDivElement>; sentinelRef: Ref<HTMLDivElement>;
} }
@@ -75,7 +77,7 @@ export function DumpFeed(
</p> </p>
} }
> >
{({ hasMore, loadingMore }) => ( {({ data: { hasMore, loadingMore } }) => (
<> <>
<DumpCardList dumps={sorted} /> <DumpCardList dumps={sorted} />
<FeedFooter <FeedFooter

View File

@@ -9,12 +9,12 @@ import { Trans } from "@lingui/react/macro";
/** /**
* Passed to {@link FeedFooter} by every list that loads its next page on * 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. * scroll.
*/ */
export interface FeedFooterProps { export interface FeedFooterProps {
hasMore: boolean; hasMore: boolean;
loadingMore: boolean; loadingMore: boolean;
/** From `useInfiniteScroll`. */ /** It comes from `useInfiniteScroll`. */
sentinelRef: Ref<HTMLDivElement>; sentinelRef: Ref<HTMLDivElement>;
} }

View File

@@ -1,11 +1,11 @@
/** /**
* The "followed" tab of the index page, which shows the dumps of the users and * Renders the "followed" tab of the index page, which shows the dumps of the
* the playlists the signed-in user follows. * users and the playlists the signed-in user follows.
* *
* @module * @module
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { TabBar } from "../shared/TabBar.tsx"; import { TabBar } from "../shared/TabBar.tsx";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -13,19 +13,19 @@ import { DumpCardList } from "../dump/DumpCardList.tsx";
import { ListState } from "../shared/ListState.tsx"; import { ListState } from "../shared/ListState.tsx";
import { FeedFooter } from "./FeedFooter.tsx"; import { FeedFooter } from "./FeedFooter.tsx";
import type { DumpFeedState } from "./DumpFeed.tsx"; import type { DumpFeedState } from "./DumpFeed.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts"; import { useDumpListSync } from "../../hooks/dump/useDumpListSync.ts";
import { useFeedCache } from "../../hooks/feed/useFeedCache.ts"; import { useFeedCache } from "../../hooks/feed/useFeedCache.ts";
import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../../hooks/feed/useInfiniteScroll.ts";
import { usePagedList } from "../../hooks/feed/usePagedList.ts";
import { useScrollSave } from "../../hooks/feed/useScrollSave.ts"; import { useScrollSave } from "../../hooks/feed/useScrollSave.ts";
import { import {
deserializeDump, deserializeDump,
hydrateDump, hydrateDump,
} from "../../utils/shared/deserialize.ts"; } from "../../utils/shared/deserialize.ts";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts"; import { canLoadMore, pageUrl, readPage } from "../../utils/feed/pagedList.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts";
type FollowedSection = "users" | "playlists"; type FollowedSection = "users" | "playlists";
@@ -38,97 +38,33 @@ function useFollowedSection(
section: FollowedSection, section: FollowedSection,
categorySlug: string | undefined, categorySlug: string | undefined,
): { state: DumpFeedState; loadMore: () => void } { ): { state: DumpFeedState; loadMore: () => void } {
const { token } = useAuth(); const { user, authFetch } = useAuth();
const catQuery = categorySlug const url = `${API_URL}/api/follows/feed/${section}${
? `&category=${encodeURIComponent(categorySlug)}` categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
: ""; }`;
const { cached, saveState } = useFeedCache<Dump>( const { cached, saveState } = useFeedCache<Dump>(
`feed:followed-${section}:${categorySlug ?? "all"}`, `feed:followed-${section}:${categorySlug ?? "all"}`,
hydrateDump, hydrateDump,
); );
const [state, setState] = useState<DumpFeedState>(() => const { state, setItems, loadMore } = usePagedList(
cached user ? `${url}:${user.id}` : undefined,
? { (page, signal) =>
status: "loaded", authFetch(pageUrl(url, page), { signal })
dumps: cached.items, .then((res) => readPage(res, deserializeDump)),
hasMore: cached.hasMore, cached,
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); useDumpListSync(setItems);
useEffect(() => { // Only the list is kept here, since `Index` saves and restores the page's
if (!token || state.status !== "loading") return; // scroll for every tab, this one included.
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( useScrollSave(
state.status === "loaded", state.status === "loaded",
useCallback(() => { useCallback(() => {
if (state.status !== "loaded") return; if (state.status === "loaded") saveState(state.data, 0);
saveState(state.dumps, state.page, state.hasMore, 0);
}, [state, saveState]), }, [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 }; return { state, loadMore };
} }
@@ -144,18 +80,18 @@ function FollowedSubFeed(
) { ) {
const sentinelRef = useInfiniteScroll( const sentinelRef = useInfiniteScroll(
onLoadMore, onLoadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore, state.status === "loaded" && canLoadMore(state.data),
); );
return ( return (
<ListState <ListState
state={state} state={state}
isEmpty={({ dumps }) => dumps.length === 0} isEmpty={({ data }) => data.items.length === 0}
empty={<p className="index-status">{emptyMessage}</p>} empty={<p className="index-status">{emptyMessage}</p>}
> >
{({ dumps, hasMore, loadingMore }) => ( {({ data: { items, hasMore, loadingMore } }) => (
<> <>
<DumpCardList dumps={dumps} /> <DumpCardList dumps={items} />
<FeedFooter <FeedFooter
hasMore={hasMore} hasMore={hasMore}
loadingMore={loadingMore} loadingMore={loadingMore}

View File

@@ -1,6 +1,6 @@
/** /**
* The "journal" tab of the index page, which lays the feed out as a mosaic of * Renders the "journal" tab of the index page, which lays the feed out as a
* tiles of several sizes. * mosaic of tiles of several sizes.
* *
* @module * @module
*/ */
@@ -37,7 +37,7 @@ export function JournalFeed(
</p> </p>
} }
> >
{({ hasMore, loadingMore }) => ( {({ data: { hasMore, loadingMore } }) => (
<> <>
{ {
/* Each block is its own grid, with a themable <hr> between them. /* Each block is its own grid, with a themable <hr> between them.

View File

@@ -59,7 +59,7 @@ export function SegmentedField<T extends FieldValues, V>({
); );
} }
/** The Public/Private options, true for public. */ // Public is true.
const VISIBILITY_OPTIONS: SegmentedOption<boolean>[] = [ const VISIBILITY_OPTIONS: SegmentedOption<boolean>[] = [
{ value: true, label: <Trans>Public</Trans> }, { value: true, label: <Trans>Public</Trans> },
{ value: false, label: <Trans>Private</Trans> }, { value: false, label: <Trans>Private</Trans> },
@@ -95,8 +95,8 @@ export function VisibilityToggle<T extends FieldValues>({
/** /**
* Renders a Public/Private {@link Segmented} outside `SegmentedField`, for * Renders a Public/Private {@link Segmented} outside `SegmentedField`, for
* `PlaylistDetail`'s edit form, which binds it with its own `Controller` and * `PlaylistEditForm`, which binds it with its own `Controller` and styles it
* styles it apart. * apart.
*/ */
export function VisibilitySegmented( export function VisibilitySegmented(
props: Omit<SegmentedProps<boolean>, "options">, props: Omit<SegmentedProps<boolean>, "options">,

View File

@@ -8,11 +8,7 @@ import type { Playlist } from "../../../shared/playlist.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { PlaylistCard } from "./PlaylistCard.tsx"; import { PlaylistCard } from "./PlaylistCard.tsx";
/** /** Passed to {@link PlaylistCardList} by every list of playlist cards. */
* 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 { export interface PlaylistCardListProps {
playlists: Playlist[]; playlists: Playlist[];
/** Given, each card shows a delete button, which calls it. */ /** Given, each card shows a delete button, which calls it. */

View File

@@ -0,0 +1,128 @@
/**
* Renders the dumps of a playlist's page, which those who may edit it drag
* into a new order and remove.
*
* @module
*/
import { useRef, useState } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { Dump } from "../../../shared/dump.ts";
import { fadeClassName, type FadeState } from "../../hooks/feed/useFading.ts";
import { LiveDumpCard } from "../dump/DumpCardList.tsx";
/**
* Passed to {@link PlaylistDumpList} by `PlaylistDetail`, from `usePlaylist`.
* The calls are given only to those who may edit the playlist.
*/
export interface PlaylistDumpListProps {
dumps: Dump[];
fading: Record<string, FadeState>;
onRemove?: (dumpId: string) => void;
onUndoRemove?: (dumpId: string) => void;
/** Counts only the dumps not fading, which alone can be dragged. */
onMove?: (from: number, to: number) => void;
/** Called as a drag ends, to save the order `onMove` left. */
onMoved?: () => void;
}
/**
* Renders the dumps, or a line saying there are none. With the calls, each
* dump not fading has a drag handle and a remove button, and one fading an
* "Undo" during its cooldown.
*/
export function PlaylistDumpList(
{ dumps, fading, onRemove, onUndoRemove, onMove, onMoved }:
PlaylistDumpListProps,
): React.JSX.Element {
// A ref, since dragover fires faster than state renders.
const dragSrcRef = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
if (dumps.length === 0) {
return (
<p className="empty-state">
<Trans>No dumps in this playlist yet.</Trans>
</p>
);
}
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
const src = dragSrcRef.current;
if (src === null || src === index) return;
// Swapping as soon as the pointer enters a card would swap it straight
// back, so the pointer must cross the card's middle first.
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (src < index && e.clientY < mid) return;
if (src > index && e.clientY > mid) return;
onMove?.(src, index);
dragSrcRef.current = index;
setDragOverIndex(index);
};
const handleDragEnd = () => {
const dragged = dragSrcRef.current !== null;
dragSrcRef.current = null;
setDragOverIndex(null);
if (dragged) onMoved?.();
};
const canDrag = !!onMove;
let activeIndex = 0;
return (
<div
className="playlist-dump-list"
onDragOver={canDrag ? (e) => e.preventDefault() : undefined}
>
{dumps.map((dump) => {
const phase = fading[dump.id];
const index = phase ? -1 : activeIndex++;
const draggable = canDrag && !phase;
return (
<div
key={dump.id}
className={`playlist-dump-item${
draggable && index === dragOverIndex
? " playlist-dump-item--drag-over"
: ""
}`}
draggable={draggable}
onDragStart={draggable
? () => {
dragSrcRef.current = index;
}
: undefined}
onDragOver={draggable ? (e) => handleDragOver(e, index) : undefined}
onDragEnd={canDrag ? handleDragEnd : undefined}
>
{draggable && <span className="drag-handle" aria-hidden>⠿</span>}
<LiveDumpCard dump={dump} className={fadeClassName(phase)} />
{!phase && onRemove && (
<button
type="button"
className="playlist-remove-btn"
onClick={() => onRemove(dump.id)}
aria-label={t`Remove from playlist`}
>
✕
</button>
)}
{phase === "cooldown" && onUndoRemove && (
<button
type="button"
className="playlist-cancel-btn"
onClick={() => onUndoRemove(dump.id)}
aria-label={t`Cancel removal`}
>
<Trans>Undo</Trans>
</button>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,220 @@
/**
* Renders the form that edits a playlist's details and cover, in place of the
* header of its page.
*
* @module
*/
import { useEffect, useState } from "react";
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 {
Playlist,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../../shared/playlist.ts";
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
import { playlistImageUrl } from "../../utils/shared/urls.ts";
import { expectOk } from "../../utils/shared/api.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useApiForm } from "../../hooks/form/useApiForm.ts";
import { CountedInput } from "../shared/CountedInput.tsx";
import { ImagePicker } from "../shared/ImagePicker.tsx";
import { TextEditor } from "../editor/TextEditor.tsx";
import { VisibilitySegmented } from "../form/SegmentedField.tsx";
import { FormError } from "../form/FormError.tsx";
import { SubmitButton } from "../form/SubmitButton.tsx";
/** Passed to {@link PlaylistEditForm} by `PlaylistDetail`. */
export interface PlaylistEditFormProps {
playlist: PlaylistWithDumps;
onCancel: () => void;
/** Opens the page's confirmation, which deletes the playlist. */
onRequestDelete: () => void;
/** Called with the playlist as the server now has it. */
onSaved: (playlist: Playlist) => void;
}
interface EditValues {
title: string;
description: string;
isPublic: boolean;
image: File | null;
}
/**
* Renders the form that edits the playlist, in place of the page's header. It
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
* title and description that changed, then a new cover, if one was picked,
* with `POST /api/playlists/:playlistId/image`.
*/
export function PlaylistEditForm(
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
): React.JSX.Element {
const { authFetch, mediaToken } = useAuth();
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Each preview holds the file in memory until its URL is revoked: when the
// next one replaces it, or when the form closes.
useEffect(() => {
if (!imagePreview) return;
return () => URL.revokeObjectURL(imagePreview);
}, [imagePreview]);
const form = useApiForm<EditValues>({
defaultValues: {
title: playlist.title,
description: playlist.description ?? "",
isPublic: playlist.isPublic,
image: null,
},
});
const description = form.watch("description");
const title = form.watch("title");
const onSubmit = form.submit(
async ({ title, description, isPublic, image }) => {
const updated = deserializePlaylist(
await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
...(title !== playlist.title ? { title } : {}),
...(description !== (playlist.description ?? "")
? { description: description || null }
: {}),
isPublic,
} satisfies UpdatePlaylistRequest,
),
}),
),
);
if (image) {
const fd = new FormData();
fd.append("file", image);
// The upload answers with the playlist as it now stands (new cover,
// new updatedAt), which is what the page should show.
onSaved(deserializePlaylist(
await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
}),
),
));
return;
}
onSaved(updated);
},
);
return (
<FormProvider {...form}>
{
/* `display: contents` keeps the header's layout, the cover beside the
fields, around a real <form>, which Enter submits. */
}
<form style={{ display: "contents" }} onSubmit={onSubmit}>
<Controller
name="image"
control={form.control}
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? playlistImageUrl(playlist, mediaToken)
: null)}
alt="Cover"
size={72}
className="animate-in"
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)}
/>
<div className="playlist-detail-content playlist-edit-content animate-in">
<Controller
name="title"
control={form.control}
render={({ field }) => (
<CountedInput
className="playlist-edit-input"
value={field.value}
onChange={field.onChange}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
autoFocus
placeholder={t`Playlist title`}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="playlist-edit-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={onCancel}
placeholder={t`Description (optional)`}
autoResize
rows={2}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)}
/>
<Controller
name="isPublic"
control={form.control}
render={({ field }) => (
<VisibilitySegmented
className="playlist-edit-toggle"
value={field.value}
onChange={field.onChange}
/>
)}
/>
<FormError title={t`Failed to save`} />
<div className="form-actions">
<button
type="button"
className="btn-danger"
onClick={onRequestDelete}
>
<Trans>Delete</Trans>
</button>
<div className="form-actions-right">
<button type="button" className="form-cancel" onClick={onCancel}>
<Trans>Cancel</Trans>
</button>
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={!title.trim() ||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</div>
</form>
</FormProvider>
);
}

View File

@@ -0,0 +1,101 @@
/**
* Renders the header of a playlist's page, with its cover, title, description,
* visibility, owner and dates.
*
* @module
*/
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import type { PlaylistWithDumps } from "../../../shared/playlist.ts";
import { playlistImageUrl } from "../../utils/shared/urls.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { Markdown } from "../editor/Markdown.tsx";
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
export interface PlaylistHeaderProps {
playlist: PlaylistWithDumps;
/** Given to those who may edit it, for whom it adds an "Edit" button. */
onEdit?: () => void;
}
/**
* Renders the playlist's details, with a follow button for anyone but its
* owner. `PlaylistEditForm` takes its place while it is edited.
*/
export function PlaylistHeader(
{ playlist, onEdit }: PlaylistHeaderProps,
): React.JSX.Element {
const { user, mediaToken } = useAuth();
return (
<>
{playlist.imageMime && (
<img
src={playlistImageUrl(playlist, mediaToken)}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-content">
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{user?.id !== playlist.userId && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{onEdit && (
<button
type="button"
className="playlist-edit-btn"
onClick={onEdit}
>
<Trans>Edit</Trans>
</button>
)}
</div>
{playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
</span>
{playlist.ownerUsername && (
<Link
to={`/users/${playlist.ownerUsername}`}
className="playlist-detail-owner"
>
@{playlist.ownerUsername}
</Link>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
<time dateTime={playlist.createdAt.toISOString()}>
{relativeTime(playlist.createdAt)}
</time>
</Tooltip>
{playlist.updatedAt && (
<Tooltip text={t`Edited ${playlist.updatedAt.toLocaleString()}`}>
<span className="playlist-edited-label">
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
</span>
</Tooltip>
)}
</div>
</div>
</>
);
}

View File

@@ -45,7 +45,8 @@ export function DescriptionEditor(
const onSubmit = form.submit(async ({ description }) => { const onSubmit = form.submit(async ({ description }) => {
const trimmed = description.trim(); const trimmed = description.trim();
// Sent even when empty: left out, the server would keep the old one. // Sent even when empty, since the server keeps the old one when it is left
// out.
await expectOk<Wire<PublicUser>>( await expectOk<Wire<PublicUser>>(
await authFetch(`${API_URL}/api/users/me`, { await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH", method: "PATCH",

View File

@@ -1,5 +1,6 @@
/** /**
* The "dumps" tab of a profile: the user's dumps beside those they upvoted. * Renders the "dumps" tab of a profile, which shows the user's dumps beside
* those they upvoted.
* *
* @module * @module
*/ */
@@ -18,7 +19,7 @@ export interface ProfileDumpsTabProps {
dumps: PagedList<Dump>; dumps: PagedList<Dump>;
votes: PagedList<Dump>; votes: PagedList<Dump>;
isOwnProfile: boolean; isOwnProfile: boolean;
/** From `useProfile`, which fades out a withdrawn upvote. */ /** It comes from `useProfile`, which fades out a withdrawn upvote. */
upvotedCardClassName: (dump: Dump) => string | undefined; upvotedCardClassName: (dump: Dump) => string | undefined;
} }

View File

@@ -1,6 +1,6 @@
/** /**
* The "followed" tab of a profile: the users and the playlists the user * Renders the "followed" tab of a profile, which shows the users and the
* follows. * playlists the user follows.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The "invitees" tab of a profile: whom the user invited, and whom they * Renders the "invitees" tab of a profile, which shows whom the user invited,
* invited in turn. * and whom they invited in turn.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The "manage" tab of a profile, which holds tools for another user on their * Renders the "manage" tab of a profile, which holds tools for another user on
* profile and site-wide tools on one's own. * their profile and site-wide tools on one's own.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* Renders the actions of the signed-in user's own profile header: inviting * Renders the actions of the signed-in user's own profile header, which invite
* someone, and whatever the header adds, the log out button. * someone, beside the log out button the header adds.
* *
* @module * @module
*/ */

View File

@@ -1,5 +1,6 @@
/** /**
* The "playlists" tab of a profile: the playlists the user made. * Renders the "playlists" tab of a profile, which shows the playlists the user
* made.
* *
* @module * @module
*/ */
@@ -17,7 +18,7 @@ export interface ProfilePlaylistsTabProps {
username: string; username: string;
playlists: PagedList<Playlist>; playlists: PagedList<Playlist>;
isOwnProfile: boolean; isOwnProfile: boolean;
/** From `useProfile`, to put a playlist made here first. */ /** It comes from `useProfile`, and puts a playlist made here first. */
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
} }

View File

@@ -14,11 +14,11 @@ import { Trans } from "@lingui/react/macro";
*/ */
export interface ProfileSectionProps { export interface ProfileSectionProps {
title: ReactNode; title: ReactNode;
/** Buttons at the end of the title row. */ /** They go at the end of the title row. */
actions?: ReactNode; actions?: ReactNode;
/** /**
* The page with the whole list, of which the section shows the first page. * It links to the page with the whole list, of which the section shows the
* The profile leaves it out while the list is empty. * first page. The profile leaves it out while the list is empty.
*/ */
viewAllHref?: string; viewAllHref?: string;
children: ReactNode; children: ReactNode;

View File

@@ -1,6 +1,6 @@
/** /**
* The "settings" tab of the signed-in user's own profile: their password, and * Renders the "settings" tab of the signed-in user's own profile, where they
* this browser's preferences. * change their password and this browser's preferences.
* *
* @module * @module
*/ */

View File

@@ -11,7 +11,7 @@ import type { ReactNode } from "react";
*/ */
export interface SettingRowProps { export interface SettingRowProps {
label: ReactNode; label: ReactNode;
/** The control, usually a `Segmented`. */ /** It is the row's control, usually a `Segmented`. */
children: ReactNode; children: ReactNode;
} }

View File

@@ -8,8 +8,8 @@
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
/** /**
* Passed to {@link LoadMoreButton} by `Notifications`, `UserListPopover` and * Passed to {@link LoadMoreButton} by the lists that load their next page on a
* the profile's followed users, which render it while there is more to load. * click, which render it while there is more to load.
*/ */
export interface LoadMoreButtonProps { export interface LoadMoreButtonProps {
loadingMore: boolean; loadingMore: boolean;

View File

@@ -19,7 +19,7 @@ export interface FollowUserButtonProps {
targetUsername: string; targetUsername: string;
} }
/** Passed to {@link FollowPlaylistButton} by the `PlaylistDetail` page. */ /** Passed to {@link FollowPlaylistButton} by `PlaylistHeader`. */
export interface FollowPlaylistButtonProps { export interface FollowPlaylistButtonProps {
targetPlaylistId: string; targetPlaylistId: string;
/** Hides the button, since only a public playlist can be followed. */ /** Hides the button, since only a public playlist can be followed. */

View File

@@ -8,16 +8,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { Avatar } from "../shared/Avatar.tsx"; import { Avatar } from "../shared/Avatar.tsx";
import { LoadMoreButton } from "../shared/LoadMoreButton.tsx"; import { LoadMoreButton } from "../shared/LoadMoreButton.tsx";
import { useAuth } from "../../hooks/auth/useAuth.ts"; import { useAuth } from "../../hooks/auth/useAuth.ts";
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { usePagedList } from "../../hooks/feed/usePagedList.ts";
import { deserializePublicUser } from "../../utils/shared/deserialize.ts"; import { deserializePublicUser } from "../../utils/shared/deserialize.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts"; import { pageUrl, readPage } from "../../utils/feed/pagedList.ts";
import { parseAPIResponse } from "../../utils/shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
/** /**
* Passed to {@link UserListPopover} by the button whose count was clicked. The * Passed to {@link UserListPopover} by the button whose count was clicked. The
@@ -28,23 +25,12 @@ export interface UserListPopoverProps {
/** Called on an outside click, on Escape, and on a scroll outside it. */ /** Called on an outside click, on Escape, and on a scroll outside it. */
onClose: () => void; onClose: () => void;
/** /**
* A paginated endpoint answering `PaginatedData<PublicUser>`, without a query * It answers `PaginatedData<PublicUser>`, a page at a time, and the popover
* string: the popover appends `page` and `limit`. * adds `page` and `limit` to its query.
*/ */
fetchUrl: string; fetchUrl: string;
} }
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
users: PublicUser[];
page: number;
hasMore: boolean;
loadingMore: boolean;
};
// In viewport coordinates. `bottom` replaces `top` when the popover opens above // In viewport coordinates. `bottom` replaces `top` when the popover opens above
// the anchor, so that it grows upward. // the anchor, so that it grows upward.
interface Placement { interface Placement {
@@ -69,8 +55,13 @@ const MIN_USABLE_HEIGHT = 100;
export function UserListPopover( export function UserListPopover(
{ anchorRef, onClose, fetchUrl }: UserListPopoverProps, { anchorRef, onClose, fetchUrl }: UserListPopoverProps,
): React.ReactPortal | null { ): React.ReactPortal | null {
const { token } = useAuth(); const { user, authFetch } = useAuth();
const [state, setState] = useState<State>({ status: "loading" }); const { state, loadMore } = usePagedList(
`${fetchUrl}:${user?.id ?? "guest"}`,
(page, signal) =>
authFetch(pageUrl(fetchUrl, page), { signal })
.then((res) => readPage(res, deserializePublicUser)),
);
const [placement, setPlacement] = useState<Placement | null>(null); const [placement, setPlacement] = useState<Placement | null>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@@ -139,71 +130,6 @@ export function UserListPopover(
}; };
}, [onClose, anchorRef]); }, [onClose, anchorRef]);
useEffect(() => {
const controller = new AbortController();
fetch(`${fetchUrl}?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const apiResponse = parseAPIResponse<PaginatedData<Wire<PublicUser>>>(
body,
);
if (!apiResponse.success) {
setState({ status: "error", error: apiResponse.error.message });
return;
}
setState({
status: "loaded",
users: apiResponse.data.items.map(deserializePublicUser),
page: 1,
hasMore: apiResponse.data.hasMore,
loadingMore: false,
});
})
.catch(() => {
if (controller.signal.aborted) return;
setState({ status: "error", error: t`Could not load.` });
});
return () => controller.abort();
}, [fetchUrl, token]);
const handleLoadMore = () => {
if (state.status !== "loaded" || state.loadingMore) return;
const nextPage = state.page + 1;
setState({ ...state, loadingMore: true });
fetch(`${fetchUrl}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((body) => {
const apiResponse = parseAPIResponse<PaginatedData<Wire<PublicUser>>>(
body,
);
setState((prev) => {
if (prev.status !== "loaded") return prev;
if (!apiResponse.success) return { ...prev, loadingMore: false };
return {
status: "loaded",
users: [
...prev.users,
...apiResponse.data.items.map(deserializePublicUser),
],
page: nextPage,
hasMore: apiResponse.data.hasMore,
loadingMore: false,
};
});
})
// Offline or an unreadable reply: give the button back for a retry.
.catch(() =>
setState((prev) =>
prev.status === "loaded" ? { ...prev, loadingMore: false } : prev
)
);
};
if (!placement) return null; if (!placement) return null;
return createPortal( return createPortal(
@@ -227,14 +153,14 @@ export function UserListPopover(
{state.status === "error" && ( {state.status === "error" && (
<p className="user-list-popover-status">{state.error}</p> <p className="user-list-popover-status">{state.error}</p>
)} )}
{state.status === "loaded" && state.users.length === 0 && ( {state.status === "loaded" && state.data.items.length === 0 && (
<p className="user-list-popover-status"> <p className="user-list-popover-status">
<Trans>No one yet.</Trans> <Trans>No one yet.</Trans>
</p> </p>
)} )}
{state.status === "loaded" && state.users.length > 0 && ( {state.status === "loaded" && state.data.items.length > 0 && (
<> <>
{state.users.map((u) => ( {state.data.items.map((u) => (
<Link <Link
key={u.id} key={u.id}
to={`/users/${u.username}`} to={`/users/${u.username}`}
@@ -251,11 +177,11 @@ export function UserListPopover(
<span>{u.username}</span> <span>{u.username}</span>
</Link> </Link>
))} ))}
{state.hasMore && ( {state.data.hasMore && (
<LoadMoreButton <LoadMoreButton
className="user-list-popover-load-more" className="user-list-popover-load-more"
loadingMore={state.loadingMore} loadingMore={state.data.loadingMore}
onClick={handleLoadMore} onClick={loadMore}
/> />
)} )}
</> </>

View File

@@ -22,7 +22,7 @@ import { deserializeCategory } from "../../utils/shared/deserialize.ts";
import type { Wire } from "../../../shared/api.ts"; import type { Wire } from "../../../shared/api.ts";
import { expectOk } from "../../utils/shared/api.ts"; import { expectOk } from "../../utils/shared/api.ts";
/** How long a failed first fetch waits before it is tried again. */ // A failed first fetch waits this long before it is tried again.
const RETRY_MS = 10_000; const RETRY_MS = 10_000;
/** /**

View File

@@ -28,7 +28,10 @@ export interface FollowContextValue {
isLoaded: boolean; isLoaded: boolean;
} }
/** Given to a consumer outside `FollowProvider`: nothing is followed. */ /**
* Given to a consumer outside `FollowProvider`, for which nothing is
* followed.
*/
export const defaultFollowValue: FollowContextValue = { export const defaultFollowValue: FollowContextValue = {
followedUserIds: new Set(), followedUserIds: new Set(),
followedPlaylistIds: new Set(), followedPlaylistIds: new Set(),

View File

@@ -23,7 +23,7 @@ export interface FollowProviderProps {
children: ReactNode; children: ReactNode;
} }
/** How long a failed fetch of the follows waits before it is tried again. */ // A failed fetch of the follows waits this long before it is tried again.
const RETRY_MS = 10_000; const RETRY_MS = 10_000;
/** /**

View File

@@ -8,9 +8,9 @@
import { createContext } from "react"; import { createContext } from "react";
/** /**
* Every site style, which `ThemeProvider` checks a stored choice against. Each * Lists every site style, which `ThemeProvider` checks a stored choice
* has a stylesheet under `src/themes/`, keyed on the root element's * against. Each has a stylesheet under `src/themes/`, keyed on the root
* `data-style`, which is absent for "smooth". * element's `data-style`, which is absent for "smooth".
*/ */
export const STYLE_NAMES = [ export const STYLE_NAMES = [
"smooth", "smooth",
@@ -20,12 +20,12 @@ export const STYLE_NAMES = [
"kanagawa", "kanagawa",
"melange", "melange",
] as const; ] as const;
/** One of {@link STYLE_NAMES}. */ /** Held by `ThemeProvider` for the style chosen in this browser. */
export type StyleName = typeof STYLE_NAMES[number]; export type StyleName = typeof STYLE_NAMES[number];
/** Every color scheme; "auto" follows the system's. */ /** Lists every color scheme. "auto" follows the system's. */
export const COLOR_SCHEMES = ["auto", "light", "dark"] as const; export const COLOR_SCHEMES = ["auto", "light", "dark"] as const;
/** One of {@link COLOR_SCHEMES}. */ /** Held by `ThemeProvider` for the color scheme chosen in this browser. */
export type ColorScheme = typeof COLOR_SCHEMES[number]; export type ColorScheme = typeof COLOR_SCHEMES[number];
/** /**

View File

@@ -140,14 +140,14 @@ export interface WSContextValue {
*/ */
recentDumps: Dump[]; recentDumps: Dump[];
/** /**
* Dumps deleted this session, or made private by someone else, for the * It holds the dumps deleted this session, or made private by someone else,
* lists to filter out. A dump made public again leaves it. * for the lists to filter out. A dump made public again leaves it.
*/ */
deletedDumpIds: Set<string>; deletedDumpIds: Set<string>;
lastVoteEvent: VoteEvent | null; lastVoteEvent: VoteEvent | null;
/** /**
* The dump as its latest `dump_created` or `dump_updated` has it, for the * It holds the dump as its latest `dump_created` or `dump_updated` has it,
* lists to update it in place, or put it back where it was. * for the lists to update it in place, or put it back where it was.
*/ */
lastDumpEvent: Dump | null; lastDumpEvent: Dump | null;
lastPlaylistEvent: PlaylistEvent | null; lastPlaylistEvent: PlaylistEvent | null;

View File

@@ -93,13 +93,15 @@ const CHAT_READ_THROTTLE = 1_000;
*/ */
const CHAT_ECHO_WINDOW = 60_000; const CHAT_ECHO_WINDOW = 60_000;
/** Toggles of one item that the server hasn't acknowledged yet. */ /**
* Held by {@link useOptimisticToggles} for an item whose toggles the server
* hasn't all acknowledged yet. `count` and `on` are as before the first of
* them, for a failure to restore.
*/
interface PendingToggle { interface PendingToggle {
timeout: ReturnType<typeof setTimeout>; timeout: ReturnType<typeof setTimeout>;
/** The count and state before the first of them, to restore on failure. */
count: number; count: number;
on: boolean; on: boolean;
/** How many acks are still to come. */
outstanding: number; outstanding: number;
} }
@@ -117,7 +119,7 @@ interface OptimisticToggles {
/** Restores the item as it was before its toggles, now. */ /** Restores the item as it was before its toggles, now. */
fail: (id: string) => void; fail: (id: string) => void;
/** /**
* Settles every toggle against the server's set after a (re)connect: each * Settles every toggle against the server's set after a (re)connect. Each
* count moves by one from its baseline when the server's state differs. * count moves by one from its baseline when the server's state differs.
*/ */
reconcile: (serverOn: Set<string>) => void; reconcile: (serverOn: Set<string>) => void;
@@ -265,7 +267,7 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
const [avatarOverrides, setAvatarOverrides] = useState< const [avatarOverrides, setAvatarOverrides] = useState<
Record<string, AvatarOverride> Record<string, AvatarOverride>
>({}); >({});
// Never reset: pages compare it with the value they last saw. // It is never reset, since pages compare it with the value they last saw.
const [connectionEpoch, setConnectionEpoch] = useState(0); const [connectionEpoch, setConnectionEpoch] = useState(0);
const [followsVersion, setFollowsVersion] = useState(0); const [followsVersion, setFollowsVersion] = useState(0);

View File

@@ -10,8 +10,8 @@ import type { Dump } from "../../../shared/dump.ts";
import { useWS } from "../ws/useWS.ts"; import { useWS } from "../ws/useWS.ts";
/** /**
* Passed to {@link useDumpListSync} by a profile's own list of dumps. The * Passed to {@link useDumpListSync} by a profile's own list of dumps, and by
* feeds pass none. * `usePlaylist` for the playlist's owner. The feeds pass none.
*/ */
export interface DumpListSyncOptions { export interface DumpListSyncOptions {
/** Keeps an edited dump that is now private, for its owner's own list. */ /** Keeps an edited dump that is now private, for its owner's own list. */
@@ -23,7 +23,8 @@ export interface DumpListSyncOptions {
* updates. A deleted dump is removed, an edited one is replaced in place, and * updates. A deleted dump is removed, an edited one is replaced in place, and
* a new or deleted comment moves its dump's `commentCount`. It never adds a * a new or deleted comment moves its dump's `commentCount`. It never adds a
* dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the * dump: the feeds merge `WSContext`'s `recentDumps` themselves, and the
* profiles put back a dump that returns with `usePositionAwareSync`. * profiles and `usePlaylist` put back a dump that returns with
* `usePositionAwareSync`.
*/ */
export function useDumpListSync( export function useDumpListSync(
setDumps: (fn: (prev: Dump[]) => Dump[]) => void, setDumps: (fn: (prev: Dump[]) => Dump[]) => void,

View File

@@ -17,25 +17,27 @@ import { fadeClassName, useFading } from "../feed/useFading.ts";
/** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */ /** Passed to {@link useUpvotedList} by `UserUpvoted` and the profile. */
export interface UseUpvotedListOptions { export interface UseUpvotedListOptions {
/** Whose upvotes the list holds, null while their profile loads. */ /**
* The list holds this user's upvotes. It is null while their profile loads.
*/
profileUserId: string | null; profileUserId: string | null;
/** Does nothing until the list is loaded. */ /** Does nothing until the list is loaded. */
setItems: (fn: (prev: Dump[]) => Dump[]) => void; setItems: (fn: (prev: Dump[]) => Dump[]) => void;
/** /**
* Dumps already at hand, which a vote of the signed-in user on their own * It holds dumps already at hand, which a vote of the signed-in user on
* list puts first at once, before the socket's vote event. The profile * their own list puts first at once, before the socket's vote event. The
* passes its first page of the user's dumps. * profile passes its first page of the user's dumps.
*/ */
source?: Dump[]; source?: Dump[];
} }
/** Returned by {@link useUpvotedList}. */ /** Returned by {@link useUpvotedList}. */
export interface UseUpvotedListResult { export interface UseUpvotedListResult {
/** For `DumpCardList`'s `cardClassName`. */ /** It goes to `DumpCardList`'s `cardClassName`. */
cardClassName: (dump: Dump) => string | undefined; cardClassName: (dump: Dump) => string | undefined;
/** /**
* Whether the vote on dump `id` was withdrawn since the list loaded. The * Checks whether the vote on dump `id` was withdrawn since the list loaded.
* profile's `usePositionAwareSync` keeps such a dump from coming back. * The profile's `usePositionAwareSync` keeps such a dump from coming back.
*/ */
isWithdrawn: (id: string) => boolean; isWithdrawn: (id: string) => boolean;
} }
@@ -87,9 +89,10 @@ export function useUpvotedList(
if (!isOwn) return; if (!isOwn) return;
const prev = prevMyVotesRef.current; const prev = prevMyVotesRef.current;
prevMyVotesRef.current = new Set(myVotes); prevMyVotesRef.current = new Set(myVotes);
// Only a baseline: the list as loaded stands. myVotes may still be the // This is only a baseline, and the list as loaded stands. myVotes may
// empty set from before the socket's welcome (or the socket may never // still be the empty set from before the socket's welcome (or the socket
// connect), and taking it as the truth here would hide every dump. // may never connect), and taking it as the truth here would hide every
// dump.
if (prev === null) return; if (prev === null) return;
for (const id of prev) if (!myVotes.has(id)) withdraw(id); for (const id of prev) if (!myVotes.has(id)) withdraw(id);
const added = new Set([...myVotes].filter((id) => !prev.has(id))); const added = new Set([...myVotes].filter((id) => !prev.has(id)));

View File

@@ -7,12 +7,20 @@
import { type RefCallback, useCallback, useEffect, useState } from "react"; import { type RefCallback, useCallback, useEffect, useState } from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { API_URL } from "../../config/api.ts";
import { expectOk, friendlyFetchError } from "../../utils/shared/api.ts"; import { friendlyFetchError } from "../../utils/shared/api.ts";
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
import { fetchFirstPage } from "../../utils/feed/pagedList.ts"; import {
canLoadMore,
mapItems,
type PagedList,
pageUrl,
readFirstPage,
readPage,
restoredList,
} from "../../utils/feed/pagedList.ts";
import type { LoadState } from "../../utils/shared/loadState.ts";
import type { Dump } from "../../../shared/dump.ts"; import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts"; import type { PublicUser } from "../../../shared/user.ts";
import { import {
deserializeDump, deserializeDump,
@@ -21,6 +29,7 @@ import {
import { useAuth } from "../auth/useAuth.ts"; import { useAuth } from "../auth/useAuth.ts";
import { useFeedCache } from "../feed/useFeedCache.ts"; import { useFeedCache } from "../feed/useFeedCache.ts";
import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../feed/useInfiniteScroll.ts";
import { useLoadMore } from "../feed/usePagedList.ts";
import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts"; import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
/** /**
@@ -28,24 +37,20 @@ import { useScrollRestore, useScrollSave } from "../feed/useScrollSave.ts";
* when the user doesn't exist, and holds an empty list when only the dumps * when the user doesn't exist, and holds an empty list when only the dumps
* failed to load. * failed to load.
*/ */
export type UserDumpFeedState = export type UserDumpFeedState = LoadState<{
| { status: "loading" } profileUser: PublicUser;
| { status: "error"; error: string } dumps: PagedList<Dump>;
| { }>;
status: "loaded";
profileUser: PublicUser;
items: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
/** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */ /** Returned by {@link useUserDumpFeed} to `UserDumps` and `UserUpvoted`. */
export interface UseUserDumpFeedResult { export interface UseUserDumpFeedResult {
state: UserDumpFeedState; state: UserDumpFeedState;
/** Does nothing until loaded. Passed to `useDumpListSync` and `useUpvotedList`. */ /**
* It does nothing until loaded. It goes to `useDumpListSync` and
* `useUpvotedList`.
*/
setItems: (fn: (prev: Dump[]) => Dump[]) => void; setItems: (fn: (prev: Dump[]) => Dump[]) => void;
/** From `useInfiniteScroll`, for an element after the last card. */ /** It comes from `useInfiniteScroll`, for an element after the last card. */
sentinelRef: RefCallback<HTMLDivElement>; sentinelRef: RefCallback<HTMLDivElement>;
} }
@@ -62,7 +67,7 @@ export function useUserDumpFeed(
endpoint: string, endpoint: string,
cacheKey: string, cacheKey: string,
): UseUserDumpFeedResult { ): UseUserDumpFeedResult {
const { token } = useAuth(); const { authFetch } = useAuth();
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump); const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
const [state, setState] = useState<UserDumpFeedState>({ status: "loading" }); const [state, setState] = useState<UserDumpFeedState>({ status: "loading" });
@@ -72,9 +77,23 @@ export function useUserDumpFeed(
setState({ status: "loading" }); setState({ status: "loading" });
} }
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => { const setDumps = useCallback(
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) }); (fn: (list: PagedList<Dump>) => PagedList<Dump>) =>
}, []); setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }),
[],
);
const setItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) => setDumps(mapItems(fn)),
[setDumps],
);
const url = `${API_URL}/api/users/${username}/${endpoint}`;
const fetchPage = useCallback(
(page: number, signal?: AbortSignal) =>
authFetch(pageUrl(url, page), { signal })
.then((res) => readPage(res, deserializeDump)),
[authFetch, url],
);
useEffect(() => { useEffect(() => {
if (!username) return; if (!username) return;
@@ -82,72 +101,31 @@ export function useUserDumpFeed(
const { signal } = controller; const { signal } = controller;
// Back on the page, the list comes from the cache and only the user is // Back on the page, the list comes from the cache and only the user is
// fetched again. // fetched again.
const list = cached ?? fetchFirstPage( const list = cached
`${API_URL}/api/users/${username}/${endpoint}`, ? restoredList(cached)
deserializeDump, : authFetch(pageUrl(url, 1), { signal })
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal }, .then((res) => readFirstPage(res, deserializeDump));
);
Promise.all([fetchPublicUser(username, signal), list]) Promise.all([fetchPublicUser(username, signal), list])
.then(([profileUser, { items, hasMore, page }]) => .then(([profileUser, dumps]) =>
setState({ setState({ status: "loaded", profileUser, dumps })
status: "loaded",
profileUser,
items,
hasMore,
page,
loadingMore: false,
})
) )
.catch((err) => { .catch((err) => {
if (signal.aborted) return; if (signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) }); setState({ status: "error", error: friendlyFetchError(err) });
}); });
return () => controller.abort(); return () => controller.abort();
}, [username, endpoint, cached, token]); }, [username, cached, authFetch, url]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
.then(({ items, hasMore }) => {
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
? {
...s,
items: [...s.items, ...newItems],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token, endpoint]);
const list = state.status === "loaded" ? state.dumps : null;
const sentinelRef = useInfiniteScroll( const sentinelRef = useInfiniteScroll(
loadMore, useLoadMore(list, setDumps, fetchPage),
state.status === "loaded" && state.hasMore && !state.loadingMore, canLoadMore(list),
); );
useScrollSave( useScrollSave(
state.status === "loaded", state.status === "loaded",
useCallback((y) => { useCallback((y) => {
if (state.status !== "loaded") return; if (state.status === "loaded") saveState(state.dumps, y);
saveState(state.items, state.page, state.hasMore, y);
}, [state, saveState]), }, [state, saveState]),
); );

View File

@@ -13,10 +13,10 @@ import {
const STORAGE_KEY = "default-feed-tab"; const STORAGE_KEY = "default-feed-tab";
/** The components reading the tab, told when this tab changes it. */ // The components reading the tab, told when this tab changes it.
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
/** The choice made while storage is blocked, which lasts until a reload. */ // Holds the choice made while storage is blocked, until a reload.
let unstored: FeedTab | null = null; let unstored: FeedTab | null = null;
/** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */ /** Returns {@link DEFAULT_FEED_TAB} when the stored value isn't a tab. */

View File

@@ -14,9 +14,9 @@ import {
} from "react"; } from "react";
/** /**
* The phase of a fading item, which {@link fadeClassName} maps to a CSS class. * Held by {@link useFading} for each fading item, and mapped to a CSS class by
* `cooldown` lasts 2 seconds, and `dismissing` lasts the 350ms of the CSS * {@link fadeClassName}. `cooldown` lasts 2 seconds, and `dismissing` lasts
* collapse. * the 350ms of the CSS collapse.
*/ */
export type FadeState = "cooldown" | "dismissing"; export type FadeState = "cooldown" | "dismissing";
@@ -24,16 +24,16 @@ const COOLDOWN_MS = 2000;
const DISMISS_MS = 350; const DISMISS_MS = 350;
/** /**
* Returned by {@link useFading} to `useUpvotedList` and `PlaylistDetail`. The * Returned by {@link useFading} to `useUpvotedList` and `usePlaylist`. The
* list keeps showing an item while it is in `fading`. * list keeps showing an item while it is in `fading`.
*/ */
export interface UseFadingResult { export interface UseFadingResult {
fading: Record<string, FadeState>; fading: Record<string, FadeState>;
/** Does nothing when `id` is already fading. */ /** Does nothing when `id` is already fading. */
startFading: (id: string) => void; startFading: (id: string) => void;
/** Called when the removal is undone: the item stays. */ /** Called when the removal is undone, so that the item stays. */
cancelFading: (id: string) => void; cancelFading: (id: string) => void;
/** Called when the list is replaced, as by another user's or a reload. */ /** Called when the list is replaced, as by another user's upvotes. */
cancelAll: () => void; cancelAll: () => void;
} }

View File

@@ -8,6 +8,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useNavigationType } from "react-router"; import { useNavigationType } from "react-router";
import { useAuth } from "../auth/useAuth.ts"; import { useAuth } from "../auth/useAuth.ts";
import type { PagedList } from "../../utils/feed/pagedList.ts";
/** Past this age, a saved feed is refetched rather than restored. */ /** Past this age, a saved feed is refetched rather than restored. */
const TTL = 10 * 60 * 1000; // 10 minutes const TTL = 10 * 60 * 1000; // 10 minutes
@@ -17,9 +18,9 @@ const TTL = 10 * 60 * 1000; // 10 minutes
* hook when the page is next reached by going back. * hook when the page is next reached by going back.
*/ */
export interface FeedCacheEntry<T> { export interface FeedCacheEntry<T> {
/** Every page loaded so far. JSON turns their dates into strings. */ /** It holds every page loaded so far, whose dates JSON turns into strings. */
items: T[]; items: T[];
/** The last page loaded, from which loading more carries on. */ /** Loading more carries on from this page, the last loaded. */
page: number; page: number;
hasMore: boolean; hasMore: boolean;
scrollY: number; scrollY: number;
@@ -27,20 +28,22 @@ export interface FeedCacheEntry<T> {
savedAt: number; savedAt: number;
} }
/**
* Passed to {@link useFeedCache}'s `saveState` by the feed pages, from the
* list they hold, without its loading flag.
*/
export type SavedList<T> = Pick<PagedList<T>, "items" | "page" | "hasMore">;
/** Returned by {@link useFeedCache} to a feed page. */ /** Returned by {@link useFeedCache} to a feed page. */
export interface UseFeedCacheResult<T> { export interface UseFeedCacheResult<T> {
/** /**
* The saved feed, when the page was reached by going back or forward and the * It holds the saved feed when the page was reached by going back or
* feed was saved less than 10 minutes ago. It changes only with the key. * forward and the feed was saved less than 10 minutes ago. It changes only
* with the key.
*/ */
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null; cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
/** Usually called through `useScrollSave`, as the page is left. */ /** Usually called through `useScrollSave`, as the page is left. */
saveState: ( saveState: (list: SavedList<T>, scrollY: number) => void;
items: T[],
page: number,
hasMore: boolean,
scrollY: number,
) => void;
} }
/** Returns the entry saved under `key`, or null if none is fresh enough. */ /** Returns the entry saved under `key`, or null if none is fresh enough. */
@@ -89,20 +92,22 @@ export function useFeedCache<T>(
} }
const saveState = useCallback( const saveState = useCallback(
(items: T[], page: number, hasMore: boolean, scrollY: number) => { ({ items, page, hasMore }: SavedList<T>, scrollY: number) => {
try { try {
sessionStorage.setItem( sessionStorage.setItem(
fullKey, fullKey,
JSON.stringify({ JSON.stringify(
items, {
page, items,
hasMore, page,
scrollY, hasMore,
savedAt: Date.now(), scrollY,
}), savedAt: Date.now(),
} satisfies FeedCacheEntry<T>,
),
); );
} catch { } catch {
// Full or blocked storage: the page just refetches next time. // The page just refetches next time, when storage is full or blocked.
} }
}, },
[fullKey], [fullKey],

View File

@@ -12,8 +12,7 @@ const MAX_RETRY_DELAY_MS = 30_000;
/** /**
* Returns a callback ref for the sentinel element placed after a feed's last * Returns a callback ref for the sentinel element placed after a feed's last
* item, and calls `onLoadMore` when the sentinel comes within 200px of the * item, and calls `onLoadMore` when the sentinel comes within 200px of the
* view while `enabled`. Feed pages pass `enabled` as "loaded, more to come and * view while `enabled`. Feed pages pass `canLoadMore` of their list.
* not already loading".
* *
* The ref is a callback so that the observer follows a new sentinel, as when * The ref is a callback so that the observer follows a new sentinel, as when
* `Index` switches tabs and another feed mounts its own. * `Index` switches tabs and another feed mounts its own.

View File

@@ -0,0 +1,107 @@
/**
* Loads a list a page at a time. Every list the API pages goes through
* {@link useLoadMore} for its next pages, and a list that loads on its own
* through {@link usePagedList}, from its first page on.
*
* @module
*/
import { useCallback, useLayoutEffect, useRef } from "react";
import type { PaginatedData } from "../../../shared/api.ts";
import {
canLoadMore,
firstPage,
mapItems,
type PagedList,
restoredList,
} from "../../utils/feed/pagedList.ts";
import type { FeedCacheEntry } from "./useFeedCache.ts";
import { type FetchState, useFetchState } from "../shared/useFetchState.ts";
/**
* Passed to {@link useLoadMore} and {@link usePagedList} by a list's page or
* hook. It resolves to page `page` of the list, its items deserialized, as
* `fetch` with `pageUrl` and `readPage` gives it, and rejects when the page
* can't be had.
*/
export type FetchPage<T> = (
page: number,
signal?: AbortSignal,
) => Promise<PaginatedData<T>>;
/**
* Returns the call that loads the page after `list`'s last one and appends it
* through `setList`. It does nothing unless `canLoadMore(list)`; a page that
* fails leaves the list as it was, for the next call to try again. The pages
* that load their lists with their user (`UserPlaylists`, `useUserDumpFeed`,
* the profile's followed users) use it directly, and `usePagedList` for the
* others.
*/
export function useLoadMore<T>(
list: PagedList<T> | null,
setList: (fn: (list: PagedList<T>) => PagedList<T>) => void,
fetchPage: FetchPage<T>,
): () => void {
// A ref, so that a new `fetchPage` on every render keeps the call stable.
const fetchPageRef = useRef(fetchPage);
useLayoutEffect(() => {
fetchPageRef.current = fetchPage;
});
return useCallback(() => {
if (!list || !canLoadMore(list)) return;
const page = list.page + 1;
setList((l) => ({ ...l, loadingMore: true }));
fetchPageRef.current(page)
.then(({ items, hasMore }) =>
setList((l) => ({
items: [...l.items, ...items],
hasMore,
page,
loadingMore: false,
}))
)
.catch(() => setList((l) => ({ ...l, loadingMore: false })));
}, [list, setList]);
}
/** Returned by {@link usePagedList}. */
export interface UsePagedListResult<T> {
state: FetchState<PagedList<T>>;
/** Does nothing until loaded. Passed to the sync hooks. */
setItems: (fn: (items: T[]) => T[]) => void;
/** It goes to `useInfiniteScroll` or to a `LoadMoreButton`. */
loadMore: () => void;
}
/**
* Returns the list `fetchPage` serves for `key`, from its first page, and the
* call that loads the next. `key` changes as `useFetchState`'s does, and
* `cached`, `useFeedCache`'s restored list for it, if any, is shown without
* loading. The index's feeds, the notifications and the popover of voters and
* likers use it.
*/
export function usePagedList<T>(
key: string | undefined,
fetchPage: FetchPage<T>,
cached?: Omit<FeedCacheEntry<T>, "savedAt"> | null,
): UsePagedListResult<T> {
const { state, setData } = useFetchState(
key,
(signal) =>
fetchPage(1, signal).then(({ items, hasMore }) =>
firstPage(items, hasMore)
),
{ initial: cached ? restoredList(cached) : undefined },
);
const setItems = useCallback(
(fn: (items: T[]) => T[]) => setData(mapItems(fn)),
[setData],
);
const loadMore = useLoadMore(
state.status === "loaded" ? state.data : null,
setData,
fetchPage,
);
return { state, setItems, loadMore };
}

View File

@@ -1,6 +1,6 @@
/** /**
* Puts an item that left a profile's list, by turning private, back at its * Puts an item that left a list, as by turning private, back at its old
* old position when it turns public again. * position when it turns public again.
* *
* @module * @module
*/ */
@@ -10,8 +10,8 @@ import { useEffect, useLayoutEffect, useRef } from "react";
/** /**
* Remembers the index of every item that leaves `items`, whatever took it * Remembers the index of every item that leaves `items`, whatever took it
* out, and puts it back there when `lastEvent` shows it again and * out, and puts it back there when `lastEvent` shows it again and
* `shouldReinsert` accepts it. The profile pages use it with `WSContext`'s * `shouldReinsert` accepts it. The profile pages and `usePlaylist` use it with
* latest dump or playlist event, beside `useDumpListSync` or * `WSContext`'s latest dump or playlist event, beside `useDumpListSync` or
* `usePlaylistListSync`, which never re-add an item themselves. `items` is the * `usePlaylistListSync`, which never re-add an item themselves. `items` is the
* loaded list, or `[]` before it loads, and `setItems` its setter. * loaded list, or `[]` before it loads, and `setItems` its setter.
*/ */

View File

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

View File

@@ -22,8 +22,9 @@ import {
import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts"; import { fetchPublicUser } from "../../utils/shared/fetchPublicUser.ts";
import type { LoadState } from "../../utils/shared/loadState.ts"; import type { LoadState } from "../../utils/shared/loadState.ts";
import { import {
fetchFirstPage,
type PagedList, type PagedList,
pageUrl,
readFirstPage,
restoredList, restoredList,
} from "../../utils/feed/pagedList.ts"; } from "../../utils/feed/pagedList.ts";
import { useAuth } from "../auth/useAuth.ts"; import { useAuth } from "../auth/useAuth.ts";
@@ -36,8 +37,8 @@ import { useUpvotedList } from "../dump/useUpvotedList.ts";
import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts"; import { usePlaylistListSync } from "../playlist/usePlaylistListSync.ts";
/** /**
* The profile's lists, each holding its first page only: each has its own page * Loaded by {@link useProfile} for `UserPublicProfile`. Each list holds its
* for the rest. * first page only, and has its own page for the rest.
*/ */
export interface ProfileData { export interface ProfileData {
user: PublicUser; user: PublicUser;
@@ -49,13 +50,19 @@ export interface ProfileData {
/** Returned by {@link useProfile} to `UserPublicProfile`. */ /** Returned by {@link useProfile} to `UserPublicProfile`. */
export interface UseProfileResult { export interface UseProfileResult {
state: LoadState<ProfileData>; state: LoadState<ProfileData>;
/** Whether the signed-in user is the profile's. False while it loads. */ /** It is false while the profile loads. */
isOwnProfile: boolean; isOwnProfile: boolean;
/** For the header's edits, which the server echoes to other browsers. */ /** It shows the header's edits, which the server echoes to other browsers. */
setUser: (fn: (user: PublicUser) => PublicUser) => void; setUser: (fn: (user: PublicUser) => PublicUser) => void;
/** For `NewPlaylistButton`, whose playlist the socket echoes later. */ /**
* It shows the playlist `NewPlaylistButton` made, which the socket echoes
* later.
*/
setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void; setPlaylists: (fn: (items: Playlist[]) => Playlist[]) => void;
/** From `useUpvotedList`, for the upvoted list's `DumpCardList`. */ /**
* It comes from `useUpvotedList`, and goes to the upvoted list's
* `DumpCardList`.
*/
upvotedCardClassName: (dump: Dump) => string | undefined; upvotedCardClassName: (dump: Dump) => string | undefined;
} }
@@ -81,7 +88,7 @@ function updateList<K extends ListKey>(
* refetching only the user. * refetching only the user.
*/ */
export function useProfile(username: string | undefined): UseProfileResult { export function useProfile(username: string | undefined): UseProfileResult {
const { user: me, token } = useAuth(); const { user: me, authFetch } = useAuth();
const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS(); const { lastDumpEvent, lastPlaylistEvent, lastUserEvent } = useWS();
const dumpsCache = useFeedCache<Dump>( const dumpsCache = useFeedCache<Dump>(
@@ -174,14 +181,10 @@ export function useProfile(username: string | undefined): UseProfileResult {
if (!username) return; if (!username) return;
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
const headers: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
const firstPageOf = <T>(list: string, deserialize: (w: Wire<T>) => T) => const firstPageOf = <T>(list: string, deserialize: (w: Wire<T>) => T) =>
fetchFirstPage(`${API_URL}/api/users/${username}/${list}`, deserialize, { authFetch(pageUrl(`${API_URL}/api/users/${username}/${list}`, 1), {
headers,
signal, signal,
}); }).then((res) => readFirstPage(res, deserialize));
// Back on the page, the lists come from the cache and only the user is // Back on the page, the lists come from the cache and only the user is
// fetched again. // fetched again.
@@ -206,7 +209,7 @@ export function useProfile(username: string | undefined): UseProfileResult {
setState({ status: "error", error: friendlyFetchError(err) }); setState({ status: "error", error: friendlyFetchError(err) });
}); });
return () => controller.abort(); return () => controller.abort();
}, [username, cachedDumps, cachedVotes, cachedPlaylists, token]); }, [username, cachedDumps, cachedVotes, cachedPlaylists, authFetch]);
const saveDumps = dumpsCache.saveState; const saveDumps = dumpsCache.saveState;
const saveVotes = votesCache.saveState; const saveVotes = votesCache.saveState;
@@ -216,9 +219,9 @@ export function useProfile(username: string | undefined): UseProfileResult {
useCallback((y) => { useCallback((y) => {
if (state.status !== "loaded") return; if (state.status !== "loaded") return;
const { dumps, votes, playlists } = state; const { dumps, votes, playlists } = state;
saveDumps(dumps.items, dumps.page, dumps.hasMore, y); saveDumps(dumps, y);
saveVotes(votes.items, votes.page, votes.hasMore, y); saveVotes(votes, y);
savePlaylists(playlists.items, playlists.page, playlists.hasMore, y); savePlaylists(playlists, y);
}, [state, saveDumps, saveVotes, savePlaylists]), }, [state, saveDumps, saveVotes, savePlaylists]),
); );
useScrollRestore(cachedDumps?.scrollY, state.status === "loaded"); useScrollRestore(cachedDumps?.scrollY, state.status === "loaded");

View File

@@ -17,31 +17,58 @@ import { friendlyFetchError } from "../../utils/shared/api.ts";
/** Held by {@link useFetchState}, and passed on to `ListState`. */ /** Held by {@link useFetchState}, and passed on to `ListState`. */
export type FetchState<T> = LoadState<{ data: T }>; export type FetchState<T> = LoadState<{ data: T }>;
/** Passed to {@link useFetchState}. */
export interface FetchStateOptions<T> {
/**
* False until it should load, as the profile's tabs pass "this tab is open".
* Turning it off doesn't abort a load under way, so those tabs finish
* loading in the background. True by default.
*/
enabled?: boolean;
/**
* It holds the data already at hand for a new key, as a feed restored from
* `useFeedCache`, which starts loaded and isn't loaded again. It is read
* only when the key changes.
*/
initial?: T;
}
/** Returned by {@link useFetchState}. */ /** Returned by {@link useFetchState}. */
export interface UseFetchStateResult<T> { export interface UseFetchStateResult<T> {
state: FetchState<T>; state: FetchState<T>;
/** Edits the loaded data, as when a next page comes in. Does nothing until loaded. */ /**
* Edits the loaded data, as when a next page comes in. It does nothing until
* loaded.
*/
setData: (fn: (data: T) => T) => void; setData: (fn: (data: T) => T) => void;
} }
/** /**
* Returns what `load` resolves to for `key`, loaded the first time `enabled` * Returns what `load` resolves to for `key`, loaded the first time it is
* is true, and kept afterwards. Turning `enabled` off doesn't abort the load, * enabled, and kept afterwards. A new `key` starts over and aborts the load of
* so the profile's tabs, which pass "this tab is open", finish loading in the * the last one, as leaving the page does; an undefined key loads nothing. The
* background. A new `key` starts over from loading and aborts the load of the * key must change with whatever the load depends on, the viewer included when
* last one, as leaving the page does; an undefined key loads nothing. `load` * it is signed in. `load` must reject with an `Error`, as `expectOk` does,
* must reject with an `Error`, as `expectOk` does, whose message it shows. * whose message it shows.
*/ */
export function useFetchState<T>( export function useFetchState<T>(
key: string | undefined, key: string | undefined,
load: (signal: AbortSignal) => Promise<T>, load: (signal: AbortSignal) => Promise<T>,
enabled = true, { enabled = true, initial }: FetchStateOptions<T> = {},
): UseFetchStateResult<T> { ): UseFetchStateResult<T> {
const [state, setState] = useState<FetchState<T>>({ status: "loading" }); const startState = (): FetchState<T> =>
const [prevKey, setPrevKey] = useState(key); initial === undefined
if (prevKey !== key) { ? { status: "loading" }
setPrevKey(key); : { status: "loaded", data: initial };
setState({ status: "loading" }); const [state, setState] = useState<FetchState<T>>(startState);
// `restored`: the key's data came from `initial`, and isn't loaded.
const [current, setCurrent] = useState({
key,
restored: initial !== undefined,
});
if (current.key !== key) {
setCurrent({ key, restored: initial !== undefined });
setState(startState());
} }
// A ref, so that a new `load` on every render doesn't load again. // A ref, so that a new `load` on every render doesn't load again.
@@ -59,7 +86,10 @@ export function useFetchState<T>(
}, [key]); }, [key]);
useEffect(() => { useEffect(() => {
if (!enabled || key === undefined || startedRef.current === key) return; if (
!enabled || key === undefined || current.restored ||
startedRef.current === key
) return;
startedRef.current = key; startedRef.current = key;
const controller = new AbortController(); const controller = new AbortController();
controllerRef.current = controller; controllerRef.current = controller;
@@ -69,7 +99,7 @@ export function useFetchState<T>(
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
setState({ status: "error", error: friendlyFetchError(err) }); setState({ status: "error", error: friendlyFetchError(err) });
}); });
}, [enabled, key]); }, [enabled, key, current.restored]);
const setData = useCallback((fn: (data: T) => T) => { const setData = useCallback((fn: (data: T) => T) => {
setState((s) => setState((s) =>

File diff suppressed because one or more lines are too long

View File

@@ -55,7 +55,7 @@ msgstr "{0} more online: {restNames}"
msgid "{0}–{1} characters: letters, numbers, or underscores" msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} characters: letters, numbers, or underscores" msgstr "{0}–{1} characters: letters, numbers, or underscores"
#: src/pages/Search.tsx:200 #: src/pages/Search.tsx:120
msgid "{label} ({count})" msgid "{label} ({count})"
msgstr "{label} ({count})" msgstr "{label} ({count})"
@@ -68,8 +68,8 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# comment} other {# comments}}" msgstr "{visibleCount, plural, one {# comment} other {# comments}}"
#: src/components/dump/create/DumpCreateModal.tsx:741 #: src/components/dump/create/DumpCreateModal.tsx:741
#: src/pages/PlaylistDetail.tsx:580 #: src/pages/PlaylistDetail.tsx:80
#: src/pages/UserPublicProfile.tsx:167 #: src/pages/UserPublicProfile.tsx:159
msgid "← Back" msgid "← Back"
msgstr "← Back" msgstr "← Back"
@@ -80,7 +80,7 @@ msgid "← Back to all dumps"
msgstr "← Back to all dumps" msgstr "← Back to all dumps"
#: src/pages/UserDumps.tsx:74 #: src/pages/UserDumps.tsx:74
#: src/pages/UserPlaylists.tsx:306 #: src/pages/UserPlaylists.tsx:240
#: src/pages/UserUpvoted.tsx:60 #: src/pages/UserUpvoted.tsx:60
msgid "← Back to profile" msgid "← Back to profile"
msgstr "← Back to profile" msgstr "← Back to profile"
@@ -99,47 +99,47 @@ msgstr "+ Playlist"
#. placeholder {0}: d.commenterUsername #. placeholder {0}: d.commenterUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:212 #: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> commented on <1>{1}</1>" msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> commented on <1>{1}</1>" msgstr "<0>{0}</0> commented on <1>{1}</1>"
#. placeholder {0}: d.followerUsername #. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle #. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:172 #: src/pages/Notifications.tsx:159
msgid "<0>{0}</0> followed your playlist <1>{1}</1>" msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> followed your playlist <1>{1}</1>" msgstr "<0>{0}</0> followed your playlist <1>{1}</1>"
#. placeholder {0}: d.likerUsername #. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:222 #: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> liked your comment on <1>{1}</1>" msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
msgstr "<0>{0}</0> liked your comment on <1>{1}</1>" msgstr "<0>{0}</0> liked your comment on <1>{1}</1>"
#. placeholder {0}: d.mentionerUsername #. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:238 #: src/pages/Notifications.tsx:225
msgid "<0>{0}</0> mentioned you in <1>{where}</1>" msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> mentioned you in <1>{where}</1>" msgstr "<0>{0}</0> mentioned you in <1>{where}</1>"
#. placeholder {0}: d.dumperUsername #. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:182 #: src/pages/Notifications.tsx:169
msgid "<0>{0}</0> posted <1>{1}</1>" msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> posted <1>{1}</1>" msgstr "<0>{0}</0> posted <1>{1}</1>"
#. placeholder {0}: d.followerUsername #. placeholder {0}: d.followerUsername
#: src/pages/Notifications.tsx:163 #: src/pages/Notifications.tsx:150
msgid "<0>{0}</0> started following you" msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> started following you" msgstr "<0>{0}</0> started following you"
#. placeholder {0}: d.voterUsername #. placeholder {0}: d.voterUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:202 #: src/pages/Notifications.tsx:189
msgid "<0>{0}</0> upvoted <1>{1}</1>" msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> upvoted <1>{1}</1>" msgstr "<0>{0}</0> upvoted <1>{1}</1>"
#. placeholder {0}: d.dumpTitle #. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle #. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:192 #: src/pages/Notifications.tsx:179
msgid "<0>{0}</0> was added to <1>{1}</1>" msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> was added to <1>{1}</1>" msgstr "<0>{0}</0> was added to <1>{1}</1>"
@@ -147,11 +147,11 @@ msgstr "<0>{0}</0> was added to <1>{1}</1>"
msgid "1 new message" msgid "1 new message"
msgstr "1 new message" msgstr "1 new message"
#: src/pages/Notifications.tsx:233 #: src/pages/Notifications.tsx:220
msgid "a comment" msgid "a comment"
msgstr "a comment" msgstr "a comment"
#: src/pages/Notifications.tsx:236 #: src/pages/Notifications.tsx:223
msgid "a post" msgid "a post"
msgstr "a post" msgstr "a post"
@@ -228,8 +228,8 @@ msgstr "Auto"
msgid "Back to login" msgid "Back to login"
msgstr "Back to login" msgstr "Back to login"
#: src/contexts/ws/WSProvider.tsx:392 #: src/contexts/ws/WSProvider.tsx:394
#: src/contexts/ws/WSProvider.tsx:758 #: src/contexts/ws/WSProvider.tsx:760
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgstr "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
@@ -239,16 +239,16 @@ msgstr "Can't connect to the live updates server. Upvotes and notifications may
#: src/components/comment/CommentForm.tsx:127 #: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:751 #: src/components/dump/create/DumpCreateModal.tsx:751
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/profile/DescriptionEditor.tsx:96 #: src/components/playlist/PlaylistEditForm.tsx:205
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83 #: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Dump.tsx:432 #: src/pages/Dump.tsx:432
#: src/pages/DumpEdit.tsx:542 #: src/pages/DumpEdit.tsx:542
#: src/pages/PlaylistDetail.tsx:948
msgid "Cancel" msgid "Cancel"
msgstr "Cancel" msgstr "Cancel"
#: src/pages/PlaylistDetail.tsx:753 #: src/components/playlist/PlaylistDumpList.tsx:118
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Cancel removal" msgstr "Cancel removal"
@@ -341,7 +341,7 @@ msgstr "Color scheme"
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirm new password" msgstr "Confirm new password"
#: src/contexts/ws/WSProvider.tsx:751 #: src/contexts/ws/WSProvider.tsx:753
msgid "Connection lost — this message may not have been sent" msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent" msgstr "Connection lost — this message may not have been sent"
@@ -362,14 +362,10 @@ msgid "Could not connect to the server. Check your connection and try again."
msgstr "Could not connect to the server. Check your connection and try again." msgstr "Could not connect to the server. Check your connection and try again."
#: src/pages/DumpEdit.tsx:530 #: src/pages/DumpEdit.tsx:530
#: src/pages/UserPlaylists.tsx:330 #: src/pages/UserPlaylists.tsx:264
msgid "Could not delete" msgid "Could not delete"
msgstr "Could not delete" msgstr "Could not delete"
#: src/components/social/UserListPopover.tsx:167
msgid "Could not load."
msgstr "Could not load."
#: src/pages/DumpEdit.tsx:490 #: src/pages/DumpEdit.tsx:490
msgid "Could not save" msgid "Could not save"
msgstr "Could not save" msgstr "Could not save"
@@ -396,7 +392,7 @@ msgstr "Create & Add"
#. placeholder {0}: created.items.length #. placeholder {0}: created.items.length
#. placeholder {1}: created.hasMore ? "+" : "" #. placeholder {1}: created.hasMore ? "+" : ""
#: src/pages/UserPlaylists.tsx:335 #: src/pages/UserPlaylists.tsx:269
msgid "Created ({0}{1})" msgid "Created ({0}{1})"
msgstr "Created ({0}{1})" msgstr "Created ({0}{1})"
@@ -423,8 +419,8 @@ msgstr "Default tab"
#: src/components/chat/ChatMessageItem.tsx:213 #: src/components/chat/ChatMessageItem.tsx:213
#: src/components/comment/CommentNode.tsx:263 #: src/components/comment/CommentNode.tsx:263
#: src/components/comment/CommentNode.tsx:269 #: src/components/comment/CommentNode.tsx:269
#: src/components/playlist/PlaylistEditForm.tsx:201
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
#: src/pages/PlaylistDetail.tsx:944
msgid "Delete" msgid "Delete"
msgstr "Delete" msgstr "Delete"
@@ -448,8 +444,8 @@ msgid "Delete failed ({0})"
msgstr "Delete failed ({0})" msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:132 #: src/components/playlist/PlaylistCard.tsx:132
#: src/pages/PlaylistDetail.tsx:766 #: src/pages/PlaylistDetail.tsx:136
#: src/pages/UserPlaylists.tsx:393 #: src/pages/UserPlaylists.tsx:327
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Delete playlist" msgstr "Delete playlist"
@@ -465,8 +461,8 @@ msgstr "Delete this dump? This cannot be undone."
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Delete this message?" msgstr "Delete this message?"
#: src/pages/PlaylistDetail.tsx:765 #: src/pages/PlaylistDetail.tsx:135
#: src/pages/UserPlaylists.tsx:392 #: src/pages/UserPlaylists.tsx:326
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone." msgstr "Delete this playlist? This cannot be undone."
@@ -475,7 +471,7 @@ msgid "deleted message"
msgstr "deleted message" msgstr "deleted message"
#: src/components/playlist/PlaylistCreateForm.tsx:104 #: src/components/playlist/PlaylistCreateForm.tsx:104
#: src/pages/PlaylistDetail.tsx:916 #: src/components/playlist/PlaylistEditForm.tsx:173
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (optional)" msgstr "Description (optional)"
@@ -525,19 +521,19 @@ msgstr "Dump it"
msgid "Dumped!" msgid "Dumped!"
msgstr "Dumped!" msgstr "Dumped!"
#: src/pages/Search.tsx:196 #: src/pages/Search.tsx:116
#: src/pages/UserDumps.tsx:88 #: src/pages/UserDumps.tsx:88
#: src/pages/UserPublicProfile.tsx:197 #: src/pages/UserPublicProfile.tsx:189
msgid "Dumps" msgid "Dumps"
msgstr "Dumps" msgstr "Dumps"
#. placeholder {0}: dumps.items.length #. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : "" #. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/components/profile/ProfileDumpsTab.tsx:36 #: src/components/profile/ProfileDumpsTab.tsx:37
msgid "Dumps ({0}{1})" msgid "Dumps ({0}{1})"
msgstr "Dumps ({0}{1})" msgstr "Dumps ({0}{1})"
#: src/pages/Notifications.tsx:473 #: src/pages/Notifications.tsx:375
msgid "Earlier" msgid "Earlier"
msgstr "Earlier" msgstr "Earlier"
@@ -545,8 +541,8 @@ msgstr "Earlier"
#: src/components/chat/ChatMessageItem.tsx:104 #: src/components/chat/ChatMessageItem.tsx:104
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:254 #: src/components/comment/CommentNode.tsx:254
#: src/components/playlist/PlaylistHeader.tsx:58
#: src/pages/Dump.tsx:550 #: src/pages/Dump.tsx:550
#: src/pages/PlaylistDetail.tsx:634
msgid "Edit" msgid "Edit"
msgstr "Edit" msgstr "Edit"
@@ -577,8 +573,8 @@ msgstr "edited"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:491 #: src/pages/Dump.tsx:491
#: src/pages/PlaylistDetail.tsx:673
msgid "edited {0}" msgid "edited {0}"
msgstr "edited {0}" msgstr "edited {0}"
@@ -587,8 +583,8 @@ msgstr "edited {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:124 #: src/components/chat/ChatMessageItem.tsx:124
#: src/components/comment/CommentNode.tsx:201 #: src/components/comment/CommentNode.tsx:201
#: src/components/playlist/PlaylistHeader.tsx:91
#: src/pages/Dump.tsx:489 #: src/pages/Dump.tsx:489
#: src/pages/PlaylistDetail.tsx:670
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Edited {0}" msgstr "Edited {0}"
@@ -605,7 +601,7 @@ msgstr "Editing {0}"
msgid "Email address" msgid "Email address"
msgstr "Email address" msgstr "Email address"
#: src/pages/Search.tsx:229 #: src/pages/Search.tsx:149
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Enter a query to search." msgstr "Enter a query to search."
@@ -632,7 +628,7 @@ msgid "Failed to generate invite"
msgstr "Failed to generate invite" msgstr "Failed to generate invite"
#: src/components/shared/ListState.tsx:43 #: src/components/shared/ListState.tsx:43
#: src/pages/Notifications.tsx:447 #: src/pages/Notifications.tsx:349
msgid "Failed to load" msgid "Failed to load"
msgstr "Failed to load" msgstr "Failed to load"
@@ -648,9 +644,9 @@ msgstr "Failed to post comment"
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Failed to post reply" msgstr "Failed to post reply"
#: src/components/profile/DescriptionEditor.tsx:98 #: src/components/playlist/PlaylistEditForm.tsx:193
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:86
#: src/pages/PlaylistDetail.tsx:936
msgid "Failed to save" msgid "Failed to save"
msgstr "Failed to save" msgstr "Failed to save"
@@ -695,23 +691,23 @@ msgstr "Follow {targetUsername}"
msgid "Follow playlist" msgid "Follow playlist"
msgstr "Follow playlist" msgstr "Follow playlist"
#: src/components/feed/FollowedFeed.tsx:215 #: src/components/feed/FollowedFeed.tsx:151
msgid "Follow some public playlists to see their dumps here." msgid "Follow some public playlists to see their dumps here."
msgstr "Follow some public playlists to see their dumps here." msgstr "Follow some public playlists to see their dumps here."
#: src/components/feed/FollowedFeed.tsx:207 #: src/components/feed/FollowedFeed.tsx:143
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here." msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:48 #: src/components/feed/FeedTabBar.tsx:48
#: src/components/profile/ProfileSettingsTab.tsx:44 #: src/components/profile/ProfileSettingsTab.tsx:44
#: src/pages/UserPublicProfile.tsx:199 #: src/pages/UserPublicProfile.tsx:191
msgid "Followed" msgid "Followed"
msgstr "Followed" msgstr "Followed"
#. placeholder {0}: followed.items.length #. placeholder {0}: followed.items.length
#. placeholder {1}: followed.hasMore ? "+" : "" #. placeholder {1}: followed.hasMore ? "+" : ""
#: src/pages/UserPlaylists.tsx:366 #: src/pages/UserPlaylists.tsx:300
msgid "Followed ({0}{1})" msgid "Followed ({0}{1})"
msgstr "Followed ({0}{1})" msgstr "Followed ({0}{1})"
@@ -729,11 +725,11 @@ msgstr "Following"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Forgot password?" msgstr "Forgot password?"
#: src/components/feed/FollowedFeed.tsx:196 #: src/components/feed/FollowedFeed.tsx:132
msgid "From people" msgid "From people"
msgstr "From people" msgstr "From people"
#: src/components/feed/FollowedFeed.tsx:197 #: src/components/feed/FollowedFeed.tsx:133
msgid "From playlists" msgid "From playlists"
msgstr "From playlists" msgstr "From playlists"
@@ -789,7 +785,7 @@ msgid "invited by"
msgstr "invited by" msgstr "invited by"
#: src/components/profile/ProfileInviteesTab.tsx:76 #: src/components/profile/ProfileInviteesTab.tsx:76
#: src/pages/UserPublicProfile.tsx:200 #: src/pages/UserPublicProfile.tsx:192
msgid "Invitees" msgid "Invitees"
msgstr "Invitees" msgstr "Invitees"
@@ -818,7 +814,7 @@ msgstr "Link"
msgid "Link or file" msgid "Link or file"
msgstr "Link or file" msgstr "Link or file"
#: src/contexts/ws/WSProvider.tsx:757 #: src/contexts/ws/WSProvider.tsx:759
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…" msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
@@ -847,11 +843,11 @@ msgstr "Loading messages…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Loading more…" msgstr "Loading more…"
#: src/pages/PlaylistDetail.tsx:564 #: src/pages/PlaylistDetail.tsx:64
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Loading playlist…" msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:150 #: src/pages/UserPublicProfile.tsx:142
msgid "Loading profile…" msgid "Loading profile…"
msgstr "Loading profile…" msgstr "Loading profile…"
@@ -861,10 +857,10 @@ msgstr "Loading profile…"
#: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/playlist/PlaylistMembershipPanel.tsx:45
#: src/components/shared/ListState.tsx:38 #: src/components/shared/ListState.tsx:38
#: src/components/shared/LoadMoreButton.tsx:32 #: src/components/shared/LoadMoreButton.tsx:32
#: src/components/social/UserListPopover.tsx:224 #: src/components/social/UserListPopover.tsx:150
#: src/pages/Notifications.tsx:443 #: src/pages/Notifications.tsx:345
#: src/pages/UserDumps.tsx:62 #: src/pages/UserDumps.tsx:62
#: src/pages/UserPlaylists.tsx:294 #: src/pages/UserPlaylists.tsx:228
#: src/pages/UserUpvoted.tsx:48 #: src/pages/UserUpvoted.tsx:48
msgid "Loading…" msgid "Loading…"
msgstr "Loading…" msgstr "Loading…"
@@ -885,7 +881,7 @@ msgid "Log in to vote"
msgstr "Log in to vote" msgstr "Log in to vote"
#: src/components/profile/ProfileHeader.tsx:174 #: src/components/profile/ProfileHeader.tsx:174
#: src/pages/UserPublicProfile.tsx:171 #: src/pages/UserPublicProfile.tsx:163
msgid "Log out" msgid "Log out"
msgstr "Log out" msgstr "Log out"
@@ -897,7 +893,7 @@ msgstr "Logging in…"
msgid "Login failed" msgid "Login failed"
msgstr "Login failed" msgstr "Login failed"
#: src/pages/UserPublicProfile.tsx:205 #: src/pages/UserPublicProfile.tsx:197
msgid "Manage" msgid "Manage"
msgstr "Manage" msgstr "Manage"
@@ -918,7 +914,7 @@ msgstr "Mute"
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
#: src/pages/Notifications.tsx:436 #: src/pages/Notifications.tsx:338
msgid "new" msgid "new"
msgstr "new" msgstr "new"
@@ -953,15 +949,15 @@ msgstr "Next →"
msgid "Next track" msgid "Next track"
msgstr "Next track" msgstr "Next track"
#: src/pages/PlaylistDetail.tsx:693 #: src/components/playlist/PlaylistDumpList.tsx:46
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet." msgstr "No dumps in this playlist yet."
#: src/pages/Search.tsx:246 #: src/pages/Search.tsx:165
msgid "No dumps match \"{q}\"." msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"." msgstr "No dumps match \"{q}\"."
#: src/components/feed/DumpFeed.tsx:74 #: src/components/feed/DumpFeed.tsx:76
#: src/components/feed/JournalFeed.tsx:36 #: src/components/feed/JournalFeed.tsx:36
msgid "No dumps yet. Be the first!" msgid "No dumps yet. Be the first!"
msgstr "No dumps yet. Be the first!" msgstr "No dumps yet. Be the first!"
@@ -971,7 +967,7 @@ msgid "No emoji found."
msgstr "No emoji found." msgstr "No emoji found."
#: src/components/profile/ProfileFollowedTab.tsx:110 #: src/components/profile/ProfileFollowedTab.tsx:110
#: src/pages/UserPlaylists.tsx:375 #: src/pages/UserPlaylists.tsx:309
msgid "No followed playlists yet." msgid "No followed playlists yet."
msgstr "No followed playlists yet." msgstr "No followed playlists yet."
@@ -983,25 +979,25 @@ msgstr "No invitees yet."
msgid "No messages yet. Say hello!" msgid "No messages yet. Say hello!"
msgstr "No messages yet. Say hello!" msgstr "No messages yet. Say hello!"
#: src/components/social/UserListPopover.tsx:232 #: src/components/social/UserListPopover.tsx:158
msgid "No one yet." msgid "No one yet."
msgstr "No one yet." msgstr "No one yet."
#: src/pages/Search.tsx:288 #: src/pages/Search.tsx:206
msgid "No playlists match \"{q}\"." msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"." msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51 #: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:51 #: src/components/profile/ProfilePlaylistsTab.tsx:52
#: src/pages/UserPlaylists.tsx:344 #: src/pages/UserPlaylists.tsx:278
msgid "No playlists yet." msgid "No playlists yet."
msgstr "No playlists yet." msgstr "No playlists yet."
#: src/contexts/ws/WSProvider.tsx:926 #: src/contexts/ws/WSProvider.tsx:928
msgid "No reply from the server — this message may not have been sent" msgid "No reply from the server — this message may not have been sent"
msgstr "No reply from the server — this message may not have been sent" msgstr "No reply from the server — this message may not have been sent"
#: src/pages/Search.tsx:262 #: src/pages/Search.tsx:180
msgid "No users match \"{q}\"." msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"." msgstr "No users match \"{q}\"."
@@ -1009,9 +1005,9 @@ msgstr "No users match \"{q}\"."
msgid "Not following anyone yet." msgid "Not following anyone yet."
msgstr "Not following anyone yet." msgstr "Not following anyone yet."
#: src/components/profile/ProfileDumpsTab.tsx:45 #: src/components/profile/ProfileDumpsTab.tsx:46
#: src/components/profile/ProfileDumpsTab.tsx:59 #: src/components/profile/ProfileDumpsTab.tsx:60
#: src/pages/Notifications.tsx:454 #: src/pages/Notifications.tsx:356
#: src/pages/UserDumps.tsx:94 #: src/pages/UserDumps.tsx:94
#: src/pages/UserUpvoted.tsx:79 #: src/pages/UserUpvoted.tsx:79
msgid "Nothing here yet." msgid "Nothing here yet."
@@ -1019,8 +1015,8 @@ msgstr "Nothing here yet."
#: src/components/layout/NotificationBell.tsx:54 #: src/components/layout/NotificationBell.tsx:54
#: src/components/profile/ProfileSettingsTab.tsx:108 #: src/components/profile/ProfileSettingsTab.tsx:108
#: src/pages/Notifications.tsx:295 #: src/pages/Notifications.tsx:282
#: src/pages/Notifications.tsx:432 #: src/pages/Notifications.tsx:334
msgid "Notifications" msgid "Notifications"
msgstr "Notifications" msgstr "Notifications"
@@ -1109,22 +1105,22 @@ msgstr "Picked up where you left off."
msgid "Play" msgid "Play"
msgstr "Play" msgstr "Play"
#: src/pages/PlaylistDetail.tsx:900 #: src/components/playlist/PlaylistEditForm.tsx:157
msgid "Playlist title" msgid "Playlist title"
msgstr "Playlist title" msgstr "Playlist title"
#: src/components/dump/create/StepIndicator.tsx:21 #: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:182 #: src/components/layout/AppHeader.tsx:182
#: src/components/layout/UserMenu.tsx:78 #: src/components/layout/UserMenu.tsx:78
#: src/pages/Search.tsx:199 #: src/pages/Search.tsx:119
#: src/pages/UserPlaylists.tsx:320 #: src/pages/UserPlaylists.tsx:254
#: src/pages/UserPublicProfile.tsx:198 #: src/pages/UserPublicProfile.tsx:190
msgid "Playlists" msgid "Playlists"
msgstr "Playlists" msgstr "Playlists"
#. placeholder {0}: playlists.items.length #. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : "" #. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/components/profile/ProfilePlaylistsTab.tsx:34 #: src/components/profile/ProfilePlaylistsTab.tsx:35
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Playlists ({0}{1})" msgstr "Playlists ({0}{1})"
@@ -1149,9 +1145,9 @@ msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:148 #: src/components/dump/DumpCard.tsx:148
#: src/components/dump/JournalCard.tsx:142 #: src/components/dump/JournalCard.tsx:142
#: src/components/playlist/PlaylistCard.tsx:95 #: src/components/playlist/PlaylistCard.tsx:95
#: src/components/playlist/PlaylistHeader.tsx:75
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:497 #: src/pages/Dump.tsx:497
#: src/pages/PlaylistDetail.tsx:653
msgid "private" msgid "private"
msgstr "private" msgstr "private"
@@ -1160,7 +1156,7 @@ msgid "Private"
msgstr "Private" msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:94 #: src/components/playlist/PlaylistCard.tsx:94
#: src/pages/PlaylistDetail.tsx:652 #: src/components/playlist/PlaylistHeader.tsx:75
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1202,7 +1198,7 @@ msgstr "Related"
msgid "Remove file" msgid "Remove file"
msgstr "Remove file" msgstr "Remove file"
#: src/pages/PlaylistDetail.tsx:743 #: src/components/playlist/PlaylistDumpList.tsx:108
msgid "Remove from playlist" msgid "Remove from playlist"
msgstr "Remove from playlist" msgstr "Remove from playlist"
@@ -1263,27 +1259,27 @@ msgstr "Role"
#: src/components/category/CategoryManager.tsx:216 #: src/components/category/CategoryManager.tsx:216
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:214 #: src/components/comment/CommentNode.tsx:214
#: src/components/profile/DescriptionEditor.tsx:88 #: src/components/playlist/PlaylistEditForm.tsx:212
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75 #: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:424 #: src/pages/Dump.tsx:424
#: src/pages/DumpEdit.tsx:545 #: src/pages/DumpEdit.tsx:545
#: src/pages/PlaylistDetail.tsx:955
msgid "Save" msgid "Save"
msgstr "Save" msgstr "Save"
#: src/components/comment/CommentNode.tsx:215 #: src/components/comment/CommentNode.tsx:215
#: src/components/playlist/PlaylistEditForm.tsx:208
#: src/components/profile/ChangePasswordModal.tsx:120 #: src/components/profile/ChangePasswordModal.tsx:120
#: src/components/profile/DescriptionEditor.tsx:85 #: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72 #: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:423 #: src/pages/Dump.tsx:423
#: src/pages/PlaylistDetail.tsx:951
#: src/pages/ResetPassword.tsx:136 #: src/pages/ResetPassword.tsx:136
msgid "Saving…" msgid "Saving…"
msgstr "Saving…" msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:162 #: src/components/layout/AppHeader.tsx:162
#: src/components/layout/SearchBar.tsx:82 #: src/components/layout/SearchBar.tsx:82
#: src/pages/Search.tsx:63 #: src/pages/Search.tsx:65
msgid "Search" msgid "Search"
msgstr "Search" msgstr "Search"
@@ -1299,11 +1295,11 @@ msgstr "Search emoji"
msgid "Search emoji…" msgid "Search emoji…"
msgstr "Search emoji…" msgstr "Search emoji…"
#: src/pages/Search.tsx:240 #: src/pages/Search.tsx:160
msgid "Search failed" msgid "Search failed"
msgstr "Search failed" msgstr "Search failed"
#: src/pages/Search.tsx:235 #: src/pages/Search.tsx:155
msgid "Searching…" msgid "Searching…"
msgstr "Searching…" msgstr "Searching…"
@@ -1333,7 +1329,7 @@ msgstr "Server unreachable"
msgid "Set new password" msgid "Set new password"
msgstr "Set new password" msgstr "Set new password"
#: src/pages/UserPublicProfile.tsx:202 #: src/pages/UserPublicProfile.tsx:194
msgid "Settings" msgid "Settings"
msgstr "Settings" msgstr "Settings"
@@ -1355,8 +1351,8 @@ msgstr "Some images couldn't be uploaded."
#: src/components/form/FormError.tsx:31 #: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:726 #: src/contexts/ws/WSProvider.tsx:728
#: src/pages/PlaylistDetail.tsx:687 #: src/pages/PlaylistDetail.tsx:119
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Something went wrong" msgstr "Something went wrong"
@@ -1380,15 +1376,11 @@ msgstr "Submit search"
msgid "That file is over {limit}." msgid "That file is over {limit}."
msgstr "That file is over {limit}." msgstr "That file is over {limit}."
#: src/pages/Notifications.tsx:235 #: src/pages/Notifications.tsx:222
msgid "the chat" msgid "the chat"
msgstr "the chat" msgstr "the chat"
#: src/pages/PlaylistDetail.tsx:513 #: src/hooks/playlist/usePlaylist.ts:160
msgid "The dump couldn't be put back. Try again."
msgstr "The dump couldn't be put back. Try again."
#: src/pages/PlaylistDetail.tsx:461
msgid "The dump couldn't be removed. Try again." msgid "The dump couldn't be removed. Try again."
msgstr "The dump couldn't be removed. Try again." msgstr "The dump couldn't be removed. Try again."
@@ -1396,6 +1388,10 @@ msgstr "The dump couldn't be removed. Try again."
msgid "The image couldn't be uploaded." msgid "The image couldn't be uploaded."
msgstr "The image couldn't be uploaded." msgstr "The image couldn't be uploaded."
#: src/hooks/playlist/usePlaylist.ts:306
msgid "The new order couldn't be saved. Try again."
msgstr "The new order couldn't be saved. Try again."
#: src/components/comment/CommentNode.tsx:280 #: src/components/comment/CommentNode.tsx:280
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again." msgstr "This comment couldn't be deleted. Try again."
@@ -1430,7 +1426,7 @@ msgstr "Title"
msgid "Title is required." msgid "Title is required."
msgstr "Title is required." msgstr "Title is required."
#: src/pages/Notifications.tsx:470 #: src/pages/Notifications.tsx:372
#: src/utils/chat/time.ts:24 #: src/utils/chat/time.ts:24
msgid "Today" msgid "Today"
msgstr "Today" msgstr "Today"
@@ -1448,7 +1444,7 @@ msgstr "Try again"
msgid "Type a message…" msgid "Type a message…"
msgstr "Type a message…" msgstr "Type a message…"
#: src/pages/PlaylistDetail.tsx:755 #: src/components/playlist/PlaylistDumpList.tsx:120
msgid "Undo" msgid "Undo"
msgstr "Undo" msgstr "Undo"
@@ -1495,7 +1491,7 @@ msgstr "Upvoted"
#. placeholder {0}: votes.items.length #. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : "" #. placeholder {1}: votes.hasMore ? "+" : ""
#: src/components/profile/ProfileDumpsTab.tsx:51 #: src/components/profile/ProfileDumpsTab.tsx:52
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Upvoted ({0}{1})" msgstr "Upvoted ({0}{1})"
@@ -1528,7 +1524,7 @@ msgstr "User menu"
msgid "Username" msgid "Username"
msgstr "Username" msgstr "Username"
#: src/pages/Search.tsx:198 #: src/pages/Search.tsx:118
msgid "Users" msgid "Users"
msgstr "Users" msgstr "Users"
@@ -1549,7 +1545,7 @@ msgstr "Volume"
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "What makes it worth it?" msgstr "What makes it worth it?"
#: src/components/profile/DescriptionEditor.tsx:77 #: src/components/profile/DescriptionEditor.tsx:78
#: src/components/profile/ProfileDescription.tsx:79 #: src/components/profile/ProfileDescription.tsx:79
msgid "Who am I?" msgid "Who am I?"
msgstr "Who am I?" msgstr "Who am I?"
@@ -1571,7 +1567,7 @@ msgstr "Why?"
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Write a reply…" msgstr "Write a reply…"
#: src/pages/Notifications.tsx:472 #: src/pages/Notifications.tsx:374
#: src/utils/chat/time.ts:27 #: src/utils/chat/time.ts:27
msgid "Yesterday" msgid "Yesterday"
msgstr "Yesterday" msgstr "Yesterday"
@@ -1580,11 +1576,11 @@ msgstr "Yesterday"
msgid "You can post it anyway." msgid "You can post it anyway."
msgstr "You can post it anyway." msgstr "You can post it anyway."
#: src/pages/Notifications.tsx:457 #: src/pages/Notifications.tsx:359
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgstr "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
#: src/contexts/ws/WSProvider.tsx:907 #: src/contexts/ws/WSProvider.tsx:909
msgid "You're offline — not sent" msgid "You're offline — not sent"
msgstr "You're offline — not sent" msgstr "You're offline — not sent"

File diff suppressed because one or more lines are too long

View File

@@ -55,7 +55,7 @@ msgstr "{0} de plus en ligne : {restNames}"
msgid "{0}–{1} characters: letters, numbers, or underscores" msgid "{0}–{1} characters: letters, numbers, or underscores"
msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas" msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
#: src/pages/Search.tsx:200 #: src/pages/Search.tsx:120
msgid "{label} ({count})" msgid "{label} ({count})"
msgstr "{label} ({count})" msgstr "{label} ({count})"
@@ -68,8 +68,8 @@ msgid "{visibleCount, plural, one {# comment} other {# comments}}"
msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}" msgstr "{visibleCount, plural, one {# commentaire} other {# commentaires}}"
#: src/components/dump/create/DumpCreateModal.tsx:741 #: src/components/dump/create/DumpCreateModal.tsx:741
#: src/pages/PlaylistDetail.tsx:580 #: src/pages/PlaylistDetail.tsx:80
#: src/pages/UserPublicProfile.tsx:167 #: src/pages/UserPublicProfile.tsx:159
msgid "← Back" msgid "← Back"
msgstr "← Retour" msgstr "← Retour"
@@ -80,7 +80,7 @@ msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos" msgstr "← Retour à toutes les recos"
#: src/pages/UserDumps.tsx:74 #: src/pages/UserDumps.tsx:74
#: src/pages/UserPlaylists.tsx:306 #: src/pages/UserPlaylists.tsx:240
#: src/pages/UserUpvoted.tsx:60 #: src/pages/UserUpvoted.tsx:60
msgid "← Back to profile" msgid "← Back to profile"
msgstr "← Retour au profil" msgstr "← Retour au profil"
@@ -99,47 +99,47 @@ msgstr "+ Collection"
#. placeholder {0}: d.commenterUsername #. placeholder {0}: d.commenterUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:212 #: src/pages/Notifications.tsx:199
msgid "<0>{0}</0> commented on <1>{1}</1>" msgid "<0>{0}</0> commented on <1>{1}</1>"
msgstr "<0>{0}</0> a commenté sur <1>{1}</1>" msgstr "<0>{0}</0> a commenté sur <1>{1}</1>"
#. placeholder {0}: d.followerUsername #. placeholder {0}: d.followerUsername
#. placeholder {1}: d.playlistTitle #. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:172 #: src/pages/Notifications.tsx:159
msgid "<0>{0}</0> followed your playlist <1>{1}</1>" msgid "<0>{0}</0> followed your playlist <1>{1}</1>"
msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>" msgstr "<0>{0}</0> a suivi votre collection <1>{1}</1>"
#. placeholder {0}: d.likerUsername #. placeholder {0}: d.likerUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:222 #: src/pages/Notifications.tsx:209
msgid "<0>{0}</0> liked your comment on <1>{1}</1>" msgid "<0>{0}</0> liked your comment on <1>{1}</1>"
msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>" msgstr "<0>{0}</0> a aimé votre commentaire sur <1>{1}</1>"
#. placeholder {0}: d.mentionerUsername #. placeholder {0}: d.mentionerUsername
#: src/pages/Notifications.tsx:238 #: src/pages/Notifications.tsx:225
msgid "<0>{0}</0> mentioned you in <1>{where}</1>" msgid "<0>{0}</0> mentioned you in <1>{where}</1>"
msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>" msgstr "<0>{0}</0> vous a mentionné dans <1>{where}</1>"
#. placeholder {0}: d.dumperUsername #. placeholder {0}: d.dumperUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:182 #: src/pages/Notifications.tsx:169
msgid "<0>{0}</0> posted <1>{1}</1>" msgid "<0>{0}</0> posted <1>{1}</1>"
msgstr "<0>{0}</0> a publié <1>{1}</1>" msgstr "<0>{0}</0> a publié <1>{1}</1>"
#. placeholder {0}: d.followerUsername #. placeholder {0}: d.followerUsername
#: src/pages/Notifications.tsx:163 #: src/pages/Notifications.tsx:150
msgid "<0>{0}</0> started following you" msgid "<0>{0}</0> started following you"
msgstr "<0>{0}</0> a commencé à vous suivre" msgstr "<0>{0}</0> a commencé à vous suivre"
#. placeholder {0}: d.voterUsername #. placeholder {0}: d.voterUsername
#. placeholder {1}: d.dumpTitle #. placeholder {1}: d.dumpTitle
#: src/pages/Notifications.tsx:202 #: src/pages/Notifications.tsx:189
msgid "<0>{0}</0> upvoted <1>{1}</1>" msgid "<0>{0}</0> upvoted <1>{1}</1>"
msgstr "<0>{0}</0> a voté pour <1>{1}</1>" msgstr "<0>{0}</0> a voté pour <1>{1}</1>"
#. placeholder {0}: d.dumpTitle #. placeholder {0}: d.dumpTitle
#. placeholder {1}: d.playlistTitle #. placeholder {1}: d.playlistTitle
#: src/pages/Notifications.tsx:192 #: src/pages/Notifications.tsx:179
msgid "<0>{0}</0> was added to <1>{1}</1>" msgid "<0>{0}</0> was added to <1>{1}</1>"
msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>" msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
@@ -147,11 +147,11 @@ msgstr "<0>{0}</0> a été ajouté à <1>{1}</1>"
msgid "1 new message" msgid "1 new message"
msgstr "1 nouveau message" msgstr "1 nouveau message"
#: src/pages/Notifications.tsx:233 #: src/pages/Notifications.tsx:220
msgid "a comment" msgid "a comment"
msgstr "un commentaire" msgstr "un commentaire"
#: src/pages/Notifications.tsx:236 #: src/pages/Notifications.tsx:223
msgid "a post" msgid "a post"
msgstr "une publication" msgstr "une publication"
@@ -228,8 +228,8 @@ msgstr "Auto"
msgid "Back to login" msgid "Back to login"
msgstr "Retour à la connexion" msgstr "Retour à la connexion"
#: src/contexts/ws/WSProvider.tsx:392 #: src/contexts/ws/WSProvider.tsx:394
#: src/contexts/ws/WSProvider.tsx:758 #: src/contexts/ws/WSProvider.tsx:760
msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects." msgid "Can't connect to the live updates server. Upvotes and notifications may not sync until it reconnects."
msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion." msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les votes et les notifications pourraient ne pas se synchroniser avant la reconnexion."
@@ -239,16 +239,16 @@ msgstr "Impossible de se connecter au serveur de mises à jour en direct. Les vo
#: src/components/comment/CommentForm.tsx:127 #: src/components/comment/CommentForm.tsx:127
#: src/components/dump/create/DumpCreateModal.tsx:751 #: src/components/dump/create/DumpCreateModal.tsx:751
#: src/components/form/FormActions.tsx:42 #: src/components/form/FormActions.tsx:42
#: src/components/profile/DescriptionEditor.tsx:96 #: src/components/playlist/PlaylistEditForm.tsx:205
#: src/components/profile/DescriptionEditor.tsx:97
#: src/components/profile/EmailEditor.tsx:83 #: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63 #: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Dump.tsx:432 #: src/pages/Dump.tsx:432
#: src/pages/DumpEdit.tsx:542 #: src/pages/DumpEdit.tsx:542
#: src/pages/PlaylistDetail.tsx:948
msgid "Cancel" msgid "Cancel"
msgstr "Annuler" msgstr "Annuler"
#: src/pages/PlaylistDetail.tsx:753 #: src/components/playlist/PlaylistDumpList.tsx:118
msgid "Cancel removal" msgid "Cancel removal"
msgstr "Annuler la suppression" msgstr "Annuler la suppression"
@@ -341,7 +341,7 @@ msgstr "Thème de couleur"
msgid "Confirm new password" msgid "Confirm new password"
msgstr "Confirmer le nouveau mot de passe" msgstr "Confirmer le nouveau mot de passe"
#: src/contexts/ws/WSProvider.tsx:751 #: src/contexts/ws/WSProvider.tsx:753
msgid "Connection lost — this message may not have been sent" msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé" msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -362,14 +362,10 @@ msgid "Could not connect to the server. Check your connection and try again."
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez." msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
#: src/pages/DumpEdit.tsx:530 #: src/pages/DumpEdit.tsx:530
#: src/pages/UserPlaylists.tsx:330 #: src/pages/UserPlaylists.tsx:264
msgid "Could not delete" msgid "Could not delete"
msgstr "Suppression impossible" msgstr "Suppression impossible"
#: src/components/social/UserListPopover.tsx:167
msgid "Could not load."
msgstr "Impossible de charger."
#: src/pages/DumpEdit.tsx:490 #: src/pages/DumpEdit.tsx:490
msgid "Could not save" msgid "Could not save"
msgstr "Sauvegarde impossible" msgstr "Sauvegarde impossible"
@@ -396,7 +392,7 @@ msgstr "Créer et ajouter"
#. placeholder {0}: created.items.length #. placeholder {0}: created.items.length
#. placeholder {1}: created.hasMore ? "+" : "" #. placeholder {1}: created.hasMore ? "+" : ""
#: src/pages/UserPlaylists.tsx:335 #: src/pages/UserPlaylists.tsx:269
msgid "Created ({0}{1})" msgid "Created ({0}{1})"
msgstr "Créées ({0}{1})" msgstr "Créées ({0}{1})"
@@ -423,8 +419,8 @@ msgstr "Onglet par défaut"
#: src/components/chat/ChatMessageItem.tsx:213 #: src/components/chat/ChatMessageItem.tsx:213
#: src/components/comment/CommentNode.tsx:263 #: src/components/comment/CommentNode.tsx:263
#: src/components/comment/CommentNode.tsx:269 #: src/components/comment/CommentNode.tsx:269
#: src/components/playlist/PlaylistEditForm.tsx:201
#: src/components/shared/ConfirmModal.tsx:34 #: src/components/shared/ConfirmModal.tsx:34
#: src/pages/PlaylistDetail.tsx:944
msgid "Delete" msgid "Delete"
msgstr "Supprimer" msgstr "Supprimer"
@@ -448,8 +444,8 @@ msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})" msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:132 #: src/components/playlist/PlaylistCard.tsx:132
#: src/pages/PlaylistDetail.tsx:766 #: src/pages/PlaylistDetail.tsx:136
#: src/pages/UserPlaylists.tsx:393 #: src/pages/UserPlaylists.tsx:327
msgid "Delete playlist" msgid "Delete playlist"
msgstr "Supprimer la collection" msgstr "Supprimer la collection"
@@ -465,8 +461,8 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
msgid "Delete this message?" msgid "Delete this message?"
msgstr "Supprimer ce message ?" msgstr "Supprimer ce message ?"
#: src/pages/PlaylistDetail.tsx:765 #: src/pages/PlaylistDetail.tsx:135
#: src/pages/UserPlaylists.tsx:392 #: src/pages/UserPlaylists.tsx:326
msgid "Delete this playlist? This cannot be undone." msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible." msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -475,7 +471,7 @@ msgid "deleted message"
msgstr "message supprimé" msgstr "message supprimé"
#: src/components/playlist/PlaylistCreateForm.tsx:104 #: src/components/playlist/PlaylistCreateForm.tsx:104
#: src/pages/PlaylistDetail.tsx:916 #: src/components/playlist/PlaylistEditForm.tsx:173
msgid "Description (optional)" msgid "Description (optional)"
msgstr "Description (facultatif)" msgstr "Description (facultatif)"
@@ -525,19 +521,19 @@ msgstr "Recommander"
msgid "Dumped!" msgid "Dumped!"
msgstr "Recommandé !" msgstr "Recommandé !"
#: src/pages/Search.tsx:196 #: src/pages/Search.tsx:116
#: src/pages/UserDumps.tsx:88 #: src/pages/UserDumps.tsx:88
#: src/pages/UserPublicProfile.tsx:197 #: src/pages/UserPublicProfile.tsx:189
msgid "Dumps" msgid "Dumps"
msgstr "Recos" msgstr "Recos"
#. placeholder {0}: dumps.items.length #. placeholder {0}: dumps.items.length
#. placeholder {1}: dumps.hasMore ? "+" : "" #. placeholder {1}: dumps.hasMore ? "+" : ""
#: src/components/profile/ProfileDumpsTab.tsx:36 #: src/components/profile/ProfileDumpsTab.tsx:37
msgid "Dumps ({0}{1})" msgid "Dumps ({0}{1})"
msgstr "Recos ({0}{1})" msgstr "Recos ({0}{1})"
#: src/pages/Notifications.tsx:473 #: src/pages/Notifications.tsx:375
msgid "Earlier" msgid "Earlier"
msgstr "Plus tôt" msgstr "Plus tôt"
@@ -545,8 +541,8 @@ msgstr "Plus tôt"
#: src/components/chat/ChatMessageItem.tsx:104 #: src/components/chat/ChatMessageItem.tsx:104
#: src/components/chat/PendingItem.tsx:93 #: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:254 #: src/components/comment/CommentNode.tsx:254
#: src/components/playlist/PlaylistHeader.tsx:58
#: src/pages/Dump.tsx:550 #: src/pages/Dump.tsx:550
#: src/pages/PlaylistDetail.tsx:634
msgid "Edit" msgid "Edit"
msgstr "Modifier" msgstr "Modifier"
@@ -577,8 +573,8 @@ msgstr "modifié"
#. placeholder {0}: relativeTime(message.updatedAt) #. placeholder {0}: relativeTime(message.updatedAt)
#: src/components/chat/ChatMessageItem.tsx:128 #: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203 #: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:491 #: src/pages/Dump.tsx:491
#: src/pages/PlaylistDetail.tsx:673
msgid "edited {0}" msgid "edited {0}"
msgstr "modifié {0}" msgstr "modifié {0}"
@@ -587,8 +583,8 @@ msgstr "modifié {0}"
#. placeholder {0}: message.updatedAt.toLocaleString() #. placeholder {0}: message.updatedAt.toLocaleString()
#: src/components/chat/ChatMessageItem.tsx:124 #: src/components/chat/ChatMessageItem.tsx:124
#: src/components/comment/CommentNode.tsx:201 #: src/components/comment/CommentNode.tsx:201
#: src/components/playlist/PlaylistHeader.tsx:91
#: src/pages/Dump.tsx:489 #: src/pages/Dump.tsx:489
#: src/pages/PlaylistDetail.tsx:670
msgid "Edited {0}" msgid "Edited {0}"
msgstr "Modifié le {0}" msgstr "Modifié le {0}"
@@ -605,7 +601,7 @@ msgstr "Édition de {0}"
msgid "Email address" msgid "Email address"
msgstr "Adresse e-mail" msgstr "Adresse e-mail"
#: src/pages/Search.tsx:229 #: src/pages/Search.tsx:149
msgid "Enter a query to search." msgid "Enter a query to search."
msgstr "Saisissez une recherche." msgstr "Saisissez une recherche."
@@ -632,7 +628,7 @@ msgid "Failed to generate invite"
msgstr "Impossible de générer une invitation" msgstr "Impossible de générer une invitation"
#: src/components/shared/ListState.tsx:43 #: src/components/shared/ListState.tsx:43
#: src/pages/Notifications.tsx:447 #: src/pages/Notifications.tsx:349
msgid "Failed to load" msgid "Failed to load"
msgstr "Chargement échoué" msgstr "Chargement échoué"
@@ -648,9 +644,9 @@ msgstr "Impossible de publier le commentaire"
msgid "Failed to post reply" msgid "Failed to post reply"
msgstr "Impossible de publier la réponse" msgstr "Impossible de publier la réponse"
#: src/components/profile/DescriptionEditor.tsx:98 #: src/components/playlist/PlaylistEditForm.tsx:193
#: src/components/profile/DescriptionEditor.tsx:99
#: src/components/profile/EmailEditor.tsx:86 #: src/components/profile/EmailEditor.tsx:86
#: src/pages/PlaylistDetail.tsx:936
msgid "Failed to save" msgid "Failed to save"
msgstr "Enregistrement échoué" msgstr "Enregistrement échoué"
@@ -695,23 +691,23 @@ msgstr "Suivre {targetUsername}"
msgid "Follow playlist" msgid "Follow playlist"
msgstr "Suivre la collection" msgstr "Suivre la collection"
#: src/components/feed/FollowedFeed.tsx:215 #: src/components/feed/FollowedFeed.tsx:151
msgid "Follow some public playlists to see their dumps here." msgid "Follow some public playlists to see their dumps here."
msgstr "Suivez des collections publiques pour voir leurs recos ici." msgstr "Suivez des collections publiques pour voir leurs recos ici."
#: src/components/feed/FollowedFeed.tsx:207 #: src/components/feed/FollowedFeed.tsx:143
msgid "Follow some users to see their dumps here." msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici." msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:48 #: src/components/feed/FeedTabBar.tsx:48
#: src/components/profile/ProfileSettingsTab.tsx:44 #: src/components/profile/ProfileSettingsTab.tsx:44
#: src/pages/UserPublicProfile.tsx:199 #: src/pages/UserPublicProfile.tsx:191
msgid "Followed" msgid "Followed"
msgstr "Suivi" msgstr "Suivi"
#. placeholder {0}: followed.items.length #. placeholder {0}: followed.items.length
#. placeholder {1}: followed.hasMore ? "+" : "" #. placeholder {1}: followed.hasMore ? "+" : ""
#: src/pages/UserPlaylists.tsx:366 #: src/pages/UserPlaylists.tsx:300
msgid "Followed ({0}{1})" msgid "Followed ({0}{1})"
msgstr "Suivies ({0}{1})" msgstr "Suivies ({0}{1})"
@@ -729,11 +725,11 @@ msgstr "Abonné"
msgid "Forgot password?" msgid "Forgot password?"
msgstr "Mot de passe oublié ?" msgstr "Mot de passe oublié ?"
#: src/components/feed/FollowedFeed.tsx:196 #: src/components/feed/FollowedFeed.tsx:132
msgid "From people" msgid "From people"
msgstr "De personnes" msgstr "De personnes"
#: src/components/feed/FollowedFeed.tsx:197 #: src/components/feed/FollowedFeed.tsx:133
msgid "From playlists" msgid "From playlists"
msgstr "De collections" msgstr "De collections"
@@ -789,7 +785,7 @@ msgid "invited by"
msgstr "invité par" msgstr "invité par"
#: src/components/profile/ProfileInviteesTab.tsx:76 #: src/components/profile/ProfileInviteesTab.tsx:76
#: src/pages/UserPublicProfile.tsx:200 #: src/pages/UserPublicProfile.tsx:192
msgid "Invitees" msgid "Invitees"
msgstr "Invités" msgstr "Invités"
@@ -818,7 +814,7 @@ msgstr "Lien"
msgid "Link or file" msgid "Link or file"
msgstr "Lien ou fichier" msgstr "Lien ou fichier"
#: src/contexts/ws/WSProvider.tsx:757 #: src/contexts/ws/WSProvider.tsx:759
msgid "Live updates are temporarily disconnected. Trying to reconnect…" msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…" msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
@@ -847,11 +843,11 @@ msgstr "Chargement des messages…"
msgid "Loading more…" msgid "Loading more…"
msgstr "Chargement…" msgstr "Chargement…"
#: src/pages/PlaylistDetail.tsx:564 #: src/pages/PlaylistDetail.tsx:64
msgid "Loading playlist…" msgid "Loading playlist…"
msgstr "Chargement de la collection…" msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:150 #: src/pages/UserPublicProfile.tsx:142
msgid "Loading profile…" msgid "Loading profile…"
msgstr "Chargement du profil…" msgstr "Chargement du profil…"
@@ -861,10 +857,10 @@ msgstr "Chargement du profil…"
#: src/components/playlist/PlaylistMembershipPanel.tsx:45 #: src/components/playlist/PlaylistMembershipPanel.tsx:45
#: src/components/shared/ListState.tsx:38 #: src/components/shared/ListState.tsx:38
#: src/components/shared/LoadMoreButton.tsx:32 #: src/components/shared/LoadMoreButton.tsx:32
#: src/components/social/UserListPopover.tsx:224 #: src/components/social/UserListPopover.tsx:150
#: src/pages/Notifications.tsx:443 #: src/pages/Notifications.tsx:345
#: src/pages/UserDumps.tsx:62 #: src/pages/UserDumps.tsx:62
#: src/pages/UserPlaylists.tsx:294 #: src/pages/UserPlaylists.tsx:228
#: src/pages/UserUpvoted.tsx:48 #: src/pages/UserUpvoted.tsx:48
msgid "Loading…" msgid "Loading…"
msgstr "Chargement…" msgstr "Chargement…"
@@ -885,7 +881,7 @@ msgid "Log in to vote"
msgstr "Se connecter pour voter" msgstr "Se connecter pour voter"
#: src/components/profile/ProfileHeader.tsx:174 #: src/components/profile/ProfileHeader.tsx:174
#: src/pages/UserPublicProfile.tsx:171 #: src/pages/UserPublicProfile.tsx:163
msgid "Log out" msgid "Log out"
msgstr "Se déconnecter" msgstr "Se déconnecter"
@@ -897,7 +893,7 @@ msgstr "Connexion…"
msgid "Login failed" msgid "Login failed"
msgstr "Connexion échouée" msgstr "Connexion échouée"
#: src/pages/UserPublicProfile.tsx:205 #: src/pages/UserPublicProfile.tsx:197
msgid "Manage" msgid "Manage"
msgstr "Administration" msgstr "Administration"
@@ -918,7 +914,7 @@ msgstr "Couper le son"
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: src/pages/Notifications.tsx:436 #: src/pages/Notifications.tsx:338
msgid "new" msgid "new"
msgstr "nouveau" msgstr "nouveau"
@@ -953,15 +949,15 @@ msgstr "Suivant →"
msgid "Next track" msgid "Next track"
msgstr "Piste suivante" msgstr "Piste suivante"
#: src/pages/PlaylistDetail.tsx:693 #: src/components/playlist/PlaylistDumpList.tsx:46
msgid "No dumps in this playlist yet." msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant." msgstr "Aucune reco dans cette collection pour l'instant."
#: src/pages/Search.tsx:246 #: src/pages/Search.tsx:165
msgid "No dumps match \"{q}\"." msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »." msgstr "Aucune reco ne correspond à « {q} »."
#: src/components/feed/DumpFeed.tsx:74 #: src/components/feed/DumpFeed.tsx:76
#: src/components/feed/JournalFeed.tsx:36 #: src/components/feed/JournalFeed.tsx:36
msgid "No dumps yet. Be the first!" msgid "No dumps yet. Be the first!"
msgstr "Pas encore de recos. Soyez le premier !" msgstr "Pas encore de recos. Soyez le premier !"
@@ -971,7 +967,7 @@ msgid "No emoji found."
msgstr "Aucun emoji trouvé." msgstr "Aucun emoji trouvé."
#: src/components/profile/ProfileFollowedTab.tsx:110 #: src/components/profile/ProfileFollowedTab.tsx:110
#: src/pages/UserPlaylists.tsx:375 #: src/pages/UserPlaylists.tsx:309
msgid "No followed playlists yet." msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies." msgstr "Pas encore de collections suivies."
@@ -983,25 +979,25 @@ msgstr "Aucun invité pour le moment."
msgid "No messages yet. Say hello!" msgid "No messages yet. Say hello!"
msgstr "Aucun message pour l'instant. Dites bonjour !" msgstr "Aucun message pour l'instant. Dites bonjour !"
#: src/components/social/UserListPopover.tsx:232 #: src/components/social/UserListPopover.tsx:158
msgid "No one yet." msgid "No one yet."
msgstr "Personne pour le moment." msgstr "Personne pour le moment."
#: src/pages/Search.tsx:288 #: src/pages/Search.tsx:206
msgid "No playlists match \"{q}\"." msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »." msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51 #: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:51 #: src/components/profile/ProfilePlaylistsTab.tsx:52
#: src/pages/UserPlaylists.tsx:344 #: src/pages/UserPlaylists.tsx:278
msgid "No playlists yet." msgid "No playlists yet."
msgstr "Pas encore de collections." msgstr "Pas encore de collections."
#: src/contexts/ws/WSProvider.tsx:926 #: src/contexts/ws/WSProvider.tsx:928
msgid "No reply from the server — this message may not have been sent" msgid "No reply from the server — this message may not have been sent"
msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé" msgstr "Pas de réponse du serveur — ce message n'a peut-être pas été envoyé"
#: src/pages/Search.tsx:262 #: src/pages/Search.tsx:180
msgid "No users match \"{q}\"." msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »." msgstr "Aucun utilisateur ne correspond à « {q} »."
@@ -1009,9 +1005,9 @@ msgstr "Aucun utilisateur ne correspond à « {q} »."
msgid "Not following anyone yet." msgid "Not following anyone yet."
msgstr "Aucun abonnement pour le moment." msgstr "Aucun abonnement pour le moment."
#: src/components/profile/ProfileDumpsTab.tsx:45 #: src/components/profile/ProfileDumpsTab.tsx:46
#: src/components/profile/ProfileDumpsTab.tsx:59 #: src/components/profile/ProfileDumpsTab.tsx:60
#: src/pages/Notifications.tsx:454 #: src/pages/Notifications.tsx:356
#: src/pages/UserDumps.tsx:94 #: src/pages/UserDumps.tsx:94
#: src/pages/UserUpvoted.tsx:79 #: src/pages/UserUpvoted.tsx:79
msgid "Nothing here yet." msgid "Nothing here yet."
@@ -1019,8 +1015,8 @@ msgstr "Rien ici pour l'instant."
#: src/components/layout/NotificationBell.tsx:54 #: src/components/layout/NotificationBell.tsx:54
#: src/components/profile/ProfileSettingsTab.tsx:108 #: src/components/profile/ProfileSettingsTab.tsx:108
#: src/pages/Notifications.tsx:295 #: src/pages/Notifications.tsx:282
#: src/pages/Notifications.tsx:432 #: src/pages/Notifications.tsx:334
msgid "Notifications" msgid "Notifications"
msgstr "Notifications" msgstr "Notifications"
@@ -1109,22 +1105,22 @@ msgstr "Reprise de votre brouillon."
msgid "Play" msgid "Play"
msgstr "Lecture" msgstr "Lecture"
#: src/pages/PlaylistDetail.tsx:900 #: src/components/playlist/PlaylistEditForm.tsx:157
msgid "Playlist title" msgid "Playlist title"
msgstr "Titre de la collection" msgstr "Titre de la collection"
#: src/components/dump/create/StepIndicator.tsx:21 #: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:182 #: src/components/layout/AppHeader.tsx:182
#: src/components/layout/UserMenu.tsx:78 #: src/components/layout/UserMenu.tsx:78
#: src/pages/Search.tsx:199 #: src/pages/Search.tsx:119
#: src/pages/UserPlaylists.tsx:320 #: src/pages/UserPlaylists.tsx:254
#: src/pages/UserPublicProfile.tsx:198 #: src/pages/UserPublicProfile.tsx:190
msgid "Playlists" msgid "Playlists"
msgstr "Collections" msgstr "Collections"
#. placeholder {0}: playlists.items.length #. placeholder {0}: playlists.items.length
#. placeholder {1}: playlists.hasMore ? "+" : "" #. placeholder {1}: playlists.hasMore ? "+" : ""
#: src/components/profile/ProfilePlaylistsTab.tsx:34 #: src/components/profile/ProfilePlaylistsTab.tsx:35
msgid "Playlists ({0}{1})" msgid "Playlists ({0}{1})"
msgstr "Collections ({0}{1})" msgstr "Collections ({0}{1})"
@@ -1149,9 +1145,9 @@ msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:148 #: src/components/dump/DumpCard.tsx:148
#: src/components/dump/JournalCard.tsx:142 #: src/components/dump/JournalCard.tsx:142
#: src/components/playlist/PlaylistCard.tsx:95 #: src/components/playlist/PlaylistCard.tsx:95
#: src/components/playlist/PlaylistHeader.tsx:75
#: src/components/playlist/PlaylistMembershipPanel.tsx:81 #: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:497 #: src/pages/Dump.tsx:497
#: src/pages/PlaylistDetail.tsx:653
msgid "private" msgid "private"
msgstr "privé" msgstr "privé"
@@ -1160,7 +1156,7 @@ msgid "Private"
msgstr "Privé" msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:94 #: src/components/playlist/PlaylistCard.tsx:94
#: src/pages/PlaylistDetail.tsx:652 #: src/components/playlist/PlaylistHeader.tsx:75
msgid "public" msgid "public"
msgstr "public" msgstr "public"
@@ -1202,7 +1198,7 @@ msgstr "Connexe"
msgid "Remove file" msgid "Remove file"
msgstr "Supprimer le fichier" msgstr "Supprimer le fichier"
#: src/pages/PlaylistDetail.tsx:743 #: src/components/playlist/PlaylistDumpList.tsx:108
msgid "Remove from playlist" msgid "Remove from playlist"
msgstr "Retirer de la collection" msgstr "Retirer de la collection"
@@ -1263,27 +1259,27 @@ msgstr "Rôle"
#: src/components/category/CategoryManager.tsx:216 #: src/components/category/CategoryManager.tsx:216
#: src/components/chat/ChatEditBox.tsx:85 #: src/components/chat/ChatEditBox.tsx:85
#: src/components/comment/CommentNode.tsx:214 #: src/components/comment/CommentNode.tsx:214
#: src/components/profile/DescriptionEditor.tsx:88 #: src/components/playlist/PlaylistEditForm.tsx:212
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75 #: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:424 #: src/pages/Dump.tsx:424
#: src/pages/DumpEdit.tsx:545 #: src/pages/DumpEdit.tsx:545
#: src/pages/PlaylistDetail.tsx:955
msgid "Save" msgid "Save"
msgstr "Enregistrer" msgstr "Enregistrer"
#: src/components/comment/CommentNode.tsx:215 #: src/components/comment/CommentNode.tsx:215
#: src/components/playlist/PlaylistEditForm.tsx:208
#: src/components/profile/ChangePasswordModal.tsx:120 #: src/components/profile/ChangePasswordModal.tsx:120
#: src/components/profile/DescriptionEditor.tsx:85 #: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72 #: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:423 #: src/pages/Dump.tsx:423
#: src/pages/PlaylistDetail.tsx:951
#: src/pages/ResetPassword.tsx:136 #: src/pages/ResetPassword.tsx:136
msgid "Saving…" msgid "Saving…"
msgstr "Enregistrement…" msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:162 #: src/components/layout/AppHeader.tsx:162
#: src/components/layout/SearchBar.tsx:82 #: src/components/layout/SearchBar.tsx:82
#: src/pages/Search.tsx:63 #: src/pages/Search.tsx:65
msgid "Search" msgid "Search"
msgstr "Rechercher" msgstr "Rechercher"
@@ -1299,11 +1295,11 @@ msgstr "Rechercher un emoji"
msgid "Search emoji…" msgid "Search emoji…"
msgstr "Rechercher un emoji…" msgstr "Rechercher un emoji…"
#: src/pages/Search.tsx:240 #: src/pages/Search.tsx:160
msgid "Search failed" msgid "Search failed"
msgstr "Recherche échouée" msgstr "Recherche échouée"
#: src/pages/Search.tsx:235 #: src/pages/Search.tsx:155
msgid "Searching…" msgid "Searching…"
msgstr "Recherche…" msgstr "Recherche…"
@@ -1333,7 +1329,7 @@ msgstr "Serveur inaccessible"
msgid "Set new password" msgid "Set new password"
msgstr "Définir un nouveau mot de passe" msgstr "Définir un nouveau mot de passe"
#: src/pages/UserPublicProfile.tsx:202 #: src/pages/UserPublicProfile.tsx:194
msgid "Settings" msgid "Settings"
msgstr "Paramètres" msgstr "Paramètres"
@@ -1355,8 +1351,8 @@ msgstr "Certaines images n'ont pas pu être envoyées."
#: src/components/form/FormError.tsx:31 #: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29 #: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:726 #: src/contexts/ws/WSProvider.tsx:728
#: src/pages/PlaylistDetail.tsx:687 #: src/pages/PlaylistDetail.tsx:119
msgid "Something went wrong" msgid "Something went wrong"
msgstr "Une erreur est survenue" msgstr "Une erreur est survenue"
@@ -1380,15 +1376,11 @@ msgstr "Lancer la recherche"
msgid "That file is over {limit}." msgid "That file is over {limit}."
msgstr "Ce fichier dépasse {limit}." msgstr "Ce fichier dépasse {limit}."
#: src/pages/Notifications.tsx:235 #: src/pages/Notifications.tsx:222
msgid "the chat" msgid "the chat"
msgstr "la tribune" msgstr "la tribune"
#: src/pages/PlaylistDetail.tsx:513 #: src/hooks/playlist/usePlaylist.ts:160
msgid "The dump couldn't be put back. Try again."
msgstr "Le dump n'a pas pu être remis. Réessayez."
#: src/pages/PlaylistDetail.tsx:461
msgid "The dump couldn't be removed. Try again." msgid "The dump couldn't be removed. Try again."
msgstr "Le dump n'a pas pu être retiré. Réessayez." msgstr "Le dump n'a pas pu être retiré. Réessayez."
@@ -1396,6 +1388,10 @@ msgstr "Le dump n'a pas pu être retiré. Réessayez."
msgid "The image couldn't be uploaded." msgid "The image couldn't be uploaded."
msgstr "L'image n'a pas pu être envoyée." msgstr "L'image n'a pas pu être envoyée."
#: src/hooks/playlist/usePlaylist.ts:306
msgid "The new order couldn't be saved. Try again."
msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
#: src/components/comment/CommentNode.tsx:280 #: src/components/comment/CommentNode.tsx:280
msgid "This comment couldn't be deleted. Try again." msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez." msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
@@ -1430,7 +1426,7 @@ msgstr "Titre"
msgid "Title is required." msgid "Title is required."
msgstr "Un titre est requis." msgstr "Un titre est requis."
#: src/pages/Notifications.tsx:470 #: src/pages/Notifications.tsx:372
#: src/utils/chat/time.ts:24 #: src/utils/chat/time.ts:24
msgid "Today" msgid "Today"
msgstr "Aujourd'hui" msgstr "Aujourd'hui"
@@ -1448,7 +1444,7 @@ msgstr "Réessayer"
msgid "Type a message…" msgid "Type a message…"
msgstr "Écrivez un message…" msgstr "Écrivez un message…"
#: src/pages/PlaylistDetail.tsx:755 #: src/components/playlist/PlaylistDumpList.tsx:120
msgid "Undo" msgid "Undo"
msgstr "Annuler" msgstr "Annuler"
@@ -1495,7 +1491,7 @@ msgstr "Voté"
#. placeholder {0}: votes.items.length #. placeholder {0}: votes.items.length
#. placeholder {1}: votes.hasMore ? "+" : "" #. placeholder {1}: votes.hasMore ? "+" : ""
#: src/components/profile/ProfileDumpsTab.tsx:51 #: src/components/profile/ProfileDumpsTab.tsx:52
msgid "Upvoted ({0}{1})" msgid "Upvoted ({0}{1})"
msgstr "Votés ({0}{1})" msgstr "Votés ({0}{1})"
@@ -1528,7 +1524,7 @@ msgstr "Menu utilisateur"
msgid "Username" msgid "Username"
msgstr "Nom d'utilisateur" msgstr "Nom d'utilisateur"
#: src/pages/Search.tsx:198 #: src/pages/Search.tsx:118
msgid "Users" msgid "Users"
msgstr "Utilisateurs" msgstr "Utilisateurs"
@@ -1549,7 +1545,7 @@ msgstr "Volume"
msgid "What makes it worth it?" msgid "What makes it worth it?"
msgstr "Pourquoi on en voudrait ?" msgstr "Pourquoi on en voudrait ?"
#: src/components/profile/DescriptionEditor.tsx:77 #: src/components/profile/DescriptionEditor.tsx:78
#: src/components/profile/ProfileDescription.tsx:79 #: src/components/profile/ProfileDescription.tsx:79
msgid "Who am I?" msgid "Who am I?"
msgstr "Qui suis-je ?" msgstr "Qui suis-je ?"
@@ -1571,7 +1567,7 @@ msgstr "Pourquoi ?"
msgid "Write a reply…" msgid "Write a reply…"
msgstr "Écrire une réponse…" msgstr "Écrire une réponse…"
#: src/pages/Notifications.tsx:472 #: src/pages/Notifications.tsx:374
#: src/utils/chat/time.ts:27 #: src/utils/chat/time.ts:27
msgid "Yesterday" msgid "Yesterday"
msgstr "Hier" msgstr "Hier"
@@ -1580,11 +1576,11 @@ msgstr "Hier"
msgid "You can post it anyway." msgid "You can post it anyway."
msgstr "Vous pouvez la publier quand même." msgstr "Vous pouvez la publier quand même."
#: src/pages/Notifications.tsx:457 #: src/pages/Notifications.tsx:359
msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content." msgid "You'll be notified when someone follows your playlists, upvotes your dumps, or posts new content."
msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu." msgstr "Vous serez notifié lorsque quelqu'un suit vos collections, vote pour vos recos ou publie du nouveau contenu."
#: src/contexts/ws/WSProvider.tsx:907 #: src/contexts/ws/WSProvider.tsx:909
msgid "You're offline — not sent" msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé" msgstr "Hors ligne — message non envoyé"

View File

@@ -65,8 +65,8 @@ export function Dump(): React.JSX.Element {
const { selectedDump } = useParams(); const { selectedDump } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
// Taken once: it stays in the history entry, where a reload or Back would // Taken once, since it stays in the history entry, where a reload or Back
// show that snapshot forever, so it is cleared from there below. // would show that snapshot forever. It is cleared from there below.
const [handedOver] = useState( const [handedOver] = useState(
() => (location.state as { dump?: Dump } | null)?.dump ?? null, () => (location.state as { dump?: Dump } | null)?.dump ?? null,
); );

View File

@@ -56,9 +56,9 @@ type DumpEditState =
/** /**
* Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators; * Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators;
* anyone else signed in gets an error rather than a form the API would refuse * anyone else signed in gets an error rather than a form the API would refuse
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the preview refresh * to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the
* and the deletion apply at once, and the form's other fields on "Save", which * preview refresh and the deletion apply at once, and the form's other fields
* goes back to the dump's page. A deletion goes to the index. * on "Save", which goes back to the dump's page. A deletion goes to the index.
*/ */
export function DumpEdit(): React.JSX.Element { export function DumpEdit(): React.JSX.Element {
const { selectedDump } = useParams(); const { selectedDump } = useParams();

View File

@@ -5,7 +5,7 @@
* @module * @module
*/ */
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { useLocation, useParams } from "react-router"; import { useLocation, useParams } from "react-router";
import { AppHeader } from "../components/layout/AppHeader.tsx"; import { AppHeader } from "../components/layout/AppHeader.tsx";
@@ -18,13 +18,12 @@ import {
} from "../config/feedTabs.ts"; } from "../config/feedTabs.ts";
import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts"; import { useDefaultFeedTab } from "../hooks/feed/useDefaultFeedTab.ts";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts"; import { deserializeDump, hydrateDump } from "../utils/shared/deserialize.ts";
import type { Dump } from "../../shared/dump.ts"; import type { Dump } from "../../shared/dump.ts";
import type { PaginatedData, Wire } from "../../shared/api.ts"; import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
import { usePagedList } from "../hooks/feed/usePagedList.ts";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts";
import { import {
useScrollRestore, useScrollRestore,
@@ -37,11 +36,7 @@ import { useCategories } from "../hooks/category/useCategories.ts";
import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts"; import { useDumpListSync } from "../hooks/dump/useDumpListSync.ts";
import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts";
import { import { DumpFeed, type MainFeedProps } from "../components/feed/DumpFeed.tsx";
DumpFeed,
type DumpFeedState,
type MainFeedProps,
} from "../components/feed/DumpFeed.tsx";
import { JournalFeed } from "../components/feed/JournalFeed.tsx"; import { JournalFeed } from "../components/feed/JournalFeed.tsx";
import { FollowedFeed } from "../components/feed/FollowedFeed.tsx"; import { FollowedFeed } from "../components/feed/FollowedFeed.tsx";
import { NotFound } from "./NotFound.tsx"; import { NotFound } from "./NotFound.tsx";
@@ -71,7 +66,7 @@ export function Index(
const justDeletedId = (location.state as { deletedDumpId?: string } | null) const justDeletedId = (location.state as { deletedDumpId?: string } | null)
?.deletedDumpId; ?.deletedDumpId;
const { user, token } = useAuth(); const { user, authFetch } = useAuth();
const { recentDumps, deletedDumpIds, connectionEpoch } = useWS(); const { recentDumps, deletedDumpIds, connectionEpoch } = useWS();
const { categories, isLoaded: categoriesLoaded } = useCategories(); const { categories, isLoaded: categoriesLoaded } = useCategories();
@@ -97,31 +92,26 @@ export function Index(
? (rawTab as FeedTab) ? (rawTab as FeedTab)
: defaultTab; : defaultTab;
const feedUrl = useCallback( const feedUrl = categorySlug
(page: number) => ? `${API_URL}/api/categories/${encodeURIComponent(categorySlug)}/dumps`
categorySlug : `${API_URL}/api/dumps/`;
? `${API_URL}/api/categories/${ const fetchPage = useCallback(
encodeURIComponent(categorySlug) (page: number, signal?: AbortSignal) =>
}/dumps?page=${page}&limit=${DEFAULT_PAGE_SIZE}` authFetch(pageUrl(feedUrl, page), { signal })
: `${API_URL}/api/dumps/?page=${page}&limit=${DEFAULT_PAGE_SIZE}`, .then((res) => readPage(res, deserializeDump)),
[categorySlug], [authFetch, feedUrl],
); );
const { cached, saveState } = useFeedCache<Dump>( const { cached, saveState } = useFeedCache<Dump>(
`feed:index:${categorySlug ?? "all"}`, `feed:index:${categorySlug ?? "all"}`,
hydrateDump, hydrateDump,
); );
const [dumpsState, setDumpsState] = useState<DumpFeedState>(() => const { state: dumpsState, setItems, loadMore } = usePagedList(
cached `${feedUrl}:${user?.id ?? "guest"}`,
? { fetchPage,
status: "loaded", cached,
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
}
: { status: "loading" }
); );
useDumpListSync(setItems);
// The Web Share Target: Android's share sheet opens `/?share_url=…`, which // The Web Share Target: Android's share sheet opens `/?share_url=…`, which
// the redirect from `/` keeps. The query is dropped from the URL at once, so // the redirect from `/` keeps. The query is dropped from the URL at once, so
@@ -135,42 +125,6 @@ export function Index(
globalThis.history.replaceState({}, "", location.pathname); globalThis.history.replaceState({}, "", location.pathname);
}, [shareUrl, location.pathname]); }, [shareUrl, location.pathname]);
useEffect(() => {
if (cached) return;
const controller = new AbortController();
(async () => {
try {
const res = await fetch(feedUrl(1), {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const { items, hasMore } = await expectOk<
PaginatedData<Wire<Dump>>
>(res);
setDumpsState({
status: "loaded",
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
} catch (err) {
if ((err as Error).name === "AbortError") return;
setDumpsState({ status: "error", error: friendlyFetchError(err) });
}
})();
return () => controller.abort();
}, [cached, token, feedUrl]);
const setDumpsItems = useCallback(
(fn: (prev: Dump[]) => Dump[]) =>
setDumpsState((s) =>
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
),
[],
);
useDumpListSync(setDumpsItems);
// Dumps posted while the socket was down never arrived. On reconnect, the // Dumps posted while the socket was down never arrived. On reconnect, the
// first page is fetched again and whatever is new goes on top, keeping the // first page is fetched again and whatever is new goes on top, keeping the
// pages loaded and the scroll position. // pages loaded and the scroll position.
@@ -179,76 +133,34 @@ export function Index(
if (connectionEpoch === backfillEpochRef.current) return; if (connectionEpoch === backfillEpochRef.current) return;
backfillEpochRef.current = connectionEpoch; backfillEpochRef.current = connectionEpoch;
const controller = new AbortController(); const controller = new AbortController();
fetch(feedUrl(1), { fetchPage(1, controller.signal)
signal: controller.signal, .then(({ items }) =>
headers: token ? { Authorization: `Bearer ${token}` } : {}, setItems((dumps) => {
}) const known = new Set(dumps.map((d) => d.id));
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res)) const newOnes = items.filter((d) => !known.has(d.id));
.then(({ items }) => { return newOnes.length === 0 ? dumps : [...newOnes, ...dumps];
const fresh = items.map(deserializeDump); })
setDumpsState((s) => { )
if (s.status !== "loaded") return s;
const known = new Set(s.dumps.map((d) => d.id));
const newOnes = fresh.filter((d) => !known.has(d.id));
if (newOnes.length === 0) return s;
return { ...s, dumps: [...newOnes, ...s.dumps] };
});
})
.catch(() => {}); .catch(() => {});
return () => controller.abort(); return () => controller.abort();
}, [connectionEpoch, feedUrl, token]); }, [connectionEpoch, fetchPage, setItems]);
const loadMore = useCallback(() => {
if (
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
dumpsState.loadingMore
) return;
const nextPage = dumpsState.page + 1;
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: true } : s
);
fetch(feedUrl(nextPage), {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((res) => expectOk<PaginatedData<Wire<Dump>>>(res))
.then(({ items, hasMore }) => {
setDumpsState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setDumpsState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [dumpsState, token, feedUrl]);
const sentinelRef = useInfiniteScroll( const sentinelRef = useInfiniteScroll(
loadMore, loadMore,
dumpsState.status === "loaded" && dumpsState.hasMore && dumpsState.status === "loaded" && canLoadMore(dumpsState.data),
!dumpsState.loadingMore,
); );
useScrollSave( useScrollSave(
dumpsState.status === "loaded", dumpsState.status === "loaded",
useCallback((y) => { useCallback((y) => {
if (dumpsState.status !== "loaded") return; if (dumpsState.status === "loaded") saveState(dumpsState.data, y);
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
}, [dumpsState, saveState]), }, [dumpsState, saveState]),
); );
useScrollRestore(cached?.scrollY, dumpsState.status === "loaded"); useScrollRestore(cached?.scrollY, dumpsState.status === "loaded");
const dumps = useMemo( const dumps = useMemo(
() => dumpsState.status === "loaded" ? dumpsState.dumps : [], () => dumpsState.status === "loaded" ? dumpsState.data.items : [],
[dumpsState], [dumpsState],
); );

View File

@@ -6,7 +6,7 @@
* @module * @module
*/ */
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -31,23 +31,10 @@ import type {
UserFollowedData, UserFollowedData,
UserMentionedData, UserMentionedData,
} from "../../shared/notification.ts"; } from "../../shared/notification.ts";
import type { PaginatedData, Wire } from "../../shared/api.ts";
import { deserializeNotification } from "../utils/shared/deserialize.ts"; import { deserializeNotification } from "../utils/shared/deserialize.ts";
import { pageUrl, readPage } from "../utils/feed/pagedList.ts";
import { usePagedList } from "../hooks/feed/usePagedList.ts";
import { PageShell } from "../components/layout/PageShell.tsx"; import { PageShell } from "../components/layout/PageShell.tsx";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
type State =
// `arrived` holds what the socket sent before the list came back, which the
// list may predate.
| { status: "loading"; arrived: Notification[] }
| { status: "error"; error: string }
| {
status: "loaded";
items: Notification[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
type NotifIconKind = type NotifIconKind =
| "upvote" | "upvote"
@@ -293,94 +280,41 @@ function groupByDate(
*/ */
export function Notifications(): React.JSX.Element { export function Notifications(): React.JSX.Element {
useDocumentTitle(t`Notifications`); useDocumentTitle(t`Notifications`);
const { authFetch } = useAuth(); const { user, authFetch } = useAuth();
const { clearUnreadNotifications, lastNotification } = useWS(); const { clearUnreadNotifications, lastNotification } = useWS();
const [state, setState] = useState<State>({ const { state, loadMore } = usePagedList(
status: "loading", user?.id,
arrived: [], (page, signal) =>
}); authFetch(
pageUrl(`${API_URL}/api/notifications`, page, NOTIFICATIONS_PAGE_SIZE),
{ signal },
).then((res) => readPage(res, deserializeNotification)),
);
// The latest notification the read-all requests have covered, so that one // Each new `lastNotification` is kept once, during render rather than in an
// arriving while the page is open is marked read once, and the one the bell // effect, so it shows in the same frame. The list may predate it or not.
// already had isn't marked again after the first load. const [arrived, setArrived] = useState<Notification[]>([]);
const lastNotificationRef = useRef(lastNotification);
const markedReadRef = useRef(lastNotification);
useLayoutEffect(() => {
lastNotificationRef.current = lastNotification;
});
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await expectOk<PaginatedData<Wire<Notification>>>(
await authFetch(
`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`,
),
);
if (cancelled) return;
const items = data.items.map(deserializeNotification);
setState((s) => ({
status: "loaded",
items: [
...(s.status === "loading" ? s.arrived : []).filter((a) =>
!items.some((n) => n.id === a.id)
),
...items,
],
hasMore: data.hasMore,
page: 1,
loadingMore: false,
}));
} catch (err) {
if (!cancelled) {
setState({ status: "error", error: friendlyFetchError(err) });
}
return;
}
// If this fails, the list is still worth showing, and they stay unread
// for next time.
markedReadRef.current = lastNotificationRef.current;
try {
const res = await authFetch(`${API_URL}/api/notifications/read-all`, {
method: "POST",
});
if (cancelled || !res.ok) return;
// Only the bell clears. Marking the list read too would take its dots
// and "new" count away a moment after they appear.
clearUnreadNotifications();
} catch {
// Offline: they stay unread for next time.
}
})();
return () => {
cancelled = true;
};
}, [authFetch, clearUnreadNotifications]);
// Each new `lastNotification` is added once, during render rather than in an
// effect, so it shows in the same frame.
const [prevLastNotification, setPrevLastNotification] = useState( const [prevLastNotification, setPrevLastNotification] = useState(
lastNotification, lastNotification,
); );
if (prevLastNotification !== lastNotification && lastNotification !== null) { if (prevLastNotification !== lastNotification && lastNotification !== null) {
setPrevLastNotification(lastNotification); setPrevLastNotification(lastNotification);
setState((s) => { setArrived((a) => [lastNotification, ...a]);
if (s.status === "loading") {
return { ...s, arrived: [lastNotification, ...s.arrived] };
}
if (s.status !== "loaded") return s;
if (s.items.some((n) => n.id === lastNotification.id)) return s;
return { ...s, items: [lastNotification, ...s.items] };
});
} }
const items = useMemo(() => {
if (state.status !== "loaded") return [];
const listed = new Set(state.data.items.map((n) => n.id));
return [...arrived.filter((n) => !listed.has(n.id)), ...state.data.items];
}, [state, arrived]);
// One that arrives while the page is open is being seen: it stays // Every notification is marked read on the server once the list shows, and
// highlighted here, but the bell must not count it. // again for each that arrives while the page is open. Only the bell clears:
// clearing the list too would take its dots and "new" count away a moment
// after they appear. A failure leaves them unread for next time.
const loaded = state.status === "loaded"; const loaded = state.status === "loaded";
const markedReadRef = useRef<Notification | null | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!lastNotification || !loaded) return; if (!loaded || markedReadRef.current === lastNotification) return;
if (markedReadRef.current === lastNotification) return;
markedReadRef.current = lastNotification; markedReadRef.current = lastNotification;
authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" }) authFetch(`${API_URL}/api/notifications/read-all`, { method: "POST" })
.then((res) => { .then((res) => {
@@ -389,39 +323,7 @@ export function Notifications(): React.JSX.Element {
.catch(() => {}); .catch(() => {});
}, [lastNotification, loaded, authFetch, clearUnreadNotifications]); }, [lastNotification, loaded, authFetch, clearUnreadNotifications]);
const loadMore = () => { const totalUnread = items.filter((n) => !n.read).length;
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
return;
}
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
authFetch(
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
)
.then((res) => expectOk<PaginatedData<Wire<Notification>>>(res))
.then((data) => {
setState((s) =>
s.status === "loaded"
? {
...s,
items: [...s.items, ...data.items.map(deserializeNotification)],
hasMore: data.hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
};
const totalUnread = state.status === "loaded"
? state.items.filter((n) => !n.read).length
: 0;
return ( return (
<PageShell> <PageShell>
@@ -447,7 +349,7 @@ export function Notifications(): React.JSX.Element {
<ErrorCard title={t`Failed to load`} message={state.error} /> <ErrorCard title={t`Failed to load`} message={state.error} />
)} )}
{state.status === "loaded" && state.items.length === 0 && ( {state.status === "loaded" && items.length === 0 && (
<div className="notifications-empty"> <div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span> <span className="notifications-empty-icon">🔕</span>
<p> <p>
@@ -462,8 +364,8 @@ export function Notifications(): React.JSX.Element {
</div> </div>
)} )}
{state.status === "loaded" && state.items.length > 0 && {state.status === "loaded" && items.length > 0 &&
groupByDate(state.items).map(({ label, items }) => ( groupByDate(items).map(({ label, items }) => (
<section key={label} className="notif-group"> <section key={label} className="notif-group">
<h2 className="notif-group-label"> <h2 className="notif-group-label">
{label === "Today" {label === "Today"
@@ -508,8 +410,11 @@ export function Notifications(): React.JSX.Element {
</section> </section>
))} ))}
{state.status === "loaded" && state.hasMore && ( {state.status === "loaded" && state.data.hasMore && (
<LoadMoreButton loadingMore={state.loadingMore} onClick={loadMore} /> <LoadMoreButton
loadingMore={state.data.loadingMore}
onClick={loadMore}
/>
)} )}
</div> </div>
</PageShell> </PageShell>

View File

@@ -5,557 +5,57 @@
* @module * @module
*/ */
import { import { useState } from "react";
useCallback, import { useNavigate, useParams } from "react-router";
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import { VALIDATION } from "../../shared/validation.ts";
import { CountedInput } from "../components/shared/CountedInput.tsx";
import type {
Playlist,
PlaylistWithDumps,
ReorderPlaylistRequest,
UpdatePlaylistRequest,
} from "../../shared/playlist.ts";
import type { Wire } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
} from "../utils/shared/deserialize.ts";
import { playlistImageUrl, playlistUrl } from "../utils/shared/urls.ts";
import { can } from "../utils/auth/permissions.ts"; import { can } from "../utils/auth/permissions.ts";
import { useAuth } from "../hooks/auth/useAuth.ts"; import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts"; import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { fadeClassName, useFading } from "../hooks/feed/useFading.ts"; import { usePlaylist } from "../hooks/playlist/usePlaylist.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { DumpCard } from "../components/dump/DumpCard.tsx";
import { PageShell } from "../components/layout/PageShell.tsx"; import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx"; import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx"; import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
import { ImagePicker } from "../components/shared/ImagePicker.tsx"; import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
import { Markdown } from "../components/editor/Markdown.tsx"; import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
import { TextEditor } from "../components/editor/TextEditor.tsx"; import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.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";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
type LoadState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; playlist: PlaylistWithDumps };
/** /**
* Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see * Renders `/playlists/:playlistId` (an id or a slug), for anyone who may see
* the playlist: a private one only to its owner. The playlist and the dumps * the playlist: a private one only to its owner. `usePlaylist` loads it and
* the viewer may see in it come from `GET /api/playlists/:playlistId`, and the * keeps it live. Its owner, or a user with `playlist:moderate`, can edit or
* socket keeps them live. Its owner, or a user with `playlist:moderate`, can * delete it, drag its dumps into a new order, and remove one, with a short
* edit or delete it, drag its dumps into a new order, and remove one, with a * window to undo.
* short window to undo.
*/ */
export function PlaylistDetail(): React.JSX.Element { export function PlaylistDetail(): React.JSX.Element {
const { playlistId } = useParams<{ playlistId: string }>(); const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, authFetch, token, mediaToken } = useAuth(); const { user } = useAuth();
const { const {
voteCounts, state,
myVotes, fading,
castVote, actionError,
removeVote, applyDetails,
deletedDumpIds, remove,
lastDumpEvent, undoRemove,
lastPlaylistEvent, move,
} = useWS(); saveOrder,
deletePlaylist,
const [state, setState] = useState<LoadState>({ status: "loading" }); } = usePlaylist(playlistId);
// The socket names playlists by id, while the URL may hold the slug.
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
useDocumentTitle(
state.status === "loaded"
? state.playlist.title
: state.status === "error"
? null
: undefined,
);
// The dumps in the playlist. `state.playlist.dumps` also keeps the removed
// ones until their fade ends.
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
// A removed dump shows faded for 2 seconds, while it can be undone, then
// 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.
const pendingUndoOrderRef = useRef<string[] | null>(null);
// Other clients see the same two echoes without that order, so a new order
// from the socket is applied only once none follows for 80 ms.
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// A ref, since dragover fires faster than state renders.
const dragSrcRef = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const headerRef = useHeightTransition<HTMLDivElement>(editOpen); const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
// A delete or a removal the server refused. A refused reorder just reloads.
const [actionError, setActionError] = useState<string | null>(null);
// For the socket effects, which must not re-run when it changes. useDocumentTitle(
const activeDumpIdsRef = useRef(activeDumpIds); state.status === "loaded"
// Every dump of the playlist seen so far, so that one hidden when it turned ? state.data.title
// private comes back when it turns public. A dump removed from the playlist : state.status === "error"
// leaves it. ? null
const knownDumpIdsRef = useRef<Set<string>>(new Set()); : undefined,
// The removals still in flight, by dump, which an undo must wait for. );
const removalsRef = useRef<Map<string, Promise<unknown>>>(new Map());
// The server's last order, where such a dump comes back.
const dumpOrderRef = useRef<string[]>([]);
useLayoutEffect(() => {
activeDumpIdsRef.current = activeDumpIds;
});
useEffect(() => () => {
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
}, []);
const fetchAbortRef = useRef<AbortController | null>(null);
const fetchPlaylist = useCallback(() => {
if (!playlistId) return;
fetchAbortRef.current?.abort();
const controller = new AbortController();
fetchAbortRef.current = controller;
// In a microtask, since the linter forbids a synchronous setState in the
// effect that calls this. The user sees no difference.
queueMicrotask(() => setState({ status: "loading" }));
fetch(`${API_URL}/api/playlists/${playlistId}`, {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => {
if (!r.ok) {
throw new Error(
r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
);
}
return r.json();
})
.then((body) => {
if (!body.success) throw new Error("Failed to load playlist");
const pl = deserializePlaylistWithDumps(
body.data as Wire<PlaylistWithDumps>,
);
setState({ status: "loaded", playlist: pl });
const ids = new Set(pl.dumps.map((d) => d.id));
const order = pl.dumps.map((d) => d.id);
setActiveDumpIds(ids);
dumpOrderRef.current = order;
for (const id of ids) knownDumpIdsRef.current.add(id);
cancelAll();
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
dumpReorderTimerRef.current = null;
}
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({
status: "error",
error: friendlyFetchError(err),
});
});
}, [playlistId, token, cancelAll]);
useEffect(() => {
fetchPlaylist();
return () => fetchAbortRef.current?.abort();
}, [fetchPlaylist]);
// The socket's changes to this playlist: its dumps, its details, its end.
useEffect(() => {
if (!lastPlaylistEvent || !playlistUUID) return;
const ev = lastPlaylistEvent;
if (ev.playlistId !== playlistUUID) return;
if (ev.type === "dumps_updated" && ev.dumpIds) {
const newIds = new Set(ev.dumpIds);
for (const id of newIds) knownDumpIdsRef.current.add(id);
const prev = activeDumpIdsRef.current;
for (const id of prev) {
if (!newIds.has(id)) {
// Shown until now and gone from the list: removed from the
// playlist, not hidden (that comes as `dump_deleted`).
knownDumpIdsRef.current.delete(id);
setActiveDumpIds((s) => {
const n = new Set(s);
n.delete(id);
return n;
});
startFading(id);
}
}
// An added dump is fetched alone. fetchPlaylist would go back through
// "loading", which unsets playlistUUID and runs this effect again.
for (const id of newIds) {
if (!prev.has(id)) {
cancelFading(id);
setActiveDumpIds((s) => new Set([...s, id]));
const orderedIds = ev.dumpIds!;
fetch(`${API_URL}/api/dumps/${id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.ok ? r.json() : null)
.then((body) => {
if (!body?.success) return;
const dump = deserializeDump(body.data as Wire<Dump>);
setState((s) => {
if (s.status !== "loaded") return s;
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
dumpMap.set(dump.id, dump);
return {
...s,
playlist: {
...s.playlist,
dumps: [
...orderedIds
.filter((oid) => dumpMap.has(oid))
.map((oid) => dumpMap.get(oid)!),
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
],
},
};
});
})
.catch(() => {});
}
}
// Debounced: see dumpReorderTimerRef.
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
}
const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
const serverOrder = ev.dumpIds!;
dumpReorderTimerRef.current = setTimeout(() => {
dumpReorderTimerRef.current = null;
setState((s) => {
if (s.status !== "loaded") return s;
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
const orderedActive = orderToApply
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!);
let ai = 0;
// The fading dumps keep their slots, the others take the new order,
// and dumps whose fetch landed late go last.
const merged = s.playlist.dumps.map((d) =>
newIds.has(d.id) ? orderedActive[ai++] : d
);
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
return { ...s, playlist: { ...s.playlist, dumps: merged } };
});
dumpOrderRef.current = serverOrder;
}, 80);
} else if (ev.type === "updated" && ev.playlist) {
setState((prev) => {
if (prev.status !== "loaded") return prev;
return {
...prev,
playlist: {
...prev.playlist,
title: ev.playlist!.title,
description: ev.playlist!.description,
isPublic: ev.playlist!.isPublic,
imageMime: ev.playlist!.imageMime,
},
};
});
} else if (ev.type === "deleted") {
navigate("/");
}
}, [
lastPlaylistEvent,
playlistUUID,
navigate,
token,
startFading,
cancelFading,
]);
// A deleted dump leaves at once, without the removal's fade.
useEffect(() => {
if (deletedDumpIds.size === 0) return;
// It mirrors the socket's state, which only an effect can do.
// eslint-disable-next-line react-hooks/set-state-in-effect
setState((prev) => {
if (prev.status !== "loaded") return prev;
const filtered = prev.playlist.dumps.filter((d) =>
!deletedDumpIds.has(d.id)
);
if (filtered.length === prev.playlist.dumps.length) return prev;
return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
});
setActiveDumpIds((prev) => {
const n = new Set(prev);
for (const id of deletedDumpIds) n.delete(id);
return n;
});
}, [deletedDumpIds]);
// An edited dump changes in place, and one of the playlist's dumps that
// turned public again comes back at its place in the server's order.
useEffect(() => {
if (!lastDumpEvent) return;
const dump = lastDumpEvent;
setState((prev) => {
if (prev.status !== "loaded") return prev;
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
if (idx !== -1) {
const dumps = [...prev.playlist.dumps];
dumps[idx] = dump;
return { ...prev, playlist: { ...prev.playlist, dumps } };
}
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
const order = dumpOrderRef.current;
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
dumpMap.set(dump.id, dump);
const reinserted = order.length > 0
? [
...order.filter((id) => dumpMap.has(id)).map((id) =>
dumpMap.get(id)!
),
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
]
: [...prev.playlist.dumps, dump];
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
}
return prev;
});
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
setActiveDumpIds((prev) => {
if (prev.has(dump.id)) return prev;
return new Set([...prev, dump.id]);
});
}
}, [lastDumpEvent]);
const handleDragStart = (index: number) => {
dragSrcRef.current = index;
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
const src = dragSrcRef.current;
if (src === null || src === index) return;
// Swapping as soon as the pointer enters a card would swap it straight
// back, so the pointer must cross the card's middle first.
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const mid = rect.top + rect.height / 2;
if (src < index && e.clientY < mid) return;
if (src > index && e.clientY > mid) return;
// The drag indices count only the dumps not fading, which go last.
setState((prev) => {
if (prev.status !== "loaded") return prev;
const ids = activeDumpIdsRef.current;
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
const reordered = [...activeDumps];
const [moved] = reordered.splice(src, 1);
reordered.splice(index, 0, moved);
return {
...prev,
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
};
});
dragSrcRef.current = index;
setDragOverIndex(index);
};
const handleDragEnd = async () => {
const src = dragSrcRef.current;
dragSrcRef.current = null;
setDragOverIndex(null);
if (src === null || state.status !== "loaded" || !playlistId) return;
const activeDumps = state.playlist.dumps.filter((d) =>
activeDumpIds.has(d.id)
);
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlistId}/order`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch {
// Back to the order the server actually kept.
fetchPlaylist();
}
};
const handleRemoveDump = (dumpId: string) => {
if (!playlistId || state.status !== "loaded") return;
const index = state.playlist.dumps.findIndex((d) => d.id === dumpId);
const dump = state.playlist.dumps[index];
knownDumpIdsRef.current.delete(dumpId);
// Removed on the server at once; the undo adds it back.
const removal = authFetch(
`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`,
{ method: "DELETE" },
).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
}).catch(() => {
cancelFading(dumpId);
knownDumpIdsRef.current.add(dumpId);
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// The fade may have taken it out of the list already.
setState((s) => {
if (s.status !== "loaded" || !dump) return s;
if (s.playlist.dumps.some((d) => d.id === dumpId)) return s;
const dumps = [...s.playlist.dumps];
dumps.splice(Math.min(index, dumps.length), 0, dump);
return { ...s, playlist: { ...s.playlist, dumps } };
});
setActionError(t`The dump couldn't be removed. Try again.`);
}).finally(() => removalsRef.current.delete(dumpId));
removalsRef.current.set(dumpId, removal);
setActiveDumpIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
startFading(dumpId);
};
const handleCancelRemove = (dumpId: string) => {
if (!playlistId || state.status !== "loaded") return;
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.
const restoredIds = new Set([...activeDumpIds, dumpId]);
const desiredOrder = state.playlist.dumps
.filter((d) => restoredIds.has(d.id))
.map((d) => d.id);
pendingUndoOrderRef.current = desiredOrder;
knownDumpIdsRef.current.add(dumpId);
// Added back only once the removal has landed, or the two could cross.
// The server adds the dump first, so the order is restored after.
(removalsRef.current.get(dumpId) ?? Promise.resolve())
.then(() =>
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "POST",
})
)
.then((res) => {
// 409: the removal failed, so it never left.
if (!res.ok && res.status !== 409) {
throw new Error(`HTTP ${res.status}`);
}
return authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
),
});
})
.catch(() => {
knownDumpIdsRef.current.delete(dumpId);
setActiveDumpIds((prev) => {
const n = new Set(prev);
n.delete(dumpId);
return n;
});
startFading(dumpId);
setActionError(t`The dump couldn't be put back. Try again.`);
})
.finally(() => {
pendingUndoOrderRef.current = null;
});
};
const openEdit = () => setEditOpen(true);
const handleEditSaved = (updated: Playlist) => {
setEditOpen(false);
// Shown at once rather than left to the socket's echo, which may never
// come (disconnected); a new slug also moves the URL.
setState((prev) =>
prev.status === "loaded"
? {
...prev,
playlist: {
...prev.playlist,
...updated,
dumps: prev.playlist.dumps,
},
}
: prev
);
if (state.status === "loaded" && updated.slug !== state.playlist.slug) {
navigate(playlistUrl(updated), { replace: true });
}
};
const handleDelete = async () => {
if (!playlistId) return;
setConfirmDelete(false);
setActionError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
method: "DELETE",
});
// expectOk throws the server's message.
if (!res.ok) await expectOk(res);
} catch (err) {
setActionError(friendlyFetchError(err));
return;
}
navigate("/");
};
if (state.status === "loading") { if (state.status === "loading") {
return ( return (
@@ -584,15 +84,9 @@ export function PlaylistDetail(): React.JSX.Element {
); );
} }
const { playlist } = state; const playlist = state.data;
const isOwner = !!user && user.id === playlist.userId; const canEdit = (!!user && user.id === playlist.userId) ||
const canEdit = isOwner || can(user, "playlist:moderate"); can(user, "playlist:moderate");
// The fading dumps stay on screen, but can't be dragged.
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
const visibleDumps = playlist.dumps.filter((d) =>
activeDumpIds.has(d.id) || d.id in fading
);
return ( return (
<PageShell> <PageShell>
@@ -604,81 +98,19 @@ export function PlaylistDetail(): React.JSX.Element {
playlist={playlist} playlist={playlist}
onCancel={() => setEditOpen(false)} onCancel={() => setEditOpen(false)}
onRequestDelete={() => setConfirmDelete(true)} onRequestDelete={() => setConfirmDelete(true)}
onSaved={handleEditSaved} onSaved={(updated) => {
setEditOpen(false);
// Shown at once rather than left to the socket's echo,
// which may never come (disconnected).
applyDetails(updated);
}}
/> />
) )
: ( : (
<> <PlaylistHeader
{playlist.imageMime && ( playlist={playlist}
<img onEdit={canEdit ? () => setEditOpen(true) : undefined}
src={playlistImageUrl(playlist, mediaToken)} />
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-content">
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{canEdit && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
<Trans>Edit</Trans>
</button>
)}
</div>
{playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic
? <Trans>public</Trans>
: <Trans>private</Trans>}
</span>
{playlist.ownerUsername && (
<Link
to={`/users/${playlist.ownerUsername}`}
className="playlist-detail-owner"
>
@{playlist.ownerUsername}
</Link>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
<time dateTime={playlist.createdAt.toISOString()}>
{relativeTime(playlist.createdAt)}
</time>
</Tooltip>
{playlist.updatedAt && (
<Tooltip
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
>
<span className="playlist-edited-label">
<Trans>
edited {relativeTime(playlist.updatedAt)}
</Trans>
</span>
</Tooltip>
)}
</div>
</div>
</>
)} )}
</div> </div>
</div> </div>
@@ -687,277 +119,28 @@ export function PlaylistDetail(): React.JSX.Element {
<ErrorCard title={t`Something went wrong`} message={actionError} /> <ErrorCard title={t`Something went wrong`} message={actionError} />
)} )}
{visibleDumps.length === 0 <PlaylistDumpList
? ( dumps={playlist.dumps}
<p className="empty-state"> fading={fading}
<Trans>No dumps in this playlist yet.</Trans> {...(canEdit && {
</p> onRemove: remove,
) onUndoRemove: undoRemove,
: ( onMove: move,
<div onMoved: saveOrder,
className="playlist-dump-list" })}
onDragOver={canEdit ? (e) => e.preventDefault() : undefined} />
>
{visibleDumps.map((dump) => {
const isActive = activeDumpIds.has(dump.id);
const phase = fading[dump.id];
// drag index is within the active-only list
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
return (
<div
key={dump.id}
className={`playlist-dump-item${
activeIndex === dragOverIndex && isActive
? " playlist-dump-item--drag-over"
: ""
}`}
draggable={canEdit && isActive}
onDragStart={canEdit && isActive
? () => handleDragStart(activeIndex)
: undefined}
onDragOver={canEdit && isActive
? (e) => handleDragOver(e, activeIndex)
: undefined}
onDragEnd={canEdit ? handleDragEnd : undefined}
>
{canEdit && isActive && (
<span className="drag-handle" aria-hidden>⠿</span>
)}
<DumpCard
dump={dump}
voteCount={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
canVote={!!user}
castVote={castVote}
removeVote={removeVote}
className={fadeClassName(phase)}
isOwner={!!user && user.id === dump.userId}
/>
{canEdit && (isActive
? (
<button
type="button"
className="playlist-remove-btn"
onClick={() => handleRemoveDump(dump.id)}
aria-label={t`Remove from playlist`}
>
✕
</button>
)
: phase === "cooldown" && (
<button
type="button"
className="playlist-cancel-btn"
onClick={() => handleCancelRemove(dump.id)}
aria-label={t`Cancel removal`}
>
<Trans>Undo</Trans>
</button>
))}
</div>
);
})}
</div>
)}
{confirmDelete && ( {confirmDelete && (
<ConfirmModal <ConfirmModal
message={t`Delete this playlist? This cannot be undone.`} message={t`Delete this playlist? This cannot be undone.`}
confirmLabel={t`Delete playlist`} confirmLabel={t`Delete playlist`}
onConfirm={handleDelete} onConfirm={() => {
setConfirmDelete(false);
void deletePlaylist();
}}
onCancel={() => setConfirmDelete(false)} onCancel={() => setConfirmDelete(false)}
/> />
)} )}
</PageShell> </PageShell>
); );
} }
/** Passed to {@link PlaylistEditForm} by {@link PlaylistDetail}. */
interface PlaylistEditFormProps {
playlist: PlaylistWithDumps;
onCancel: () => void;
/** Opens the page's confirmation, which deletes the playlist. */
onRequestDelete: () => void;
/** Called with the playlist as the server now has it. */
onSaved: (playlist: Playlist) => void;
}
interface EditValues {
title: string;
description: string;
isPublic: boolean;
image: File | null;
}
/**
* Renders the form that edits the playlist, in place of the page's header. It
* saves the details with `PUT /api/playlists/:playlistId`, sending only the
* title and description that changed, then a new cover, if one was picked,
* with `POST /api/playlists/:playlistId/image`.
*/
function PlaylistEditForm(
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
) {
const { authFetch, mediaToken } = useAuth();
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Each preview holds the file in memory until its URL is revoked: when the
// next one replaces it, or when the form closes.
useEffect(() => {
if (!imagePreview) return;
return () => URL.revokeObjectURL(imagePreview);
}, [imagePreview]);
const form = useApiForm<EditValues>({
defaultValues: {
title: playlist.title,
description: playlist.description ?? "",
isPublic: playlist.isPublic,
image: null,
},
});
const description = form.watch("description");
const title = form.watch("title");
const onSubmit = form.submit(
async ({ title, description, isPublic, image }) => {
const updated = deserializePlaylist(
await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
...(title !== playlist.title ? { title } : {}),
...(description !== (playlist.description ?? "")
? { description: description || null }
: {}),
isPublic,
} satisfies UpdatePlaylistRequest,
),
}),
),
);
if (image) {
const fd = new FormData();
fd.append("file", image);
// The upload answers with the playlist as it now stands (new cover,
// new updatedAt), which is what the page should show.
onSaved(deserializePlaylist(
await expectOk<Wire<Playlist>>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
}),
),
));
return;
}
onSaved(updated);
},
);
return (
<FormProvider {...form}>
{
/* `display: contents` keeps the image-beside-content flex layout while
still giving us a real <form> for submit + Enter handling. */
}
<form style={{ display: "contents" }} onSubmit={onSubmit}>
<Controller
name="image"
control={form.control}
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? playlistImageUrl(playlist, mediaToken)
: null)}
alt="Cover"
size={72}
className="animate-in"
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)}
/>
<div className="playlist-detail-content playlist-edit-content animate-in">
<Controller
name="title"
control={form.control}
render={({ field }) => (
<CountedInput
className="playlist-edit-input"
value={field.value}
onChange={field.onChange}
onKeyDown={(e) => {
if (e.key === "Escape") onCancel();
}}
autoFocus
placeholder={t`Playlist title`}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="playlist-edit-textarea"
value={field.value}
onChange={field.onChange}
onSubmit={() => void onSubmit()}
onCancel={onCancel}
placeholder={t`Description (optional)`}
autoResize
rows={2}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)}
/>
<Controller
name="isPublic"
control={form.control}
render={({ field }) => (
<VisibilitySegmented
className="playlist-edit-toggle"
value={field.value}
onChange={field.onChange}
/>
)}
/>
<FormError title={t`Failed to save`} />
<div className="form-actions">
<button
type="button"
className="btn-danger"
onClick={onRequestDelete}
>
<Trans>Delete</Trans>
</button>
<div className="form-actions-right">
<button type="button" className="form-cancel" onClick={onCancel}>
<Trans>Cancel</Trans>
</button>
<SubmitButton
pendingLabel={<Trans>Saving…</Trans>}
disabled={!title.trim() ||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</div>
</form>
</FormProvider>
);
}

View File

@@ -5,7 +5,7 @@
* @module * @module
*/ */
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback } from "react";
import { Link, useSearchParams } from "react-router"; import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
@@ -28,30 +28,32 @@ import type { Dump } from "../../shared/dump.ts";
import type { Playlist } from "../../shared/playlist.ts"; import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts"; import type { PublicUser } from "../../shared/user.ts";
import type { Wire } from "../../shared/api.ts"; import type { Wire } from "../../shared/api.ts";
import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts"; import type { SearchResults } from "../../shared/search.ts";
import { friendlyFetchError } from "../utils/shared/api.ts"; import { SEARCH_URL } from "../config/api.ts";
import { expectOk } from "../utils/shared/api.ts";
import {
canLoadMore,
firstPage,
type PagedList,
pageUrl,
} from "../utils/feed/pagedList.ts";
import { useFetchState } from "../hooks/shared/useFetchState.ts";
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
const SEARCH_TABS = ["dumps", "users", "playlists"] as const; const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
type Tab = (typeof SEARCH_TABS)[number]; type Tab = (typeof SEARCH_TABS)[number];
// Only the dumps are paged: the API answers at most 20 users and playlists. /**
type SearchState = * Held by `Search` for a query, from the first page of `SearchResults`. The
| { status: "idle" } * dumps take the next pages.
| { status: "loading" } */
| { status: "error"; error: string } interface Results {
| { dumps: PagedList<Dump>;
status: "loaded"; /** It counts every match, which the pages loaded so far may not reach. */
q: string; dumpTotal: number;
dumps: { users: PublicUser[];
items: Dump[]; playlists: Playlist[];
total: number; }
hasMore: boolean;
page: number;
loadingMore: boolean;
};
users: PublicUser[];
playlists: Playlist[];
};
/** /**
* Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come * Renders `/search?q=` and `/search/~/:searchTab`, for anyone. The results come
@@ -65,131 +67,49 @@ export function Search(): React.JSX.Element {
const q = searchParams.get("q") ?? ""; const q = searchParams.get("q") ?? "";
const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps"); const [tab, setTab] = useTabParam<Tab>(SEARCH_TABS, "dumps");
const { token } = useAuth(); const { user, authFetch } = useAuth();
const search = useCallback(async (page: number, signal?: AbortSignal) => {
const url = new URL(SEARCH_URL, location.origin);
url.searchParams.set("q", q);
return expectOk<Wire<SearchResults>>(
await authFetch(pageUrl(url.toString(), page), { signal }),
);
}, [authFetch, q]);
const [state, setState] = useState<SearchState>({ status: "idle" }); const { state, setData } = useFetchState<Results>(
const abortRef = useRef<AbortController | null>(null); q.trim() ? `${q}:${user?.id ?? "guest"}` : undefined,
(signal) =>
const fetchSearch = useCallback(async (query: string, page: number) => { search(1, signal).then(({ dumps, users, playlists }) => ({
// Deferred: the set-state-in-effect lint rule forbids setting state dumps: firstPage(dumps.items.map(deserializeDump), dumps.hasMore),
// synchronously in the effect that calls this. dumpTotal: dumps.total ?? 0,
if (!query.trim()) { users: users.map(deserializePublicUser),
queueMicrotask(() => setState({ status: "idle" })); playlists: playlists.map(deserializePlaylist),
return; })),
}
if (page === 1) {
queueMicrotask(() => setState({ status: "loading" }));
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
const url = new URL(SEARCH_URL, location.origin);
url.searchParams.set("q", query);
url.searchParams.set("page", String(page));
url.searchParams.set("limit", String(DEFAULT_PAGE_SIZE));
const res = await fetch(url.toString(), {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? `HTTP ${res.status}`);
}
const body = await res.json() as {
success: true;
data: {
dumps: { items: Wire<Dump>[]; total: number; hasMore: boolean };
users: Wire<PublicUser>[];
playlists: Wire<Playlist>[];
};
};
const { data } = body;
if (page === 1) {
setState({
status: "loaded",
q: query,
dumps: {
items: data.dumps.items.map(deserializeDump),
total: data.dumps.total,
hasMore: data.dumps.hasMore,
page: 1,
loadingMore: false,
},
users: data.users.map(deserializePublicUser),
playlists: data.playlists.map(deserializePlaylist),
});
} else {
setState((prev) => {
if (prev.status !== "loaded") return prev;
return {
...prev,
dumps: {
...prev.dumps,
items: [
...prev.dumps.items,
...data.dumps.items.map(deserializeDump),
],
hasMore: data.dumps.hasMore,
page,
loadingMore: false,
},
};
});
}
} catch (err) {
if ((err as Error).name === "AbortError") return;
// A later page that fails leaves the results already shown; scrolling
// back to the end tries it again.
if (page > 1) {
setState((prev) =>
prev.status === "loaded"
? { ...prev, dumps: { ...prev.dumps, loadingMore: false } }
: prev
);
return;
}
setState({ status: "error", error: friendlyFetchError(err) });
}
}, [token]);
useEffect(() => {
// The lint rule flags the call itself, whatever fetchSearch defers inside.
queueMicrotask(() => fetchSearch(q, 1));
return () => abortRef.current?.abort();
}, [q, fetchSearch]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.dumps.hasMore ||
state.dumps.loadingMore
) return;
setState((prev) => {
if (prev.status !== "loaded") return prev;
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
});
fetchSearch(q, state.dumps.page + 1);
}, [state, q, fetchSearch]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore &&
!state.dumps.loadingMore,
); );
const dumpCount = state.status === "loaded" ? state.dumps.total : null; const dumps = state.status === "loaded" ? state.data.dumps : null;
const userCount = state.status === "loaded" ? state.users.length : null; const loadMore = useLoadMore(
const playlistCount = state.status === "loaded" dumps,
? state.playlists.length useCallback(
: null; (fn: (dumps: PagedList<Dump>) => PagedList<Dump>) =>
setData((d) => ({ ...d, dumps: fn(d.dumps) })),
[setData],
),
(page) =>
search(page).then(({ dumps }) => ({
items: dumps.items.map(deserializeDump),
hasMore: dumps.hasMore,
})),
);
const sentinelRef = useInfiniteScroll(
loadMore,
tab === "dumps" && canLoadMore(dumps),
);
const results = state.status === "loaded" ? state.data : null;
const dumpCount = results ? results.dumpTotal : null;
const userCount = results ? results.users.length : null;
const playlistCount = results ? results.playlists.length : null;
function tabLabel(tab: Tab, count: number | null) { function tabLabel(tab: Tab, count: number | null) {
const label = tab === "dumps" const label = tab === "dumps"
@@ -224,13 +144,13 @@ export function Search(): React.JSX.Element {
/> />
)} )}
{state.status === "idle" && ( {!q.trim() && (
<p className="search-page-empty"> <p className="search-page-empty">
<Trans>Enter a query to search.</Trans> <Trans>Enter a query to search.</Trans>
</p> </p>
)} )}
{state.status === "loading" && ( {q.trim() && state.status === "loading" && (
<p className="search-page-empty"> <p className="search-page-empty">
<Trans>Searching…</Trans> <Trans>Searching…</Trans>
</p> </p>
@@ -240,29 +160,27 @@ export function Search(): React.JSX.Element {
<ErrorCard title={t`Search failed`} message={state.error} /> <ErrorCard title={t`Search failed`} message={state.error} />
)} )}
{state.status === "loaded" && tab === "dumps" && ( {results && tab === "dumps" && (
<> results.dumps.items.length === 0
{state.dumps.items.length === 0 ? <p className="search-page-empty">{t`No dumps match "${q}".`}</p>
? <p className="search-page-empty">{t`No dumps match "${q}".`}</p> : (
: ( <>
<> <DumpCardList dumps={results.dumps.items} />
<DumpCardList dumps={state.dumps.items} /> <FeedFooter
<FeedFooter hasMore={results.dumps.hasMore}
hasMore={state.dumps.hasMore} loadingMore={results.dumps.loadingMore}
loadingMore={state.dumps.loadingMore} sentinelRef={sentinelRef}
sentinelRef={sentinelRef} />
/> </>
</> )
)}
</>
)} )}
{state.status === "loaded" && tab === "users" && ( {results && tab === "users" && (
state.users.length === 0 results.users.length === 0
? <p className="search-page-empty">{t`No users match "${q}".`}</p> ? <p className="search-page-empty">{t`No users match "${q}".`}</p>
: ( : (
<ul className="user-results"> <ul className="user-results">
{state.users.map((u) => ( {results.users.map((u) => (
<li key={u.id}> <li key={u.id}>
<Link <Link
to={`/users/${u.username}`} to={`/users/${u.username}`}
@@ -281,14 +199,14 @@ export function Search(): React.JSX.Element {
) )
)} )}
{state.status === "loaded" && tab === "playlists" && ( {results && tab === "playlists" && (
state.playlists.length === 0 results.playlists.length === 0
? ( ? (
<p className="search-page-empty"> <p className="search-page-empty">
{t`No playlists match "${q}".`} {t`No playlists match "${q}".`}
</p> </p>
) )
: <PlaylistCardList playlists={state.playlists} /> : <PlaylistCardList playlists={results.playlists} />
)} )}
</main> </main>
</div> </div>

View File

@@ -44,7 +44,7 @@ export function UserDumps(): React.JSX.Element {
const profileUserId = state.status === "loaded" ? state.profileUser.id : null; const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId; const isOwnProfile = me?.id === profileUserId;
const dumpItems = state.status === "loaded" ? state.items : []; const dumpItems = state.status === "loaded" ? state.dumps.items : [];
usePositionAwareSync( usePositionAwareSync(
dumpItems, dumpItems,
setItems, setItems,
@@ -78,7 +78,7 @@ export function UserDumps(): React.JSX.Element {
); );
} }
const { profileUser, items: dumps, hasMore, loadingMore } = state; const { profileUser, dumps: { items: dumps, hasMore, loadingMore } } = state;
return ( return (
<PageShell> <PageShell>

View File

@@ -10,9 +10,8 @@ import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import { expectOk, friendlyFetchError } from "../utils/shared/api.ts"; import { expectOk, friendlyFetchError } from "../utils/shared/api.ts";
import type { PaginatedData, Wire } from "../../shared/api.ts";
import type { Playlist } from "../../shared/playlist.ts"; import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts"; import type { PublicUser } from "../../shared/user.ts";
import { import {
@@ -31,11 +30,16 @@ import {
useScrollSave, useScrollSave,
} from "../hooks/feed/useScrollSave.ts"; } from "../hooks/feed/useScrollSave.ts";
import { import {
fetchFirstPage, canLoadMore,
mapItems,
type PagedList, type PagedList,
pageUrl,
readFirstPage,
readPage,
restoredList, restoredList,
withFirst, withFirst,
} from "../utils/feed/pagedList.ts"; } from "../utils/feed/pagedList.ts";
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts"; import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx";
@@ -67,7 +71,7 @@ type State =
*/ */
export function UserPlaylists(): React.JSX.Element { export function UserPlaylists(): React.JSX.Element {
const { username } = useParams(); const { username } = useParams();
const { user: me, authFetch, token } = useAuth(); const { user: me, authFetch } = useAuth();
useDocumentTitle(username ?? null); useDocumentTitle(username ?? null);
const { lastPlaylistEvent } = useWS(); const { lastPlaylistEvent } = useWS();
@@ -96,13 +100,31 @@ export function UserPlaylists(): React.JSX.Element {
const profileUserId = state.status === "loaded" ? state.profileUser.id : null; const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId; const isOwnProfile = me?.id === profileUserId;
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => { type SetList = (
setState((s) => fn: (list: PagedList<Playlist>) => PagedList<Playlist>,
s.status !== "loaded" ) => void;
? s const setCreatedList = useCallback<SetList>(
: { ...s, created: { ...s.created, items: fn(s.created.items) } } (fn) =>
); setState((s) =>
}, []); s.status !== "loaded" ? s : { ...s, created: fn(s.created) }
),
[],
);
const setFollowedList = useCallback<SetList>(
(fn) =>
setState((s) =>
s.status !== "loaded" ? s : { ...s, followed: fn(s.followed) }
),
[],
);
const setCreated = useCallback(
(fn: (prev: Playlist[]) => Playlist[]) => setCreatedList(mapItems(fn)),
[setCreatedList],
);
const setFollowed = useCallback(
(fn: (prev: Playlist[]) => Playlist[]) => setFollowedList(mapItems(fn)),
[setFollowedList],
);
const createdItems = state.status === "loaded" ? state.created.items : []; const createdItems = state.status === "loaded" ? state.created.items : [];
const lastPlaylistItem = lastPlaylistEvent?.type === "updated" const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
? (lastPlaylistEvent.playlist ?? null) ? (lastPlaylistEvent.playlist ?? null)
@@ -120,35 +142,29 @@ export function UserPlaylists(): React.JSX.Element {
ownerId: profileUserId ?? undefined, ownerId: profileUserId ?? undefined,
}); });
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
setState((s) =>
s.status !== "loaded"
? s
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
);
}, []);
usePlaylistListSync(setFollowed, { noNewEntries: true }); usePlaylistListSync(setFollowed, { noNewEntries: true });
const createdUrl = `${API_URL}/api/users/${username}/playlists`;
const followedUrl = `${API_URL}/api/users/${username}/followed-playlists`;
const fetchPage = (url: string) => (page: number) =>
authFetch(pageUrl(url, page))
.then((res) => readPage(res, deserializePlaylist));
useEffect(() => { useEffect(() => {
if (!username) return; if (!username) return;
const controller = new AbortController(); const controller = new AbortController();
const { signal } = controller; const { signal } = controller;
const firstPageOf = (url: string) =>
authFetch(pageUrl(url, 1), { signal })
.then((res) => readFirstPage(res, deserializePlaylist));
// Back on the page, the lists come from the cache and only the user is // Back on the page, the lists come from the cache and only the user is
// fetched again. // fetched again.
const cached = cachedCreated && cachedFollowed; const cached = cachedCreated && cachedFollowed;
Promise.all([ Promise.all([
fetchPublicUser(username, signal), fetchPublicUser(username, signal),
cached ? restoredList(cachedCreated) : fetchFirstPage( cached ? restoredList(cachedCreated) : firstPageOf(createdUrl),
`${API_URL}/api/users/${username}/playlists`, cached ? restoredList(cachedFollowed) : firstPageOf(followedUrl),
deserializePlaylist,
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal },
),
cached ? restoredList(cachedFollowed) : fetchFirstPage(
`${API_URL}/api/users/${username}/followed-playlists`,
deserializePlaylist,
{ signal },
),
]) ])
.then(([profileUser, created, followed]) => .then(([profileUser, created, followed]) =>
setState({ status: "loaded", profileUser, created, followed }) setState({ status: "loaded", profileUser, created, followed })
@@ -158,114 +174,32 @@ export function UserPlaylists(): React.JSX.Element {
setState({ status: "error", error: friendlyFetchError(err) }); setState({ status: "error", error: friendlyFetchError(err) });
}); });
return () => controller.abort(); return () => controller.abort();
}, [username, cachedCreated, cachedFollowed, token]); }, [
username,
const loadMoreCreated = useCallback(() => { cachedCreated,
if ( cachedFollowed,
state.status !== "loaded" || !state.created.hasMore || authFetch,
state.created.loadingMore || !username createdUrl,
) return; followedUrl,
const nextPage = state.created.page + 1; ]);
setState((s) =>
s.status === "loaded"
? { ...s, created: { ...s.created, loadingMore: true } }
: s
);
fetch(
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
.then(({ items, hasMore }) => {
setState((s) =>
s.status === "loaded"
? {
...s,
created: {
items: [...s.created.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded"
? { ...s, created: { ...s.created, loadingMore: false } }
: s
)
);
}, [state, username, token]);
const loadMoreFollowed = useCallback(() => {
if (
state.status !== "loaded" || !state.followed.hasMore ||
state.followed.loadingMore || !username
) return;
const nextPage = state.followed.page + 1;
setState((s) =>
s.status === "loaded"
? { ...s, followed: { ...s.followed, loadingMore: true } }
: s
);
fetch(
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
)
.then((res) => expectOk<PaginatedData<Wire<Playlist>>>(res))
.then(({ items, hasMore }) => {
setState((s) =>
s.status === "loaded"
? {
...s,
followed: {
items: [...s.followed.items, ...items.map(deserializePlaylist)],
hasMore,
page: nextPage,
loadingMore: false,
},
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded"
? { ...s, followed: { ...s.followed, loadingMore: false } }
: s
)
);
}, [state, username]);
const createdList = state.status === "loaded" ? state.created : null;
const followedList = state.status === "loaded" ? state.followed : null;
const createdSentinelRef = useInfiniteScroll( const createdSentinelRef = useInfiniteScroll(
loadMoreCreated, useLoadMore(createdList, setCreatedList, fetchPage(createdUrl)),
state.status === "loaded" && state.created.hasMore && canLoadMore(createdList),
!state.created.loadingMore,
); );
const followedSentinelRef = useInfiniteScroll( const followedSentinelRef = useInfiniteScroll(
loadMoreFollowed, useLoadMore(followedList, setFollowedList, fetchPage(followedUrl)),
state.status === "loaded" && state.followed.hasMore && canLoadMore(followedList),
!state.followed.loadingMore,
); );
useScrollSave( useScrollSave(
state.status === "loaded", state.status === "loaded",
useCallback((y) => { useCallback((y) => {
if (state.status !== "loaded") return; if (state.status !== "loaded") return;
saveCreated( saveCreated(state.created, y);
state.created.items, saveFollowed(state.followed, y);
state.created.page,
state.created.hasMore,
y,
);
saveFollowed(
state.followed.items,
state.followed.page,
state.followed.hasMore,
y,
);
}, [state, saveCreated, saveFollowed]), }, [state, saveCreated, saveFollowed]),
); );

View File

@@ -5,20 +5,27 @@
* @module * @module
*/ */
import { useCallback } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { Trans } from "@lingui/react/macro"; import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL } from "../config/api.ts";
import type { Invitee, PublicUser } from "../../shared/user.ts"; import type { Invitee, PublicUser } from "../../shared/user.ts";
import type { PaginatedData, Wire } from "../../shared/api.ts"; import type { Wire } from "../../shared/api.ts";
import { import {
deserializeInvitee, deserializeInvitee,
deserializePlaylist, deserializePlaylist,
deserializePublicUser, deserializePublicUser,
} from "../utils/shared/deserialize.ts"; } from "../utils/shared/deserialize.ts";
import { dataOr, expectOk } from "../utils/shared/api.ts"; import { dataOr } from "../utils/shared/api.ts";
import { can } from "../utils/auth/permissions.ts"; import { can } from "../utils/auth/permissions.ts";
import { fetchFirstPage } from "../utils/feed/pagedList.ts"; import {
type PagedList,
pageUrl,
readFirstPage,
readPage,
} from "../utils/feed/pagedList.ts";
import { useLoadMore } from "../hooks/feed/usePagedList.ts";
import { useAuth } from "../hooks/auth/useAuth.ts"; import { useAuth } from "../hooks/auth/useAuth.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { useFetchState } from "../hooks/shared/useFetchState.ts"; import { useFetchState } from "../hooks/shared/useFetchState.ts";
@@ -92,22 +99,19 @@ export function UserPublicProfile(): React.JSX.Element {
const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps"); const [tab, setTab] = useTabParam<ProfileTab>(availableTabs, "dumps");
const loaded = state.status === "loaded"; const loaded = state.status === "loaded";
const followedUsersUrl = `${API_URL}/api/users/${username}/followed-users`;
const followed = useFetchState<ProfileFollowed>( const followed = useFetchState<ProfileFollowed>(
username, username,
(signal) => (signal) =>
Promise.all([ Promise.all([
fetchFirstPage( fetch(pageUrl(followedUsersUrl, 1), { signal })
`${API_URL}/api/users/${username}/followed-users`, .then((res) => readFirstPage(res, deserializePublicUser)),
deserializePublicUser, fetch(
pageUrl(`${API_URL}/api/users/${username}/followed-playlists`, 1),
{ signal }, { signal },
), ).then((res) => readFirstPage(res, deserializePlaylist)),
fetchFirstPage(
`${API_URL}/api/users/${username}/followed-playlists`,
deserializePlaylist,
{ signal },
),
]).then(([users, playlists]) => ({ users, playlists })), ]).then(([users, playlists]) => ({ users, playlists })),
loaded && tab === "followed", { enabled: loaded && tab === "followed" },
); );
const invitees = useFetchState<Invitee[]>( const invitees = useFetchState<Invitee[]>(
username, username,
@@ -115,33 +119,21 @@ export function UserPublicProfile(): React.JSX.Element {
fetch(`${API_URL}/api/users/${username}/invitees`, { signal }) fetch(`${API_URL}/api/users/${username}/invitees`, { signal })
.then((res) => dataOr<Wire<Invitee>[]>(res, [])) .then((res) => dataOr<Wire<Invitee>[]>(res, []))
.then((entries) => entries.map(deserializeInvitee)), .then((entries) => entries.map(deserializeInvitee)),
loaded && tab === "invitees", { enabled: loaded && tab === "invitees" },
); );
const loadMoreFollowedUsers = () => { const setFollowed = followed.setData;
if ( const loadMoreFollowedUsers = useLoadMore(
followed.state.status !== "loaded" || followed.state.status === "loaded" ? followed.state.data.users : null,
followed.state.data.users.loadingMore useCallback(
) return; (fn: (users: PagedList<PublicUser>) => PagedList<PublicUser>) =>
const nextPage = followed.state.data.users.page + 1; setFollowed((d) => ({ ...d, users: fn(d.users) })),
const setUsers = ( [setFollowed],
fn: (u: ProfileFollowed["users"]) => ProfileFollowed["users"], ),
) => followed.setData((d) => ({ ...d, users: fn(d.users) })); (page) =>
setUsers((u) => ({ ...u, loadingMore: true })); fetch(pageUrl(followedUsersUrl, page))
fetch( .then((res) => readPage(res, deserializePublicUser)),
`${API_URL}/api/users/${username}/followed-users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, );
)
.then(expectOk<PaginatedData<Wire<PublicUser>>>)
.then(({ items, hasMore }) =>
setUsers((u) => ({
items: [...u.items, ...items.map(deserializePublicUser)],
hasMore,
page: nextPage,
loadingMore: false,
}))
)
.catch(() => setUsers((u) => ({ ...u, loadingMore: false })));
};
if (state.status === "loading") { if (state.status === "loading") {
return ( return (

View File

@@ -64,7 +64,7 @@ export function UserUpvoted(): React.JSX.Element {
); );
} }
const { profileUser, items: votes, hasMore, loadingMore } = state; const { profileUser, dumps: { items: votes, hasMore, loadingMore } } = state;
return ( return (
<PageShell> <PageShell>

View File

@@ -53,7 +53,7 @@ describe("UserListPopover", () => {
first.unmount(); first.unmount();
route("GET /api/x/voters", () => new TypeError("offline")); route("GET /api/x/voters", () => new TypeError("offline"));
renderWithContexts(<Harness />); renderWithContexts(<Harness />);
await screen.findByText("Could not load."); await screen.findByText(/Could not connect to the server/);
}); });
test("a failed next page lets you try again", async () => { test("a failed next page lets you try again", async () => {

View File

@@ -46,8 +46,8 @@ describe("useUserDumpFeed", () => {
{ status: "loaded" } { status: "loaded" }
>; >;
expect(s.profileUser.username).toBe("ann"); expect(s.profileUser.username).toBe("ann");
expect(s.items[0].createdAt).toBeInstanceOf(Date); expect(s.dumps.items[0].createdAt).toBeInstanceOf(Date);
expect(s.hasMore).toBe(true); expect(s.dumps.hasMore).toBe(true);
const [req] = calls("GET /api/users/:u/dumps"); const [req] = calls("GET /api/users/:u/dumps");
expect(req.url.searchParams.get("page")).toBe("1"); expect(req.url.searchParams.get("page")).toBe("1");
expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`); expect(req.headers.get("Authorization")).toBe(`Bearer ${session.token}`);
@@ -91,7 +91,7 @@ describe("useUserDumpFeed", () => {
act(() => obs.report(true)); act(() => obs.report(true));
await waitFor(() => { await waitFor(() => {
const s = result.current.state; const s = result.current.state;
expect(s.status === "loaded" && s.items.map((d) => d.id)).toEqual([ expect(s.status === "loaded" && s.dumps.items.map((d) => d.id)).toEqual([
"a", "a",
"b", "b",
]); ]);
@@ -100,8 +100,8 @@ describe("useUserDumpFeed", () => {
typeof result.current.state, typeof result.current.state,
{ status: "loaded" } { status: "loaded" }
>; >;
expect(s.hasMore).toBe(false); expect(s.dumps.hasMore).toBe(false);
expect(s.page).toBe(2); expect(s.dumps.page).toBe(2);
}); });
test("changing user resets to loading", async () => { test("changing user resets to loading", async () => {
@@ -130,6 +130,6 @@ describe("useUserDumpFeed", () => {
typeof result.current.state, typeof result.current.state,
{ status: "loaded" } { status: "loaded" }
>; >;
expect(s.items.map((d) => d.id)).toEqual(["b"]); expect(s.dumps.items.map((d) => d.id)).toEqual(["b"]);
}); });
}); });

View File

@@ -22,7 +22,13 @@ describe("useFeedCache", () => {
const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), { const first = renderHook(() => useFeedCache<{ n: number }>("k", (x) => x), {
wrapper: wrapper(), wrapper: wrapper(),
}); });
act(() => first.result.current.saveState([{ n: 1 }], 2, true, 300)); act(() =>
first.result.current.saveState({
items: [{ n: 1 }],
page: 2,
hasMore: true,
}, 300)
);
const second = renderHook( const second = renderHook(
() => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })), () => useFeedCache<{ n: number }>("k", (x) => ({ n: x.n * 10 })),
{ wrapper: wrapper() }, { wrapper: wrapper() },
@@ -40,7 +46,9 @@ describe("useFeedCache", () => {
const first = renderHook(() => useFeedCache("k", (x) => x), { const first = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(), wrapper: wrapper(),
}); });
act(() => first.result.current.saveState([], 1, false, 0)); act(() =>
first.result.current.saveState({ items: [], page: 1, hasMore: false }, 0)
);
vi.advanceTimersByTime(11 * 60 * 1000); vi.advanceTimersByTime(11 * 60 * 1000);
const second = renderHook(() => useFeedCache("k", (x) => x), { const second = renderHook(() => useFeedCache("k", (x) => x), {
wrapper: wrapper(), wrapper: wrapper(),
@@ -92,6 +100,8 @@ describe("useFeedCache", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("QuotaExceededError"); throw new Error("QuotaExceededError");
}); });
expect(() => result.current.saveState([], 1, false, 0)).not.toThrow(); expect(() =>
result.current.saveState({ items: [], page: 1, hasMore: false }, 0)
).not.toThrow();
}); });
}); });

View File

@@ -0,0 +1,64 @@
import { describe, expect, test, vi } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { usePagedList } from "../../../hooks/feed/usePagedList.ts";
import { deferred } from "../../fetch.ts";
describe("usePagedList", () => {
test("loads the first page, then appends the next ones", async () => {
const fetchPage = vi.fn((page: number) =>
Promise.resolve({ items: [page], hasMore: page < 2 })
);
const { result } = renderHook(() => usePagedList("k", fetchPage));
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.loadMore());
await waitFor(() =>
expect(result.current.state).toEqual({
status: "loaded",
data: { items: [1, 2], hasMore: false, page: 2, loadingMore: false },
})
);
act(() => result.current.loadMore());
expect(fetchPage).toHaveBeenCalledTimes(2);
});
test("loads one next page at a time, and a failed one can be tried again", async () => {
const next = deferred<{ items: number[]; hasMore: boolean }>();
const fetchPage = vi.fn((page: number) =>
page === 1 ? Promise.resolve({ items: [1], hasMore: true }) : next.promise
);
const { result } = renderHook(() => usePagedList("k", fetchPage));
await waitFor(() => expect(result.current.state.status).toBe("loaded"));
act(() => result.current.loadMore());
act(() => result.current.loadMore());
expect(fetchPage).toHaveBeenCalledTimes(2);
await act(async () => {
next.reject(new Error("offline"));
await next.promise.catch(() => {});
});
expect(result.current.state).toMatchObject({
data: { items: [1], page: 1, loadingMore: false },
});
});
test("a cached list shows as it was, and carries on from its page", async () => {
const fetchPage = vi.fn((page: number) =>
Promise.resolve({ items: [page], hasMore: false })
);
const { result } = renderHook(() =>
usePagedList("k", fetchPage, {
items: [1, 2],
page: 2,
hasMore: true,
scrollY: 0,
})
);
expect(result.current.state).toMatchObject({ data: { items: [1, 2] } });
act(() => result.current.loadMore());
await waitFor(() =>
expect(result.current.state).toMatchObject({
data: { items: [1, 2, 3] },
})
);
expect(fetchPage).toHaveBeenCalledTimes(1);
});
});

View File

@@ -7,7 +7,7 @@ describe("useFetchState", () => {
test("loads once enabled, and only once per key", async () => { test("loads once enabled, and only once per key", async () => {
const load = vi.fn(() => Promise.resolve(1)); const load = vi.fn(() => Promise.resolve(1));
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ enabled }) => useFetchState("a", load, enabled), ({ enabled }) => useFetchState("a", load, { enabled }),
{ initialProps: { enabled: false } }, { initialProps: { enabled: false } },
); );
expect(load).not.toHaveBeenCalled(); expect(load).not.toHaveBeenCalled();
@@ -25,7 +25,7 @@ describe("useFetchState", () => {
test("turning it off doesn't stop a load under way", async () => { test("turning it off doesn't stop a load under way", async () => {
const d = deferred<number>(); const d = deferred<number>();
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ enabled }) => useFetchState("a", () => d.promise, enabled), ({ enabled }) => useFetchState("a", () => d.promise, { enabled }),
{ initialProps: { enabled: true } }, { initialProps: { enabled: true } },
); );
rerender({ enabled: false }); rerender({ enabled: false });
@@ -79,4 +79,18 @@ describe("useFetchState", () => {
act(() => result.current.setData((xs) => [...xs, 2])); act(() => result.current.setData((xs) => [...xs, 2]));
expect(result.current.state).toEqual({ status: "loaded", data: [1, 2] }); expect(result.current.state).toEqual({ status: "loaded", data: [1, 2] });
}); });
test("an initial value starts loaded and loads nothing, for its key only", async () => {
const load = vi.fn(() => Promise.resolve(2));
const { result, rerender } = renderHook(
({ key, initial }) => useFetchState(key, load, { initial }),
{ initialProps: { key: "a", initial: 1 as number | undefined } },
);
expect(result.current.state).toEqual({ status: "loaded", data: 1 });
expect(load).not.toHaveBeenCalled();
rerender({ key: "b", initial: undefined });
await waitFor(() =>
expect(result.current.state).toEqual({ status: "loaded", data: 2 })
);
});
}); });

View File

@@ -76,7 +76,7 @@ describe("PlaylistDetail", () => {
}); });
test("not found", async () => { test("not found", async () => {
route("GET /api/playlists/:id", () => fail("nope", 404)); route("GET /api/playlists/:id", () => fail("Playlist not found", 404));
renderApp(<PlaylistDetail />, { renderApp(<PlaylistDetail />, {
route: "/playlists/x", route: "/playlists/x",
path: "/playlists/:playlistId", path: "/playlists/:playlistId",
@@ -212,15 +212,13 @@ describe("PlaylistDetail", () => {
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
}); });
test("removing a dump fades it out, with an undo", async () => { test("a removal is sent once its fade ends, and an undo sends nothing", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true }); vi.useFakeTimers({ shouldAdvanceTime: true });
try { try {
route( route(
"DELETE /api/playlists/:pid/dumps/:did", "DELETE /api/playlists/:pid/dumps/:did",
() => new Response(null, { status: 204 }), () => new Response(null, { status: 204 }),
); );
route("POST /api/playlists/:pid/dumps/:did", () => ({}));
route("PUT /api/playlists/:pid/order", () => ({}));
const { user } = open(); const { user } = open();
await screen.findByText("Bravo"); await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest( const bravo = screen.getByText("Bravo").closest(
@@ -229,33 +227,30 @@ describe("PlaylistDetail", () => {
await user.click( await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }), within(bravo).getByRole("button", { name: "Remove from playlist" }),
); );
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
"/api/playlists/mix/dumps/b",
);
await user.click( await user.click(
within(bravo).getByRole("button", { name: "Cancel removal" }), within(bravo).getByRole("button", { name: "Cancel removal" }),
); );
await waitFor(() => act(() => vi.advanceTimersByTime(2_400));
expect(calls("PUT /api/playlists/:pid/order")).toHaveLength(1) expect(screen.getByText("Bravo")).toBeInTheDocument();
); expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(0);
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
dumpIds: ["a", "b", "c"],
});
await user.click( await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }), within(bravo).getByRole("button", { name: "Remove from playlist" }),
); );
act(() => vi.advanceTimersByTime(2_400)); act(() => vi.advanceTimersByTime(2_400));
expect(screen.queryByText("Bravo")).not.toBeInTheDocument(); expect(screen.queryByText("Bravo")).not.toBeInTheDocument();
expect(calls("DELETE /api/playlists/:pid/dumps/:did")[0].path).toBe(
"/api/playlists/p1/dumps/b",
);
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
test("a refused removal puts the dump back and says so", async () => { test("leaving the page during the fade still sends the removal", async () => {
route( route(
"DELETE /api/playlists/:pid/dumps/:did", "DELETE /api/playlists/:pid/dumps/:did",
() => fail("Forbidden", 403), () => new Response(null, { status: 204 }),
); );
const { user } = open(); const { user } = open();
await screen.findByText("Bravo"); await screen.findByText("Bravo");
@@ -265,14 +260,36 @@ describe("PlaylistDetail", () => {
await user.click( await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }), within(bravo).getByRole("button", { name: "Remove from playlist" }),
); );
expect(await screen.findByRole("alert")).toHaveTextContent( act(() => globalThis.dispatchEvent(new Event("pagehide")));
"couldn't be removed", expect(calls("DELETE /api/playlists/:pid/dumps/:did")).toHaveLength(1);
);
expect(within(bravo).getByRole("button", { name: "Remove from playlist" }))
.toBeInTheDocument();
}); });
test("dragging reorders, and a refused reorder reloads the real order", async () => { test("a refused removal puts the dump back and says so", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
route(
"DELETE /api/playlists/:pid/dumps/:did",
() => fail("Forbidden", 403),
);
const { user } = open();
await screen.findByText("Bravo");
const bravo = screen.getByText("Bravo").closest(
".playlist-dump-item",
) as HTMLElement;
await user.click(
within(bravo).getByRole("button", { name: "Remove from playlist" }),
);
act(() => vi.advanceTimersByTime(2_400));
expect(await screen.findByRole("alert")).toHaveTextContent(
"couldn't be removed",
);
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
} finally {
vi.useRealTimers();
}
});
test("dragging reorders, and a refused reorder goes back to the server's order", async () => {
route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403)); route("PUT /api/playlists/:pid/order", () => fail("Forbidden", 403));
open(); open();
await screen.findByText("Charlie"); await screen.findByText("Charlie");
@@ -292,12 +309,23 @@ describe("PlaylistDetail", () => {
expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({ expect(calls("PUT /api/playlists/:pid/order")[0].body).toEqual({
dumpIds: ["b", "c", "a"], dumpIds: ["b", "c", "a"],
}); });
await waitFor(() => expect(await screen.findByRole("alert")).toHaveTextContent(
expect(calls("GET /api/playlists/:id")).toHaveLength(2) "order couldn't be saved",
);
await waitFor(() =>
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"])
); );
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
});
test("a dump that goes private leaves, and comes back at its place", async () => {
const { socket } = open({ session: visitor });
await screen.findByText("Bravo");
socket.receive({ type: "dump_deleted", dumpId: "b" });
expect(titles()).toEqual(["Alpha", "Charlie"]);
socket.receive({
type: "dump_created",
dump: wire(makeDump({ id: "b", title: "Bravo" })),
});
expect(await screen.findByText("Bravo")).toBeInTheDocument();
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
}); });
test("deleting the playlist asks, then goes home", async () => { test("deleting the playlist asks, then goes home", async () => {

View File

@@ -9,6 +9,7 @@ import {
makeDump, makeDump,
makePlaylist, makePlaylist,
makeSession, makeSession,
makeToken,
makeUser, makeUser,
page, page,
wire, wire,
@@ -316,9 +317,10 @@ describe("UserPublicProfile", () => {
}); });
test("change password keeps you signed in with the fresh tokens", async () => { test("change password keeps you signed in with the fresh tokens", async () => {
const freshToken = makeToken({ sub: "ann" });
route( route(
"POST /api/users/me/change-password", "POST /api/users/me/change-password",
() => ({ token: "fresh-token", mediaToken: "fresh-media" }), () => ({ token: freshToken, mediaToken: "fresh-media" }),
); );
const { user } = open({ const { user } = open({
session: annSession, session: annSession,
@@ -353,7 +355,7 @@ describe("UserPublicProfile", () => {
expect(await within(dialog).findByText("Password changed successfully.")) expect(await within(dialog).findByText("Password changed successfully."))
.toBeInTheDocument(); .toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe( expect(JSON.parse(localStorage.getItem(AUTH_STORAGE_KEY)!).token).toBe(
"fresh-token", freshToken,
); );
expect(calls("POST /api/users/me/change-password")[0].body).toEqual({ expect(calls("POST /api/users/me/change-password")[0].body).toEqual({
currentPassword: "oldpassword", currentPassword: "oldpassword",

View File

@@ -1,8 +1,10 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { API_URL } from "../../../config/api.ts"; import { API_URL } from "../../../config/api.ts";
import { import {
fetchFirstPage,
firstPage, firstPage,
pageUrl,
readFirstPage,
readPage,
restoredList, restoredList,
withFirst, withFirst,
} from "../../../utils/feed/pagedList.ts"; } from "../../../utils/feed/pagedList.ts";
@@ -10,10 +12,15 @@ import { calls, fail, route } from "../../fetch.ts";
import { page } from "../../fixtures.ts"; import { page } from "../../fixtures.ts";
describe("pagedList", () => { describe("pagedList", () => {
test("fetchFirstPage asks for page 1 and deserializes it", async () => { test("pageUrl adds the page to a query, or starts one", () => {
expect(pageUrl("/a", 2, 5)).toBe("/a?page=2&limit=5");
expect(pageUrl("/a?c=x", 1, 5)).toBe("/a?c=x&page=1&limit=5");
});
test("readFirstPage deserializes the first page", async () => {
route("GET /api/things", () => page([{ id: "a" }], true)); route("GET /api/things", () => page([{ id: "a" }], true));
const list = await fetchFirstPage( const list = await readFirstPage(
`${API_URL}/api/things`, await fetch(pageUrl(`${API_URL}/api/things`, 1)),
(w: { id: string }) => ({ ...w, seen: true }), (w: { id: string }) => ({ ...w, seen: true }),
); );
expect(list).toEqual({ expect(list).toEqual({
@@ -25,13 +32,13 @@ describe("pagedList", () => {
expect(calls("GET /api/things")[0].url.searchParams.get("page")).toBe("1"); 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 () => { test("a refused page is an empty first page, and an error elsewhere", async () => {
route("GET /api/refused", () => fail("No", 403)); route("GET /api/refused", () => fail("No", 403));
expect(await fetchFirstPage(`${API_URL}/api/refused`, (w) => w)) expect(
.toEqual(firstPage([], false)); await readFirstPage(await fetch(`${API_URL}/api/refused`), (w) => w),
route("GET /api/lost", () => new TypeError("Failed to fetch")); ).toEqual(firstPage([], false));
await expect(fetchFirstPage(`${API_URL}/api/lost`, (w) => w)).rejects await expect(readPage(await fetch(`${API_URL}/api/refused`), (w) => w))
.toThrow(TypeError); .rejects.toThrow("No");
}); });
test("restoredList carries on from the cached page", () => { test("restoredList carries on from the cached page", () => {

View File

@@ -1,22 +1,24 @@
/** /**
* A list of which the first pages are loaded, as the pages holding several * Declares a list the API serves a page at a time, and reads its pages. Every
* such lists keep each one. * such list in the app is a {@link PagedList}, whose next page `useLoadMore`
* appends.
* *
* @module * @module
*/ */
import { DEFAULT_PAGE_SIZE } from "../../config/api.ts"; import { DEFAULT_PAGE_SIZE } from "../../config/api.ts";
import type { PaginatedData, Wire } from "../../../shared/api.ts"; import type { PaginatedData, Wire } from "../../../shared/api.ts";
import { dataOr } from "../shared/api.ts"; import { dataOr, expectOk } from "../shared/api.ts";
/** /**
* Held for each of its lists by `UserPlaylists` and the profile, whose lists * Held for a list by `usePagedList`, or by a page that loads several lists
* load together with the user and then a page at a time. * with their user, as `UserPlaylists` and the profile do. `useLoadMore` loads
* the pages after `page`.
*/ */
export interface PagedList<T> { export interface PagedList<T> {
items: T[]; items: T[];
hasMore: boolean; hasMore: boolean;
/** The last page loaded, from which loading more carries on. */ /** Loading more carries on from this page, the last loaded. */
page: number; page: number;
loadingMore: boolean; loadingMore: boolean;
} }
@@ -33,23 +35,63 @@ export function restoredList<T>(
return { items, hasMore, page, loadingMore: false }; return { items, hasMore, page, loadingMore: false };
} }
/**
* Checks whether `list` has a next page to load now, which `useLoadMore`
* requires and `useInfiniteScroll` takes as `enabled`. A list not loaded yet
* is null.
*/
export function canLoadMore(list: PagedList<unknown> | null): boolean {
return !!list && list.hasMore && !list.loadingMore;
}
/**
* Returns a setter of a list's items as a setter of the list, for the sync
* hooks, which edit items only.
*/
export function mapItems<T>(
fn: (items: T[]) => T[],
): (list: PagedList<T>) => PagedList<T> {
return (list) => ({ ...list, items: fn(list.items) });
}
/** Returns `url` asking for page `page`, `limit` items long. */
export function pageUrl(
url: string,
page: number,
limit = DEFAULT_PAGE_SIZE,
): string {
return `${url}${url.includes("?") ? "&" : "?"}page=${page}&limit=${limit}`;
}
/**
* Returns the page `res` answers, its items deserialized.
*
* @throws {Error} With the server's message when it refuses, and when the
* body isn't an envelope.
*/
export async function readPage<T>(
res: Response,
deserialize: (wire: Wire<T>) => T,
): Promise<PaginatedData<T>> {
const { items, hasMore } = await expectOk<PaginatedData<Wire<T>>>(res);
return { items: items.map(deserialize), hasMore };
}
const EMPTY_PAGE: PaginatedData<never> = { items: [], hasMore: false }; const EMPTY_PAGE: PaginatedData<never> = { items: [], hasMore: false };
/** /**
* Returns the first page of the list at `url`, which takes `page` and `limit` * Returns the first page `res` answers as a list, or an empty list when the
* in its query, or an empty list when the server refuses it. The profile pages * server refuses it. The profile pages read their lists with it beside the
* load their lists with it beside the user, whose failure alone fails the * user, whose failure alone fails the page.
* page.
* *
* @throws {Error} Offline, when aborted, and when the body isn't an envelope. * @throws {Error} When the body isn't an envelope.
*/ */
export async function fetchFirstPage<T>( export async function readFirstPage<T>(
url: string, res: Response,
deserialize: (wire: Wire<T>) => T, deserialize: (wire: Wire<T>) => T,
init?: RequestInit,
): Promise<PagedList<T>> { ): Promise<PagedList<T>> {
const { items, hasMore } = await dataOr<PaginatedData<Wire<T>>>( const { items, hasMore } = await dataOr<PaginatedData<Wire<T>>>(
await fetch(`${url}?page=1&limit=${DEFAULT_PAGE_SIZE}`, init), res,
EMPTY_PAGE, EMPTY_PAGE,
); );
return firstPage(items.map(deserialize), hasMore); return firstPage(items.map(deserialize), hasMore);

View File

@@ -49,9 +49,9 @@ export async function expectOk<T>(res: Response): Promise<T> {
/** /**
* Returns the payload of `res`, or `fallback` when the response says * Returns the payload of `res`, or `fallback` when the response says
* `success: false`. The profile pages read their side lists with it, as * `success: false`. `readFirstPage` reads the profile pages' lists with it, and
* `{ items: [], hasMore: false }` when refused, so that a list the server * the profile its invitees, so that a list the server refuses shows empty
* refuses shows empty rather than failing the page. * rather than failing the page.
* *
* @throws {Error} When the body isn't JSON or isn't an envelope. * @throws {Error} When the body isn't JSON or isn't an envelope.
*/ */

View File

@@ -1,6 +1,6 @@
/** /**
* The state of something a component loads from the API, which `ListState` * Declares the state of something a component loads from the API, which
* renders while it isn't loaded. * `ListState` renders while it isn't loaded.
* *
* @module * @module
*/ */

View File

@@ -7,7 +7,7 @@
const DUMP_KEY = "visited_dumps"; const DUMP_KEY = "visited_dumps";
const PLAYLIST_KEY = "visited_playlists"; const PLAYLIST_KEY = "visited_playlists";
/** The age past which an item shows no unread dot, visited or not. */ // Past this age, an item shows no unread dot, visited or not.
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
// Each key's visits, id to epoch ms, with the stored string they were parsed // Each key's visits, id to epoch ms, with the stored string they were parsed
@@ -23,7 +23,7 @@ function load(key: string): Map<string, number> {
try { try {
raw = localStorage.getItem(key); raw = localStorage.getItem(key);
} catch { } catch {
// Blocked storage: whatever this page recorded. // With storage blocked, only what this page recorded counts.
return cache.get(key)?.visits ?? new Map(); return cache.get(key)?.visits ?? new Map();
} }
const cached = cache.get(key); const cached = cache.get(key);
@@ -36,7 +36,7 @@ function load(key: string): Map<string, number> {
? new Map(parsed.map((id) => [id, Date.now()])) ? new Map(parsed.map((id) => [id, Date.now()]))
: new Map(Object.entries(parsed)); : new Map(Object.entries(parsed));
} catch { } catch {
// Unreadable: nothing visited. // An unreadable list counts as nothing visited.
} }
cache.set(key, { raw, visits }); cache.set(key, { raw, visits });
return visits; return visits;