Compare commits

..

8 Commits

Author SHA1 Message Date
khannurien
de223baaa4 v4: the playlists page is reachable at every width, and lists the user's own — below 1150px the header's text link was hidden and nothing replaced it: the avatar menu's "My playlists" led to the user's own, and a guest has no menu. The menu's entry is now "Playlists", to /playlists, at every width, and the page gains a tab after its orders, "Mine", for a signed-in user: their own playlists, private ones too, from GET /api/users/:id/playlists in the default order, narrowed by the category menu and kept live as the others are. It is /playlists?mine=1 (PLAYLISTS_TABS, parsePlaylistsTab, shared/paths.ts), a tab taking the order's place; a guest isn't offered it, and its link sends one to sign in. PlaylistSortTabs takes the tabs it shows, a profile's being the orders alone. The text link shows to a guest at every width, their header giving up its margins under 400px rather than the link, and to a signed-in user where the row has room beside the icons, as measured in every style and both languages: from 560px up to 860px, where the index page's tabs move into the header, and from 1150px as before. A guest in French in the kanagawa style still overflows the header under 340px, by up to 19px.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:14:55 +00:00
khannurien
561cdf98b0 v4: playlists sort by their last dump, their creation or their size, and /playlists is a grid of covers — every list of playlists came newest first, so one fed every week sank under those made since and left empty. GET /api/playlists and /api/users/:key/playlists take ?sort=updated|new|dumps (PLAYLIST_SORTS, shared/playlist.ts), updated being the default: the last to take a dump first, or its creation while it holds none; a new title, cover or description doesn't bring it back up. A playlist carries that date as lastDumpAt, read with its dumpCount by isMemberVisibleTo's rule (VISIBLE_DUMP_FIGURES, the viewer's id as ?1), so the owner's private dumps date and count their playlists for them alone, in the lists and over the socket: playlist_created, playlist_updated and playlist_dumps_updated carry each socket's own. The /playlists page and a profile's playlists tab show the orders as tabs beside the category menu and keep the one picked in ?sort=, the default being the bare path; a tab takes the current history entry's place, and picking a category keeps the order. Live, usePlaylistListSync takes the list's order: a new playlist goes where it puts it, one that rises moves up to its place, as in the updated order when it takes a dump, and one that falls stays until the list is fetched again, its place being perhaps on a page not loaded. The lists in an order of their own, the followed playlists and the search's, pass none, and take no new playlist as before (noNewEntries goes). comparePlaylists is the order on the client, which a test holds to the API's.
/playlists lays its cards out as tiles, the cover across the top, four to a row and two on a phone, on the wide column: the same PlaylistCard, laid out by its list's class (.playlist-grid), so each theme's card skin carries over. It drops the "public" badge there, every playlist listed being public. On every card, a playlist without a cover shows a tile of its own colour with its initial (ThumbnailPlaceholder) in place of the 📋, and the date is "updated …" of its last dump, or "created …".

A middle, ctrl, cmd or shift click anywhere on a playlist card opens it in a new tab — only its title was a link, the rest navigating by a click handler. useCardLink (hooks/shared) is useDumpCardLink's handlers for any card, which the dump cards now get through it.

A profile tab's query stays with the tab — going from the playlists to the dumps kept the whole query, so a playlist category, and now ?sort=, landed on the dumps' URL. Only the dump lists pass their category to each other; any other change of tab drops the query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:51:37 +00:00
khannurien
350e945eb1 v4: a new link between two dumps shows among their related dumps at once — it showed on the next load of the page: the backlink service recorded the link without telling anyone, and the dump page fetched its related dumps once, the socket then only editing or removing the ones it had. relinkBacklinks now sends dump_related_changed when a note or a comment gains or loses a link, naming the dump and those at the other end, and the page showing one of them fetches /related again. A link shows to those who may see both its ends, so each socket is told only of the dumps its user may see, and not at all without one at each end. A private dump that turns public sends it too, for the links it already had, which are new to everyone but its poster. The page also fetches its related dumps again on a reconnection, as it does its comments and playlists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:43:55 +00:00
khannurien
dde0232acb v4: a link from one dump's page to another's opens that dump — it reloaded the one on screen: the page stays mounted when its path changes, and for one render the old dump was still loaded under the new path, so useCanonicalPath took that path for a former one and put the old dump's back, which was then fetched again. A related dump's link did it, and so did any dump link in a note or a comment. The page now drops the dump on screen in the render that sees a path naming another, as usePlaylist and useProfile do. The dump handed over by DumpEdit or a title edit goes with it, so that coming back to its path fetches it instead of matching that snapshot and loading forever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:34:32 +00:00
khannurien
7dd00068df v4: the header has two playlists links, each to its own page — it had three: the text button and the avatar menu's "Playlists" both led to the user's own playlists, and the menu's "All playlists" to /playlists. The text button now leads to /playlists, every public playlist, and shows to guests too, the page being open to them. The menu keeps one entry, "My playlists", to the user's own. Below 1150px, where the text button is hidden, the header no longer links to /playlists.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:30:45 +00:00
khannurien
28f910b00e v4: a playlist's dump count is of the dumps the viewer may see — it counted every dump in the playlist, so a card said "3 dumps" over a page listing two, and told everyone how many private dumps a playlist held. The lists (/playlists, a profile's playlists and followed ones, the search, a dump's playlists, the add-to-playlist panel) now count by isMemberVisibleTo's rule, in SQL as VISIBLE_DUMP_COUNT (lib/playlist-members.ts): the public dumps, and for the owner their own private ones. GET /api/playlists and /api/users/:key/follow/playlists read the optional sign-in for it. The community page and the digests count the public dumps, their figures being the same for every reader. Live, playlist_created and playlist_updated carry each socket's own count: broadcastChange builds the owner's message and the others' apart. playlist_dumps_updated loses its dumpCount, a client counting the dumps it is sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:25:24 +00:00
khannurien
ffc071af4c v4: a description shows as text where a list quotes it — a playlist's card printed its description as written, so one holding an image showed "![](/api/attachments/…)", and so did a user's line in the search results. Both now go through markdownToPlainText (shared/markdown.ts), as the chat's reply previews do: the markup goes, a link keeps its text, an image is left out, and a description of nothing but an image leaves no line. The playlist's page and the profile render the Markdown as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:12:44 +00:00
khannurien
1d56c323a4 v4: the community page's figures stay on one line when a label wraps — each figure is a column-reverse flex box stretched to the row's height, and the default justify-content packed it at the bottom, so a label on two lines ("messages dans la tribune") lifted its number above the others. justify-content: flex-end puts each figure at the top of its cell.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:10:27 +00:00
54 changed files with 2069 additions and 571 deletions

20
API.md
View File

@@ -15,9 +15,11 @@ default view is the bare path, with no redirect. A category is a segment where
the page is the category's own, a dump category's feed at the root (`/music`)
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
narrows a list on another page, a profile's or a search's, it is `?category=`,
of the kind that list holds. Switching tabs, or a tab's views, takes the place
of the current history entry; picking a category, or opening another page or an
editor, adds one.
of the kind that list holds. The order of a list of playlists is `?sort=`, the
default order being the bare path, and the playlists page's list of the viewer's
own is `?mine=1`. Switching tabs, a tab's views or a list's order takes the
place of the current history entry; picking a category, or opening another page
or an editor, adds one.
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
@@ -33,14 +35,14 @@ has one word, the same as in pages.
| `/` | `/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists`; the bare `/` is the viewer's default tab, the bare `/follow` the users followed | anyone, `/follow` signed in | `Index` |
| `/<category>` | `/<category>/hot`, `/new`, `/journal`, `/follow`, `/follow/playlists` | anyone, `/follow` signed in | `Index` |
| `/@<name>` | the overview: dumps beside upvoted | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists | anyone | `UserPublicProfile` |
| `/@<name>/dumps`, `/upvoted`, `/playlists`, `/invitees` | `?category=` narrows the dumps, the upvoted or the playlists; `?sort=` orders the playlists | anyone | `UserPublicProfile` |
| `/@<name>/follow` | `/@<name>/follow/playlists`; the bare tab lists users | anyone | `UserPublicProfile` |
| `/@<name>/manage` | | `user:manage`, on others' profiles | `UserPublicProfile` |
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
| `/playlists` | `/playlists/<category>`, a playlist category's | anyone | `Playlists` |
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either; `?mine=1` lists the viewer's own, signed in only | anyone | `Playlists` |
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
| `/notifications` | | signed in | `Notifications` |
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
@@ -135,7 +137,7 @@ there: those endpoints answer it 400.
- `GET /api/users/:key/avatar` — anyone. The avatar.
- `GET /api/users/:key/dumps`, `/upvoted`, `/playlists` — anyone; private items
to their owner only. Their lists; `?category=` narrows one to a category of
its kind.
its kind, and `?sort=` orders the playlists as `GET /api/playlists` does.
- `GET /api/users/:key/dumps/:slug`, `/playlists/:slug` — anyone; private items
to their owner only. An item by its page path, by an alias too.
- `GET /api/users/:key/follow/users`, `/follow/playlists` — anyone. What they
@@ -174,8 +176,10 @@ there: those endpoints answer it 400.
- `POST /api/playlists` — signed in. Creates a playlist, filed under the
playlist categories it names.
- `GET /api/playlists` — anyone. Every public playlist, newest first;
`?category=<slug>` narrows it to a playlist category.
- `GET /api/playlists` — anyone. Every public playlist; `?category=<slug>`
narrows it to a playlist category, and `?sort=updated|new|dumps` orders it:
the last to take a dump first (the default), the newest, or the fullest. The
caller's own private dumps count, and date their playlists, for them alone.
- `GET /api/playlists/:id` — anyone who may see it. A playlist and its dumps.
- `PUT`, `DELETE /api/playlists/:id` — its owner, `playlist:moderate`. Title,
slug, description, visibility, categories; deletion.

View File

@@ -1,7 +1,8 @@
/**
* Decides which of a playlist's dumps a viewer may see. The playlist service
* filters its listings with it, and the WebSocket service filters each
* client's `playlist_dumps_updated` message the same way.
* filters its listings with it, the WebSocket service filters each client's
* `playlist_dumps_updated` message the same way, and the queries that list
* playlists count their dumps by the same rule.
*
* @module
*/
@@ -34,3 +35,53 @@ export function isMemberVisibleTo(
return !member.isPrivate ||
(member.userId === viewerId && viewerId === playlist.userId);
}
/**
* The fields of a playlist's dump that {@link dumpFiguresFor} needs: those
* that tell who may see it, and when it was added. The playlist service reads
* them for its broadcasts.
*/
export interface DatedPlaylistMember extends PlaylistMember {
addedAt: Date;
}
/** A figure of the dumps of `playlists p` that the viewer `?1` may see. */
const visibleDumps = (figure: string) =>
`(SELECT ${figure} FROM playlist_dumps pd
INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = p.id
AND (d.is_private = 0 OR (d.user_id = p.user_id AND p.user_id = ?1))
)`;
/**
* The `dump_count` and `last_dump_at` columns of a query on `playlists p`:
* how many of the playlist's dumps the viewer may see, by
* {@link isMemberVisibleTo}'s rule, and when the latest of them was added,
* null without any. Both read the viewer's id, or `""` for a signed-out one,
* as `?1`: it is the query's first parameter, and each `?` after it takes the
* next.
*/
export const VISIBLE_DUMP_FIGURES = `${visibleDumps("COUNT(*)")} as dump_count,
${visibleDumps("MAX(pd.added_at)")} as last_dump_at`;
/**
* Returns what `playlist` says of its dumps to the viewer, `members` being
* all of them: the `dumpCount` and `lastDumpAt` that
* {@link VISIBLE_DUMP_FIGURES} reads in a query. The WebSocket service puts
* them on the playlist each socket is sent.
*/
export function dumpFiguresFor(
members: DatedPlaylistMember[],
playlist: Pick<Playlist, "userId">,
viewerId: string | null | undefined,
): Required<Pick<Playlist, "dumpCount">> & Pick<Playlist, "lastDumpAt"> {
const visible = members.filter((m) =>
isMemberVisibleTo(m, playlist, viewerId)
);
return {
dumpCount: visible.length,
lastDumpAt: visible.length
? new Date(Math.max(...visible.map((m) => m.addedAt.getTime())))
: undefined,
};
}

View File

@@ -10,9 +10,9 @@ import type { Playlist } from "../../shared/playlist.ts";
/**
* Returned by the playlist and follow services' `SELECT`s on `playlists`,
* which join the owner's name as `owner_username`. A query may add a
* `dump_count` column, which {@link playlistRowToApi} carries over when
* present.
* which join the owner's name as `owner_username`. A query may add the
* `dump_count` and `last_dump_at` columns (`VISIBLE_DUMP_FIGURES`), which
* {@link playlistRowToApi} carries over when present.
*/
export interface PlaylistRow {
id: string;
@@ -62,6 +62,9 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
lastDumpAt: typeof row.last_dump_at === "string"
? new Date(row.last_dump_at)
: undefined,
ownerUsername: row.owner_username,
};
}

View File

@@ -3,9 +3,11 @@
* playlist's id; its page reads it by its path, through
* `GET /api/users/:key/playlists/:slug`.
*
* - `GET /api/playlists?category=&page=&limit=` — public. Answers a page of
* every public playlist, newest first, or only those in the playlist
* category at `category`, which 404s when there is none.
* - `GET /api/playlists?category=&sort=&page=&limit=` — public. Answers a
* page of every public playlist, in the order `sort` names
* (`PLAYLIST_SORTS`, the last to take a dump first by default), or only
* those in the playlist category at `category`, which 404s when there is
* none.
* - `POST /api/playlists` — signed in. Creates a playlist, filed under the
* playlist categories it names.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
@@ -44,7 +46,11 @@ import {
isReorderPlaylistRequest,
isUpdatePlaylistRequest,
} from "../validators/playlist.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistWithDumps,
} from "../../shared/playlist.ts";
import { authMiddleware, type AuthState } from "../middleware/auth.ts";
import { can } from "../lib/permissions.ts";
import {
@@ -77,12 +83,19 @@ const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists",
});
router.get("/", (ctx) => {
router.get("/", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx);
const params = ctx.request.url.searchParams;
const { page, limit } = parsePagination(params);
const slug = params.get("category");
const categoryId = slug ? getCategoryBySlug("playlist", slug).id : undefined;
const { items, hasMore } = listPublicPlaylists(page, limit, categoryId);
const { items, hasMore } = listPublicPlaylists(
requestingUserId,
page,
limit,
parsePlaylistSort(params.get("sort")),
categoryId,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },

View File

@@ -19,9 +19,10 @@
* - `GET /api/users/:key/upvoted?page=&limit=&category=` — optional sign-in.
* Answers a page of the dumps they upvoted, private ones only to
* themselves, optionally in one category.
* - `GET /api/users/:key/playlists?page=&limit=&category=` — optional
* - `GET /api/users/:key/playlists?page=&limit=&category=&sort=` — optional
* sign-in. Answers a page of the playlists they made, the private ones only
* to themselves, optionally in one playlist category.
* to themselves, optionally in one playlist category, in the order `sort`
* names, as `GET /api/playlists` does.
* - `GET /api/users/:key/playlists/:slug` — optional sign-in. Answers their
* playlist whose slug is or was `slug`, with its current slug and the dumps
* the caller may see; a private one 404s but for its owner.
@@ -56,7 +57,11 @@ import {
type UserSuggestion,
} from "../../shared/user.ts";
import type { Dump } from "../../shared/dump.ts";
import type { Playlist, PlaylistWithDumps } from "../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
type PlaylistWithDumps,
} from "../../shared/playlist.ts";
import {
fieldOf,
isRegisterUserRequest,
@@ -210,7 +215,14 @@ router.get("/:key/playlists", async (ctx) => {
const categoryId = resolveCategoryFilter("playlist", params);
const { items, hasMore } = categoryId === "missing"
? { items: [], hasMore: false }
: listPlaylistsByUser(user.id, requestingUserId, page, limit, categoryId);
: listPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
parsePlaylistSort(params.get("sort")),
categoryId,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },
@@ -224,7 +236,7 @@ router.get("/:key/playlists/:slug", async (ctx) => {
const responseBody: APIResponse<PlaylistWithDumps> = {
success: true,
data: getPlaylist(
getPlaylistByPath(user.id, ctx.params.slug),
getPlaylistByPath(user.id, ctx.params.slug, requestingUserId),
requestingUserId,
),
};
@@ -245,10 +257,16 @@ router.get("/:key/follow/users", (ctx) => {
ctx.response.body = responseBody;
});
router.get("/:key/follow/playlists", (ctx) => {
router.get("/:key/follow/playlists", async (ctx) => {
const user = resolveUserKey(ctx.params.key);
const requestingUserId = await parseOptionalAuth(ctx);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getFollowedPlaylistsByUser(user.id, page, limit);
const { items, hasMore } = getFollowedPlaylistsByUser(
user.id,
requestingUserId,
page,
limit,
);
const responseBody: APIResponse<PaginatedData<Playlist>> = {
success: true,
data: { items, hasMore },

View File

@@ -1,7 +1,8 @@
/**
* Records which dumps a dump's note and comments link to, and finds the dumps
* related to one through those links. The dump and comment services keep the
* links up to date, and the dump page lists the related dumps.
* links up to date, and the dump page lists the related dumps, which it
* fetches again when `dump_related_changed` says they changed.
*
* @module
*/
@@ -16,6 +17,7 @@ import {
import { parsePagePath } from "../../shared/paths.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { parseRows } from "../model/rows.ts";
import { broadcastDumpRelatedChanged } from "./ws-service.ts";
/**
* Matches an http(s) URL in Markdown. It stops at the delimiters of
@@ -46,6 +48,42 @@ function resolveDumpPathId(pathname: string): string | null {
return row?.id ?? null;
}
/**
* Tells clients that the links between `dumpId` and each of `otherIds`
* changed, as `broadcastDumpRelatedChanged` says, after reading who may see
* each of them. It sends nothing without `otherIds`.
*/
function announceRelatedChange(dumpId: string, otherIds: string[]): void {
if (otherIds.length === 0) return;
const ids = [dumpId, ...otherIds];
const rows = db.prepare(
`SELECT id, user_id, is_private FROM dumps
WHERE id IN (${ids.map(() => "?").join(", ")});`,
).all(...ids) as { id: string; user_id: string; is_private: number }[];
const dumps = rows.map((row) => ({
id: row.id,
userId: row.user_id,
isPrivate: Boolean(row.is_private),
}));
const dump = dumps.find((d) => d.id === dumpId);
if (!dump) return;
broadcastDumpRelatedChanged(dump, dumps.filter((d) => d.id !== dumpId));
}
/**
* Tells clients that dump `dumpId`, which just turned public, is now related
* to the dumps linked to or from it, for the dump service. Its poster saw
* those links already; to everyone else they are new.
*/
export function announceLinksNowPublic(dumpId: string): void {
const rows = db.prepare(
`SELECT to_dump_id AS id FROM dump_backlinks WHERE from_dump_id = ?
UNION
SELECT from_dump_id FROM dump_backlinks WHERE to_dump_id = ?;`,
).all(dumpId, dumpId) as { id: string }[];
announceRelatedChange(dumpId, rows.map((row) => row.id));
}
/**
* 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
@@ -53,7 +91,8 @@ function resolveDumpPathId(pathname: string): string | null {
* 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 an empty `text` when a
* comment is deleted.
* comment is deleted. Clients are told of the dumps it links or no longer
* links, as `dump_related_changed`.
*/
export function relinkBacklinks(
sourceType: "dump_comment" | "comment",
@@ -90,7 +129,11 @@ export function relinkBacklinks(
targetIds.delete(fromDumpId);
transaction(() => {
const formerIds = transaction(() => {
const former = db.prepare(
`SELECT to_dump_id FROM dump_backlinks
WHERE source_type = ? AND source_id = ?;`,
).all(sourceType, sourceId) as { to_dump_id: string }[];
db.prepare(
`DELETE FROM dump_backlinks WHERE source_type = ? AND source_id = ?;`,
).run(sourceType, sourceId);
@@ -102,7 +145,13 @@ export function relinkBacklinks(
for (const toDumpId of targetIds) {
insert.run(sourceType, sourceId, fromDumpId, toDumpId, createdAt);
}
return new Set(former.map((row) => row.to_dump_id));
});
// Most saves change no link, and send nothing.
announceRelatedChange(fromDumpId, [
...targetIds.symmetricDifference(formerIds),
]);
}
/**

View File

@@ -57,7 +57,7 @@ import {
VALIDATION,
} from "../../shared/validation.ts";
import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts";
import { announceLinksNowPublic, relinkBacklinks } from "./backlink-service.ts";
import {
assertCategoriesExist,
attachCategoryIds,
@@ -96,7 +96,8 @@ function assertFileDumpText(title?: string, comment?: string): void {
* `broadcastDumpChange` for the dump itself, and, when it was deleted or
* changed visibility, the dumps of each playlist holding it, which moved for
* their viewers. A deletion passes the `holders` it read before the cascade
* took the dump out of them.
* took the dump out of them. A dump that turned public also joins the related
* dumps of those it is linked with.
*/
function announceDumpChange(
before: Dump | null,
@@ -106,6 +107,7 @@ function announceDumpChange(
broadcastDumpChange(before, after);
if (!before || (after && after.isPrivate === before.isPrivate)) return;
broadcastHoldersDumps(holders ?? playlistsHolding(before.id));
if (after && !after.isPrivate) announceLinksNowPublic(after.id);
}
/**

View File

@@ -23,6 +23,7 @@ import {
} from "../model/dump.ts";
import { isFollowRow } from "../model/follow.ts";
import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { VISIBLE_DUMP_FIGURES } from "../lib/playlist-members.ts";
import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds, categoryJoin } from "./category-service.ts";
import { sendToUser } from "./ws-service.ts";
@@ -232,12 +233,14 @@ export function getFollowedPlaylistsDumpFeed(
/**
* Returns a page of the public playlists `userId` follows, most recently
* followed first, for their profile and playlists pages.
* followed first, for their profile and playlists pages. Their dumps are
* counted for `requestingUserId`, null being a signed-out viewer.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/
export function getFollowedPlaylistsByUser(
userId: string,
requestingUserId: string | null,
page: number,
limit: number,
): Page<Playlist> {
@@ -245,7 +248,7 @@ export function getFollowedPlaylistsByUser(
const rawRows = db.prepare(
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
${VISIBLE_DUMP_FIGURES}
FROM playlists p
LEFT JOIN users u ON u.id = p.user_id
INNER JOIN follows f ON f.followed_playlist_id = p.id
@@ -253,7 +256,7 @@ export function getFollowedPlaylistsByUser(
AND p.is_public = 1
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit + 1, offset);
).all(requestingUserId ?? "", userId, limit + 1, offset);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(500, "Malformed playlist data");

View File

@@ -12,6 +12,7 @@ import type {
CreatePlaylistRequest,
Playlist,
PlaylistMembership,
PlaylistSort,
PlaylistWithDumps,
UpdatePlaylistRequest,
} from "../../shared/playlist.ts";
@@ -42,21 +43,41 @@ import {
setCategories,
} from "./category-service.ts";
import {
type DatedPlaylistMember,
isMemberVisibleTo,
type PlaylistMember,
VISIBLE_DUMP_FIGURES,
} from "../lib/playlist-members.ts";
import { LIKE, likePattern } from "../lib/like.ts";
import { parseRows } from "../model/rows.ts";
/**
* The select list and `FROM` clause of a playlist query, adding the owner's
* name and the dump count. Callers append the `WHERE`. The dump count includes
* dumps the viewer can't see.
* name, the count of the dumps the viewer may see and when the latest was
* added. Callers append the `WHERE`, and pass the viewer's id first, as
* {@link viewerParam} gives it.
*/
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
${VISIBLE_DUMP_FIGURES}
FROM playlists p JOIN users u ON u.id = p.user_id`;
/**
* The `ORDER BY` of each of the `PLAYLIST_SORTS`, on a {@link PLAYLIST_SELECT}:
* `comparePlaylists` (`shared/playlist.ts`) in SQL, which a test holds to it.
*/
const PLAYLIST_ORDER: Record<PlaylistSort, string> = {
updated: "COALESCE(last_dump_at, p.created_at) DESC, p.created_at DESC",
new: "p.created_at DESC",
dumps: "dump_count DESC, p.created_at DESC",
};
/**
* Returns what {@link VISIBLE_DUMP_FIGURES}'s parameter takes for `viewerId`:
* a signed-out viewer matches no user.
*/
function viewerParam(viewerId: string | null | undefined): string {
return viewerId ?? "";
}
/**
* Converts the rows of a {@link PLAYLIST_SELECT} to playlists, their
* categories filled in.
@@ -69,13 +90,18 @@ function playlistsOf(rows: unknown[]): Playlist[] {
/**
* Returns playlist `playlistId`, private or not. A caller that shows it to
* someone must check `isPublic` first, as the OG middleware does.
* someone must check `isPublic` first, as the OG middleware does. Its
* `dumpCount` and `lastDumpAt` are `viewerId`'s, or a signed-out viewer's
* without one.
*
* @throws {APIException} 404 when there is no such playlist.
*/
export function getPlaylistById(playlistId: string): Playlist {
export function getPlaylistById(
playlistId: string,
viewerId: string | null = null,
): Playlist {
const row = db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`)
.get(playlistId);
.get(viewerParam(viewerId), playlistId);
if (!row || !isPlaylistRow(row)) {
throw new APIException(404, "Playlist not found");
}
@@ -86,13 +112,18 @@ export function getPlaylistById(playlistId: string): Playlist {
/**
* Returns the playlist of `userId`'s whose slug is or was `slug`, private or
* not, or throws a 404, as {@link getPlaylistById} does. Reached by a former
* slug, it comes with its current one.
* not, or throws a 404, as {@link getPlaylistById} does, counting its dumps
* for `viewerId` likewise. Reached by a former slug, it comes with its current
* one.
*/
export function getPlaylistByPath(userId: string, slug: string): Playlist {
export function getPlaylistByPath(
userId: string,
slug: string,
viewerId: string | null = null,
): Playlist {
const playlistId = resolveSlug("playlist", userId, slug);
if (!playlistId) throw new APIException(404, "Playlist not found");
return getPlaylistById(playlistId);
return getPlaylistById(playlistId, viewerId);
}
/**
@@ -136,7 +167,7 @@ export function createPlaylist(
}
linkAttachments(req.description, "playlist", id);
}
broadcastPlaylistChange(null, playlist);
broadcastPlaylistChange(null, playlist, []);
return playlist;
}
@@ -144,7 +175,8 @@ export function createPlaylist(
* Returns a playlist with the dumps `requestingUserId` may see in it, in
* order, for `GET /api/playlists/:playlistId`, and for
* `GET /api/users/:key/playlists/:slug` once it has found the playlist by its
* path. A null `requestingUserId` is a signed-out viewer.
* path, for the same viewer. A null `requestingUserId` is a signed-out viewer.
* Its `dumpCount` is the number of those dumps.
*
* @throws {APIException} 404 when there is no such playlist, or it is private
* and not theirs.
@@ -154,7 +186,7 @@ export function getPlaylist(
requestingUserId: string | null,
): PlaylistWithDumps {
const playlist = typeof playlistId === "string"
? getPlaylistById(playlistId)
? getPlaylistById(playlistId, requestingUserId)
: playlistId;
if (!playlist.isPublic && requestingUserId !== playlist.userId) {
@@ -175,7 +207,7 @@ export function getPlaylist(
isMemberVisibleTo(d, playlist, requestingUserId)
);
return { ...playlist, dumps: visibleDumps };
return { ...playlist, dumpCount: visibleDumps.length, dumps: visibleDumps };
}
/**
@@ -198,22 +230,33 @@ export function searchPlaylists(
const rows = requestingUserId
? db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(...catParams, requestingUserId, pattern, pattern, limit)
).all(
requestingUserId,
...catParams,
requestingUserId,
pattern,
pattern,
limit,
)
: db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`,
).all(...catParams, pattern, pattern, limit);
).all(viewerParam(null), ...catParams, pattern, pattern, limit);
return playlistsOf(rows);
}
/**
* Returns a page of every public playlist, newest first, for
* Returns a page of every public playlist, in the order `sort` names, for
* `GET /api/playlists`, optionally only those in the playlist category
* `categoryId`. The client's `/playlists` page lists them.
* `categoryId`. The client's `/playlists` page lists them. Their dumps are
* counted, and so ordered, for `requestingUserId`, null being a signed-out
* viewer.
*/
export function listPublicPlaylists(
requestingUserId: string | null,
page: number,
limit: number,
sort: PlaylistSort,
categoryId?: string,
): Page<Playlist> {
const offset = (page - 1) * limit;
@@ -221,27 +264,28 @@ export function listPublicPlaylists(
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, limit + 1, offset);
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all(viewerParam(requestingUserId), ...catParams, limit + 1, offset);
return pageOf(playlistsOf(rows), limit);
}
/**
* Returns the public playlists created from `from` to `to`, the fullest first
* and the newer on a tie. The community service gathers them for the
* `/community` page and the digests.
* `/community` page and the digests, which show every reader the same
* figures: only their public dumps are counted.
*/
export function listNewPublicPlaylists(from: Date, to: Date): Playlist[] {
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT}
WHERE p.is_public = 1 AND p.created_at >= ? AND p.created_at < ?
ORDER BY dump_count DESC, p.created_at DESC;`,
).all(from.toISOString(), to.toISOString());
ORDER BY ${PLAYLIST_ORDER.dumps};`,
).all(viewerParam(null), from.toISOString(), to.toISOString());
return playlistsOf(rows);
}
/**
* Returns a page of `userId`'s playlists, newest first, for
* Returns a page of `userId`'s playlists, in the order `sort` names, for
* `GET /api/users/:key/playlists`, optionally only those in the playlist
* category `categoryId`. The private ones are listed only when
* `requestingUserId` is `userId`.
@@ -251,6 +295,7 @@ export function listPlaylistsByUser(
requestingUserId: string | null,
page: number,
limit: number,
sort: PlaylistSort,
categoryId?: string,
): Page<Playlist> {
const isOwner = requestingUserId === userId;
@@ -261,8 +306,14 @@ export function listPlaylistsByUser(
const rows = db.prepare(
`SELECT ${PLAYLIST_SELECT} ${catJoin}
WHERE p.user_id = ?${privacyFilter}
ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`,
).all(...catParams, userId, limit + 1, offset);
ORDER BY ${PLAYLIST_ORDER[sort]} LIMIT ? OFFSET ?;`,
).all(
viewerParam(requestingUserId),
...catParams,
userId,
limit + 1,
offset,
);
return pageOf(playlistsOf(rows), limit);
}
@@ -287,7 +338,7 @@ export function updatePlaylist(
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
const playlist = getPlaylistById(playlistId, requestingUserId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Only its owner or a moderator may do that");
@@ -347,7 +398,7 @@ export function updatePlaylist(
}
linkAttachments(newDescription, "playlist", playlist.id);
}
broadcastPlaylistChange(playlist, updated);
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
// Its followers weren't told of the dumps added while it was private.
if (!playlist.isPublic && newIsPublic) {
notifyPlaylistFollowersNowPublic({ playlistId: playlist.id });
@@ -374,7 +425,7 @@ export function deletePlaylist(
}
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
broadcastPlaylistChange(playlist, null);
broadcastPlaylistChange(playlist, null, []);
}
/**
@@ -390,7 +441,7 @@ export function getEditablePlaylist(
requestingUserId: string,
canModerate: boolean,
): Playlist {
const playlist = getPlaylistById(playlistId);
const playlist = getPlaylistById(playlistId, requestingUserId);
if (playlist.userId !== requestingUserId && !canModerate) {
throw new APIException(403, "Only its owner or a moderator may do that");
}
@@ -421,8 +472,8 @@ export function setPlaylistImage(
db.prepare(
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
).run(imageMime, new Date().toISOString(), playlist.id);
const updated = getPlaylistById(playlist.id);
broadcastPlaylistChange(playlist, updated);
const updated = getPlaylistById(playlist.id, requestingUserId);
broadcastPlaylistChange(playlist, updated, getCurrentMembers(playlist.id));
return updated;
}
@@ -590,13 +641,13 @@ export function getPlaylistMembershipsForDump(
): PlaylistMembership[] {
const rows = db.prepare(
`SELECT p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps x WHERE x.playlist_id = p.id) as dump_count,
${VISIBLE_DUMP_FIGURES},
EXISTS (SELECT 1 FROM playlist_dumps pd
WHERE pd.playlist_id = p.id AND pd.dump_id = ?) as has_dump
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.user_id = ?
ORDER BY p.created_at DESC;`,
).all(dumpId, userId);
).all(userId, dumpId, userId);
const memberships = rows.map((row) => {
if (!isPlaylistRow(row)) {
@@ -626,7 +677,7 @@ export function getPlaylistsForDump(
INNER JOIN playlist_dumps pd ON pd.playlist_id = p.id
WHERE pd.dump_id = ? AND (p.is_public = 1 OR p.user_id = ?)
ORDER BY p.created_at DESC LIMIT 20;`,
).all(dumpId, requestingUserId ?? "");
).all(viewerParam(requestingUserId), dumpId, requestingUserId ?? "");
return playlistsOf(rows);
}
@@ -660,18 +711,19 @@ export function getPlaylistImageInfo(
* Returns a playlist's dumps in order, all of them, for the callers to filter
* per viewer.
*/
function getCurrentMembers(playlistId: string): PlaylistMember[] {
function getCurrentMembers(playlistId: string): DatedPlaylistMember[] {
const rows = db.prepare(
`SELECT pd.dump_id, d.is_private, d.user_id
`SELECT pd.dump_id, pd.added_at, d.is_private, d.user_id
FROM playlist_dumps pd INNER JOIN dumps d ON d.id = pd.dump_id
WHERE pd.playlist_id = ? ORDER BY pd.position ASC;`,
).all(playlistId) as Array<
{ dump_id: string; is_private: number; user_id: string }
{ dump_id: string; added_at: string; is_private: number; user_id: string }
>;
return rows.map((r) => ({
id: r.dump_id,
isPrivate: Boolean(r.is_private),
userId: r.user_id,
addedAt: new Date(r.added_at),
}));
}
@@ -701,7 +753,7 @@ export function playlistsHolding(dumpId: string): PlaylistHolder[] {
/**
* Sends each of `holders` its dumps again, after one of them was deleted or
* changed visibility: the count moved, or what each viewer may see of them.
* changed visibility, which changes what each viewer may see of them.
*/
export function broadcastHoldersDumps(holders: PlaylistHolder[]): void {
for (const holder of holders) {

View File

@@ -18,8 +18,9 @@ import type { OnlineUser, ServerToClientMessage } from "../../shared/ws.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser, Role } from "../../shared/user.ts";
import {
type DatedPlaylistMember,
dumpFiguresFor,
isMemberVisibleTo,
type PlaylistMember,
} from "../lib/playlist-members.ts";
import type { Job } from "../lib/scheduler.ts";
@@ -179,35 +180,43 @@ export function broadcastPresence(): void {
* `before` is null for a new item, `after` for a deleted one. A private item's
* changes reach its owner's sockets alone. When it goes private, everyone else
* is told it was deleted, and gets nothing of the change; when it goes public,
* they are told it was created, since to them it is new.
* they are told it was created, since to them it is new. The owner's sockets
* and the others' each get a message of their own, built with `toOwner` set
* for the former, for what the two don't see alike.
*/
function broadcastChange<T extends { userId: string }>(
before: T | null,
after: T | null,
isPrivate: (item: T) => boolean,
messages: {
created: (item: T) => ServerToClientMessage;
updated: (item: T) => ServerToClientMessage;
created: (item: T, toOwner: boolean) => ServerToClientMessage;
updated: (item: T, toOwner: boolean) => ServerToClientMessage;
deleted: (item: T) => ServerToClientMessage;
},
): void {
const item = after ?? before;
if (!item) return;
const toOwner = (client: WsClient) => client.userId === item.userId;
const toOthers = (client: WsClient) => client.userId !== item.userId;
const wasPublic = !!before && !isPrivate(before);
const isPublic = !!after && !isPrivate(after);
if (!after) {
broadcast(messages.deleted(item), isPrivate(item) ? toOwner : undefined);
} else if (!before) {
broadcast(messages.created(after), isPrivate(after) ? toOwner : undefined);
} else if (isPrivate(after)) {
broadcast(messages.updated(after), toOwner);
if (!isPrivate(before)) broadcast(messages.deleted(after), toOthers);
} else if (isPrivate(before)) {
broadcast(messages.updated(after), toOwner);
broadcast(messages.created(after), toOthers);
} else {
broadcast(messages.updated(after));
broadcast(
!after
? messages.deleted(item)
: before
? messages.updated(after, true)
: messages.created(after, true),
(client) => client.userId === item.userId,
);
const toOthers = (client: WsClient) => client.userId !== item.userId;
if (after && isPublic) {
broadcast(
wasPublic
? messages.updated(after, false)
: messages.created(after, false),
toOthers,
);
} else if (wasPublic) {
broadcast(messages.deleted(item), toOthers);
}
}
@@ -226,6 +235,29 @@ export function broadcastDumpChange(
});
}
/**
* Sends `dump_related_changed` after the links between `dump` and each of
* `others` changed, for the backlink service. A link shows only to those who
* may see both its ends, so a socket is told only when its user may see
* `dump` and one of `others`, and of those alone.
*/
export function broadcastDumpRelatedChanged(
dump: Pick<Dump, "id" | "isPrivate" | "userId">,
others: Pick<Dump, "id" | "isPrivate" | "userId">[],
): void {
for (const client of clients) {
const sees = (d: Pick<Dump, "isPrivate" | "userId">) =>
!d.isPrivate || d.userId === client.userId;
if (!sees(dump)) continue;
const seen = others.filter(sees);
if (seen.length === 0) continue;
send(client.socket, {
type: "dump_related_changed",
dumpIds: [dump.id, ...seen.map((d) => d.id)],
});
}
}
/**
* Sends `votes_update` after the route stored a vote, to everyone, or with
* `onlyUserId` to that user's sockets alone (for a private dump).
@@ -265,15 +297,28 @@ export function broadcastCommentLikeUpdate(
* Tells the clients that may see a playlist how it changed, as
* {@link broadcastChange} says, for the playlist service after every write to
* its details. A playlist turned private thus leaves the others' views without
* their learning what it became.
* their learning what it became. `members` are the dumps of `after`: each
* socket gets the playlist with the count of those its user may see and the
* date of the latest, the owner's private dumps being theirs alone to count.
*/
export function broadcastPlaylistChange(
before: Playlist | null,
after: Playlist | null,
members: DatedPlaylistMember[],
): void {
const counted = (playlist: Playlist, toOwner: boolean): Playlist => ({
...playlist,
...dumpFiguresFor(members, playlist, toOwner ? playlist.userId : null),
});
broadcastChange(before, after, (playlist) => !playlist.isPublic, {
created: (playlist) => ({ type: "playlist_created", playlist }),
updated: (playlist) => ({ type: "playlist_updated", playlist }),
created: (playlist, toOwner) => ({
type: "playlist_created",
playlist: counted(playlist, toOwner),
}),
updated: (playlist, toOwner) => ({
type: "playlist_updated",
playlist: counted(playlist, toOwner),
}),
deleted: (p) => ({
type: "playlist_deleted",
playlistId: p.id,
@@ -285,11 +330,12 @@ export function broadcastPlaylistChange(
/**
* Sends `playlist_dumps_updated` to the playlist's audience, each socket with
* only the dumps its user may see, as `GET` would list them. The private dumps
* in a public playlist stay hidden from everyone but their posters.
* in a public playlist stay hidden from everyone but their posters, and so
* does their number: a client counts the dumps it is sent.
*/
export function broadcastPlaylistDumpsUpdated(
playlist: Pick<Playlist, "id" | "isPublic" | "userId">,
members: PlaylistMember[],
members: DatedPlaylistMember[],
): void {
for (const client of clients) {
if (!playlist.isPublic && client.userId !== playlist.userId) continue;
@@ -299,7 +345,7 @@ export function broadcastPlaylistDumpsUpdated(
dumpIds: members
.filter((m) => isMemberVisibleTo(m, playlist, client.userId))
.map((m) => m.id),
dumpCount: members.length,
lastDumpAt: dumpFiguresFor(members, playlist, client.userId).lastDumpAt,
});
}
}

View File

@@ -16,10 +16,12 @@ import {
TEST_DIR,
} from "../helpers.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistMembership,
PlaylistWithDumps,
import {
comparePlaylists,
type Playlist,
PLAYLIST_SORTS,
type PlaylistMembership,
type PlaylistWithDumps,
} from "../../../shared/playlist.ts";
import { assert, assertEquals, assertMatch } from "@std/assert";
@@ -146,6 +148,72 @@ test("a public playlist hides its private dumps from everyone but their owner",
);
});
test("a playlist's dumpCount is of the dumps the viewer may see", async () => {
const owner = await createUser();
const author = await createUser();
const pl = insertPlaylist({ userId: owner.id, title: "Counted banana" });
const pub = insertDump({ userId: owner.id });
const mine = insertDump({ userId: owner.id, isPrivate: true });
// Someone else's, gone private after it was added: its owner no longer
// sees it there, and neither does its poster.
const theirs = insertDump({ userId: author.id, isPrivate: true });
for (const [i, d] of [pub, mine, theirs].entries()) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(pl.id, d.id, i, new Date().toISOString());
}
await put(`/api/me/follow/playlists/${pl.id}`, { auth: author.auth });
const lists = [
"/api/playlists",
`/api/users/@${owner.username}/playlists`,
`/api/users/@${author.username}/follow/playlists`,
];
for (
const [auth, expected] of [
[owner.auth, 2],
[author.auth, 1],
[undefined, 1],
] as const
) {
for (const url of lists) {
const { items } = (await get<PaginatedData<Playlist>>(url, { auth }))
.data;
assertEquals(items.map((p) => p.dumpCount), [expected], url);
}
assertEquals(
(await get<{ playlists: Playlist[] }>("/api/search?q=banana", { auth }))
.data.playlists.map((p) => p.dumpCount),
[expected],
);
assertEquals(
(await get<Playlist[]>(`/api/dumps/${pub.id}/playlists`, { auth })).data
.map((p) => p.dumpCount),
[expected],
);
const page = await get<PlaylistWithDumps>(`/api/playlists/${pl.id}`, {
auth,
});
assertEquals(page.data.dumpCount, expected);
assertEquals(ids(page).length, expected);
}
assertEquals(
(await get<PlaylistMembership[]>(`/api/me/playlists?contains=${pub.id}`, {
auth: owner.auth,
})).data.map((m) => [m.hasDump, m.playlist.dumpCount]),
[[true, 2]],
);
// An edit answers with the editor's count.
assertEquals(
(await put<Playlist>(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Counted" },
})).data.dumpCount,
2,
);
});
test("reorder: must name exactly the current members", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
@@ -599,7 +667,7 @@ test("playlist categories: filed on creation, replaced on update, dump categorie
assertEquals(cleared.data.categoryIds, []);
});
test("GET /api/playlists: every public playlist, newest first, by category", async () => {
test("GET /api/playlists: every public playlist, by category", async () => {
const owner = await createUser();
const other = await createUser();
const mixes = insertCategory({ slug: "mixes", kind: "playlist" });
@@ -663,3 +731,110 @@ test("GET /api/users/:key/playlists?category=: their playlists in one category",
[],
);
});
test("?sort=: by the last dump added, by creation or by dumps, as the viewer sees them", async () => {
const owner = await createUser();
const made = (title: string, createdAt: string) => {
const { id } = insertPlaylist({ userId: owner.id, title });
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run(createdAt, id);
return id;
};
const add = (playlistId: string, addedAt: string, isPrivate = false) =>
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, 0, ?);`,
).run(playlistId, insertDump({ userId: owner.id, isPrivate }).id, addedAt);
const a = made("A", "2026-01-01T00:00:00.000Z");
const b = made("B", "2026-02-01T00:00:00.000Z");
const c = made("C", "2026-03-01T00:00:00.000Z");
add(a, "2026-04-01T00:00:00.000Z");
add(b, "2026-02-10T00:00:00.000Z");
add(b, "2026-02-11T00:00:00.000Z");
// Only the owner sees it: to anyone else, C is empty and as it was made.
add(c, "2026-05-01T00:00:00.000Z", true);
type Auth = typeof owner.auth;
const list = async (query: string, auth?: Auth) =>
(await get<PaginatedData<Playlist>>(query, { auth })).data.items;
const titles = async (query: string, auth?: Auth) =>
(await list(query, auth)).map((p) => p.title);
for (
const url of ["/api/playlists", `/api/users/${owner.id}/playlists`]
) {
assertEquals(await titles(`${url}?sort=updated`), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=new`), ["C", "B", "A"], url);
assertEquals(await titles(`${url}?sort=dumps`), ["B", "A", "C"], url);
assertEquals(
await titles(`${url}?sort=updated`, owner.auth),
["C", "A", "B"],
url,
);
// A and C hold one dump each to their owner: the newer goes first.
assertEquals(
await titles(`${url}?sort=dumps`, owner.auth),
["B", "C", "A"],
url,
);
// The first order is the default, which an unknown one falls back to.
assertEquals(await titles(url), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=nope`), ["A", "C", "B"], url);
assertEquals(await titles(`${url}?sort=new&limit=1&page=2`), ["B"], url);
}
const dates = (await list("/api/playlists?sort=new")).map((p) =>
p.lastDumpAt
);
assertEquals(dates, [
undefined,
"2026-02-11T00:00:00.000Z",
"2026-04-01T00:00:00.000Z",
] as unknown[]);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${c}`, { auth: owner.auth }))
.data.lastDumpAt as unknown,
"2026-05-01T00:00:00.000Z",
);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${c}`)).data.lastDumpAt,
undefined,
);
});
test("comparePlaylists orders playlists as the API's ?sort= pages them", async () => {
const owner = await createUser();
// Ties of every kind: dates and counts shared by several playlists.
for (let i = 0; i < 8; i++) {
const { id } = insertPlaylist({ userId: owner.id, title: `P${i}` });
db.prepare(`UPDATE playlists SET created_at = ? WHERE id = ?;`)
.run(`2026-01-0${1 + i}T00:00:00.000Z`, id);
for (let n = 0; n < i % 3; n++) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(
id,
insertDump({ userId: owner.id }).id,
n,
`2026-02-0${1 + ((i * 5 + n) % 9)}T00:00:00.000Z`,
);
}
}
for (const sort of PLAYLIST_SORTS) {
const { items } = (await get<PaginatedData<Playlist>>(
`/api/playlists?sort=${sort}`,
)).data;
// The dates come as strings over the wire.
const playlists = items.map((p) => ({
...p,
createdAt: new Date(p.createdAt),
lastDumpAt: p.lastDumpAt && new Date(p.lastDumpAt),
}));
assertEquals(
playlists.toSorted(comparePlaylists(sort)).map((p) => p.title),
items.map((p) => p.title),
sort,
);
}
});

View File

@@ -654,6 +654,78 @@ wsTest("REST writes broadcast to the right audience", async (connect) => {
assertEquals(x.all("playlist_dumps_updated"), []);
});
wsTest(
"dump_related_changed reaches those who may see both ends of a link",
async (connect) => {
const owner = await createUser();
const other = await createUser();
const src = insertDump({ userId: owner.id, title: "src" });
const target = insertDump({ userId: other.id, title: "target" });
const secret = insertDump({
userId: other.id,
title: "secret",
isPrivate: true,
});
const o = await connect(owner);
const x = await connect(other);
const anon = await connect();
// A comment linking a public dump and a private one: the latter's poster
// alone hears of it.
const links =
`http://gerbeur.test${target.path} http://gerbeur.test${secret.path}`;
const comment = await post<Comment>(`/api/dumps/${src.id}/comments`, {
auth: owner.auth,
json: { body: links },
});
for (const c of [o, anon]) {
assertEquals((await c.next("dump_related_changed")).dumpIds, [
src.id,
target.id,
]);
}
const both = (await x.next("dump_related_changed")).dumpIds;
assertEquals(both[0], src.id);
assertEquals(new Set(both.slice(1)), new Set([target.id, secret.id]));
// An edit that changes no link says nothing; a deletion drops them.
await patch(`/api/comments/${comment.data.id}`, {
auth: owner.auth,
json: { body: `${links} again` },
});
await o.next("comment_updated");
assertEquals(o.all("dump_related_changed").length, 1);
await del(`/api/comments/${comment.data.id}`, { auth: owner.auth });
assertEquals((await anon.next("dump_related_changed")).dumpIds, [
src.id,
target.id,
]);
await o.next("dump_related_changed");
await x.next("dump_related_changed");
// A private dump's links are its poster's alone, until it turns public.
const hidden = insertDump({ userId: owner.id, isPrivate: true });
await put(`/api/dumps/${hidden.id}`, {
auth: owner.auth,
json: { comment: `http://gerbeur.test${target.path}` },
});
assertEquals((await o.next("dump_related_changed")).dumpIds, [
hidden.id,
target.id,
]);
await x.sync();
assertEquals(x.all("dump_related_changed").length, 2);
await put(`/api/dumps/${hidden.id}`, {
auth: owner.auth,
json: { isPrivate: false },
});
assertEquals((await x.next("dump_related_changed")).dumpIds, [
hidden.id,
target.id,
]);
},
);
wsTest("a password change closes the user's sockets", async (connect) => {
const user = await createUser();
const c = await connect(user);
@@ -672,6 +744,47 @@ wsTest("a password change closes the user's sockets", async (connect) => {
assertFalse(res.data.token === user.token);
});
wsTest(
"playlist_dumps_updated dates the latest dump each viewer can see",
async (connect) => {
const owner = await createUser();
const pl = insertPlaylist({ userId: owner.id });
const o = await connect(owner);
const anon = await connect();
const add = async (isPrivate: boolean) => {
const dump = insertDump({ userId: owner.id, isPrivate });
await post(`/api/playlists/${pl.id}/dumps/${dump.id}`, {
auth: owner.auth,
});
return [
(await o.next("playlist_dumps_updated")).lastDumpAt,
(await anon.next("playlist_dumps_updated")).lastDumpAt,
];
};
const [first, firstToAnon] = await add(false);
assert(first);
assertEquals(firstToAnon, first);
// The owner's private dump moves the date for them alone.
const [second, secondToAnon] = await add(true);
assert(second && second >= first);
assertEquals(secondToAnon, first);
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Renamed" },
});
assertEquals(
(await o.next("playlist_updated")).playlist.lastDumpAt,
second,
);
assertEquals(
(await anon.next("playlist_updated")).playlist.lastDumpAt,
first,
);
},
);
wsTest(
"playlist_dumps_updated lists only what each viewer can see",
async (connect) => {
@@ -709,8 +822,32 @@ wsTest(
for (const c of [a, x, anon]) {
const msg = await c.next("playlist_dumps_updated");
assertEquals(msg.dumpIds, [added.id, pub.id]);
// The count is the playlist's, as its dumpCount gives it to everyone.
assertEquals(msg.dumpCount, 4);
}
// A change to its details carries the count of what each may see, too.
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { title: "Renamed" },
});
assertEquals((await o.next("playlist_updated")).playlist.dumpCount, 3);
for (const c of [a, x, anon]) {
assertEquals((await c.next("playlist_updated")).playlist.dumpCount, 2);
}
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { isPublic: false },
});
await put(`/api/playlists/${pl.id}`, {
auth: owner.auth,
json: { isPublic: true },
});
assertEquals(
(await o.next("playlist_updated", (m) => m.playlist.isPublic)).playlist
.dumpCount,
3,
);
for (const c of [a, x, anon]) {
assertEquals((await c.next("playlist_created")).playlist.dumpCount, 2);
}
},
);
@@ -817,7 +954,6 @@ wsTest(
});
const hidden = await x.next("playlist_dumps_updated");
assertEquals(hidden.dumpIds, [keep.id, gone.id]);
assertEquals(hidden.dumpCount, 3);
assertEquals(
(await o.next("playlist_dumps_updated")).dumpIds,
[keep.id, flip.id, gone.id],
@@ -835,7 +971,7 @@ wsTest(
await del(`/api/dumps/${gone.id}`, { auth: owner.auth });
const after = await x.next(
"playlist_dumps_updated",
(m) => m.dumpCount === 2,
(m) => m.dumpIds.length === 2,
);
assertEquals(after.dumpIds, [keep.id, flip.id]);

View File

@@ -43,9 +43,25 @@ test("communityStatsOf: the period's top public dumps, members, public playlists
insertPlaylist({ userId: ann.id, title: "Small" });
const full = insertPlaylist({ userId: bob.id, title: "Full" });
insertPlaylist({ userId: bob.id, title: "Hidden", isPublic: false });
// Bob's private dump isn't counted: every reader gets the same figures.
const secret = insertDump({
userId: bob.id,
title: "Secret",
isPrivate: true,
});
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?);`,
).run(full.id, hit.id, ago(0), full.id, tie.id, ago(0));
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?), (?, ?, 1, ?), (?, ?, 2, ?);`,
).run(
full.id,
hit.id,
ago(0),
full.id,
tie.id,
ago(0),
full.id,
secret.id,
ago(0),
);
const stats = communityStatsOf(...week());
assertEquals(stats.top.map((d) => d.title), ["Hit", "Newer", "Tie"]);

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# comment} other {# comments}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:120
#: src/components/playlist/PlaylistCard.tsx:131
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# dump} other {# dumps}}"
@@ -60,7 +60,7 @@ msgstr "{0}–{1} characters: letters, numbers, or underscores"
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# dump} other {# dumps}}, {upvotes, plural, one {# upvote} other {# upvotes}} and {comments, plural, one {# comment} other {# comments}}."
#: src/pages/Search.tsx:178
#: src/pages/Search.tsx:215
msgid "{label} ({count})"
msgstr "{label} ({count})"
@@ -207,13 +207,13 @@ msgstr "# Welcome to {site}!\n"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Back"
#: src/pages/Dump.tsx:353
#: src/pages/Dump.tsx:378
#: src/pages/Dump.tsx:605
#: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:627
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Back to all dumps"
@@ -226,7 +226,7 @@ msgstr "+ Invite someone"
msgid "+ New playlist"
msgstr "+ New playlist"
#: src/pages/Dump.tsx:452
#: src/pages/Dump.tsx:474
msgid "+ Playlist"
msgstr "+ Playlist"
@@ -345,10 +345,6 @@ msgstr "Admin"
msgid "All"
msgstr "All"
#: src/components/layout/UserMenu.tsx:108
msgid "All playlists"
msgstr "All playlists"
#. placeholder {0}: first.ownerUsername
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/dump/create/DuplicateNotice.tsx:39
@@ -419,8 +415,8 @@ msgstr "by @{username}"
msgid "email.digest.playlistMeta"
msgstr "by @{username} · {n, plural, one {# dump} other {# dumps}}"
#: src/contexts/ws/WSProvider.tsx:376
#: src/contexts/ws/WSProvider.tsx:765
#: src/contexts/ws/WSProvider.tsx:377
#: src/contexts/ws/WSProvider.tsx:770
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."
@@ -440,7 +436,7 @@ msgstr "Can't sign in there"
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:494
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Cancel"
@@ -454,7 +450,7 @@ msgstr "Cancel removal"
msgid "Cancel reply"
msgstr "Cancel reply"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/AppHeader.tsx:181
msgid "Cancel search"
msgstr "Cancel search"
@@ -555,7 +551,7 @@ msgstr "Confirm new password"
msgid "Connecting…"
msgstr "Connecting…"
#: src/contexts/ws/WSProvider.tsx:758
#: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent"
msgstr "Connection lost — this message may not have been sent"
@@ -583,7 +579,7 @@ msgstr "Could not change password"
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."
#: src/components/profile/ProfilePlaylistsTab.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:194
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Could not delete"
@@ -632,6 +628,10 @@ msgstr "Create"
msgid "Create & Add"
msgstr "Create & Add"
#: src/components/playlist/PlaylistCard.tsx:142
msgid "created {ago}"
msgstr "created {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Creating…"
@@ -679,9 +679,9 @@ msgstr "Delete dump"
msgid "Delete failed ({0})"
msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:149
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist"
msgstr "Delete playlist"
@@ -698,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
msgid "Delete this message?"
msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
@@ -753,7 +753,7 @@ msgstr "Drop a file here, or paste one"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/layout/AppHeader.tsx:201
#: src/components/layout/AppHeader.tsx:200
msgid "Dump"
msgstr "Dump"
@@ -770,8 +770,8 @@ msgid "Dumped!"
msgstr "Dumped!"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:174
#: src/pages/UserPublicProfile.tsx:160
#: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:164
msgid "Dumps"
msgstr "Dumps"
@@ -789,7 +789,7 @@ msgstr "Earlier"
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:601
#: src/pages/Dump.tsx:623
msgid "Edit"
msgstr "Edit"
@@ -807,7 +807,7 @@ msgid "Edit email"
msgstr "Edit email"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:511
#: src/pages/Dump.tsx:533
msgid "Edit title"
msgstr "Edit title"
@@ -821,7 +821,7 @@ msgstr "edited"
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:554
#: src/pages/Dump.tsx:576
msgid "edited {0}"
msgstr "edited {0}"
@@ -831,7 +831,7 @@ msgstr "edited {0}"
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:552
#: src/pages/Dump.tsx:574
msgid "Edited {0}"
msgstr "Edited {0}"
@@ -852,7 +852,7 @@ msgstr "Email"
msgid "Email address"
msgstr "Email address"
#: src/pages/Search.tsx:230
#: src/pages/Search.tsx:267
msgid "Enter a query to search."
msgstr "Enter a query to search."
@@ -952,9 +952,9 @@ msgid "Follow some users to see their dumps here."
msgstr "Follow some users to see their dumps here."
#: src/components/feed/FeedTabBar.tsx:61
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163
#: src/pages/UserPublicProfile.tsx:167
msgid "Followed"
msgstr "Followed"
@@ -1025,7 +1025,7 @@ msgstr "If you didn't ask for this, ignore this email. Your password stays the s
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:621
#: src/pages/Dump.tsx:643
msgid "In collections"
msgstr "In collections"
@@ -1071,7 +1071,7 @@ msgid "digest.invitedByYou"
msgstr "invited by you"
#: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164
#: src/pages/UserPublicProfile.tsx:168
msgid "Invitees"
msgstr "Invitees"
@@ -1110,11 +1110,11 @@ msgstr "Link"
msgid "Link or file"
msgstr "Link or file"
#: src/contexts/ws/WSProvider.tsx:764
#: src/contexts/ws/WSProvider.tsx:769
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/layout/AppHeader.tsx:234
#: src/components/layout/AppHeader.tsx:233
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
@@ -1126,7 +1126,7 @@ msgstr "Load more"
msgid "Load older messages"
msgstr "Load older messages"
#: src/pages/Dump.tsx:337
#: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Loading dump…"
@@ -1143,7 +1143,7 @@ msgstr "Loading more…"
msgid "Loading playlist…"
msgstr "Loading playlist…"
#: src/pages/UserPublicProfile.tsx:108
#: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…"
msgstr "Loading profile…"
@@ -1159,7 +1159,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:208
#: src/components/layout/AppHeader.tsx:207
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66
@@ -1180,8 +1180,8 @@ msgstr "Log in to vote"
msgid "Log in with {0}"
msgstr "Log in with {0}"
#: src/components/layout/UserMenu.tsx:139
#: src/pages/UserPublicProfile.tsx:133
#: src/components/layout/UserMenu.tsx:132
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Log out"
@@ -1198,10 +1198,10 @@ msgstr "Login failed"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:126
#: src/components/layout/UserMenu.tsx:119
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
#: src/pages/UserPublicProfile.tsx:170
msgid "Manage"
msgstr "Manage"
@@ -1209,6 +1209,10 @@ msgstr "Manage"
msgid "Max {limit}"
msgstr "Max {limit}"
#: src/components/playlist/PlaylistSortTabs.tsx:34
msgid "Mine"
msgstr "Mine"
#: src/components/profile/RoleSwitcher.tsx:23
msgid "Moderator"
msgstr "Moderator"
@@ -1218,6 +1222,10 @@ msgstr "Moderator"
msgid "digest.monthly"
msgstr "Monthly recap"
#: src/components/playlist/PlaylistSortTabs.tsx:33
msgid "Most dumps"
msgstr "Most dumps"
#: src/components/player/MediaPlayer.tsx:299
msgid "Mute"
msgstr "Mute"
@@ -1229,6 +1237,7 @@ msgstr "Name"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:32
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "New"
@@ -1272,7 +1281,7 @@ msgstr "Next track"
msgid "No dumps in this playlist yet."
msgstr "No dumps in this playlist yet."
#: src/pages/Search.tsx:246
#: src/pages/Search.tsx:283
msgid "No dumps match \"{q}\"."
msgstr "No dumps match \"{q}\"."
@@ -1285,7 +1294,7 @@ msgstr "No dumps yet. Be the first!"
msgid "No emoji found."
msgstr "No emoji found."
#: src/components/profile/ProfileFollowTab.tsx:117
#: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet."
msgstr "No followed playlists yet."
@@ -1301,21 +1310,21 @@ msgstr "No messages yet. Say hello!"
msgid "No one yet."
msgstr "No one yet."
#: src/pages/Search.tsx:287
#: src/pages/Search.tsx:330
msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178
#: src/pages/Playlists.tsx:126
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/Playlists.tsx:180
msgid "No playlists yet."
msgstr "No playlists yet."
#: src/contexts/ws/WSProvider.tsx:934
#: src/contexts/ws/WSProvider.tsx:939
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"
#: src/pages/Search.tsx:261
#: src/pages/Search.tsx:298
msgid "No users match \"{q}\"."
msgstr "No users match \"{q}\"."
@@ -1408,7 +1417,7 @@ msgstr "Or log in with your account on another gerbeur"
msgid "Other instances"
msgstr "Other instances"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:163
msgid "Overview"
msgstr "Overview"
@@ -1468,14 +1477,14 @@ msgid "Playlist categories"
msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:172
#: src/components/layout/UserMenu.tsx:92
#: src/components/profile/ProfileFollowTab.tsx:148
#: src/components/profile/ProfilePlaylistsTab.tsx:149
#: src/pages/Playlists.tsx:53
#: src/pages/Playlists.tsx:114
#: src/pages/Search.tsx:177
#: src/pages/UserPublicProfile.tsx:162
#: src/components/layout/AppHeader.tsx:173
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162
#: src/pages/Playlists.tsx:74
#: src/pages/Playlists.tsx:160
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
msgstr "Playlists"
@@ -1499,10 +1508,10 @@ msgstr "Previous track"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:106
#: src/components/playlist/PlaylistCard.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:560
#: src/pages/Dump.tsx:582
msgid "private"
msgstr "private"
@@ -1510,7 +1519,7 @@ msgstr "private"
msgid "Private"
msgstr "Private"
#: src/components/playlist/PlaylistCard.tsx:105
#: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77
msgid "public"
msgstr "public"
@@ -1545,7 +1554,7 @@ msgstr "Registering…"
msgid "Registration failed"
msgstr "Registration failed"
#: src/pages/Dump.tsx:612
#: src/pages/Dump.tsx:634
msgid "Related"
msgstr "Related"
@@ -1632,7 +1641,7 @@ msgid "email.reset.title"
msgstr "Reset your password"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:371
#: src/pages/Dump.tsx:393
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Retry"
@@ -1647,7 +1656,7 @@ msgstr "Role"
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:486
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Save"
@@ -1657,14 +1666,14 @@ msgstr "Save"
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:485
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:90
#: src/pages/Search.tsx:106
msgid "Search"
msgstr "Search"
@@ -1680,11 +1689,11 @@ msgstr "Search emoji"
msgid "Search emoji…"
msgstr "Search emoji…"
#: src/pages/Search.tsx:241
#: src/pages/Search.tsx:278
msgid "Search failed"
msgstr "Search failed"
#: src/pages/Search.tsx:236
#: src/pages/Search.tsx:273
msgid "Searching…"
msgstr "Searching…"
@@ -1710,7 +1719,7 @@ msgstr "Send reset link"
msgid "Sending…"
msgstr "Sending…"
#: src/components/layout/AppHeader.tsx:198
#: src/components/layout/AppHeader.tsx:197
msgid "Server unreachable"
msgstr "Server unreachable"
@@ -1724,7 +1733,7 @@ msgstr "Set new password"
msgid "email.settings"
msgstr "Settings"
#: src/components/layout/UserMenu.tsx:116
#: src/components/layout/UserMenu.tsx:109
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1766,7 +1775,7 @@ msgstr "Someone asked to reset the password of your {site} account. The button b
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -1831,7 +1840,7 @@ msgstr "The new order couldn't be saved. Try again."
msgid "This comment couldn't be deleted. Try again."
msgstr "This comment couldn't be deleted. Try again."
#: src/pages/Dump.tsx:346
#: src/pages/Dump.tsx:368
msgid "This dump was deleted, or made private."
msgstr "This dump was deleted, or made private."
@@ -1972,6 +1981,14 @@ msgstr "Unsubscribe"
msgid "Unsubscribed"
msgstr "Unsubscribed"
#: src/components/playlist/PlaylistSortTabs.tsx:31
msgid "Updated"
msgstr "Updated"
#: src/components/playlist/PlaylistCard.tsx:141
msgid "updated {ago}"
msgstr "updated {ago}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:237
msgid "email.digest.upload"
@@ -1999,7 +2016,7 @@ msgid "Upvote"
msgstr "Upvote"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161
#: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted"
msgstr "Upvoted"
@@ -2028,7 +2045,7 @@ msgstr "User"
msgid "User management"
msgstr "User management"
#: src/components/layout/UserMenu.tsx:67
#: src/components/layout/UserMenu.tsx:68
msgid "User menu"
msgstr "User menu"
@@ -2040,8 +2057,8 @@ msgstr "User menu"
msgid "Username"
msgstr "Username"
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/pages/Search.tsx:176
#: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213
msgid "Users"
msgstr "Users"
@@ -2151,7 +2168,7 @@ msgstr "You're getting this email because a password reset was requested for thi
msgid "email.welcome.reason"
msgstr "You're getting this email because an account was created on {site} with this address."
#: src/contexts/ws/WSProvider.tsx:915
#: src/contexts/ws/WSProvider.tsx:920
msgid "You're offline — not sent"
msgstr "You're offline — not sent"

File diff suppressed because one or more lines are too long

View File

@@ -24,7 +24,7 @@ msgid "{0, plural, one {# comment} other {# comments}}"
msgstr "{0, plural, one {# commentaire} other {# commentaires}}"
#. placeholder {0}: playlist.dumpCount
#: src/components/playlist/PlaylistCard.tsx:120
#: src/components/playlist/PlaylistCard.tsx:131
msgid "{0, plural, one {# dump} other {# dumps}}"
msgstr "{0, plural, one {# reco} other {# recos}}"
@@ -60,7 +60,7 @@ msgstr "{0}–{1} caractères : lettres, chiffres ou tirets bas"
msgid "email.digest.totals"
msgstr "{dumps, plural, one {# reco} other {# recos}}, {upvotes, plural, one {# vote} other {# votes}} et {comments, plural, one {# commentaire} other {# commentaires}}."
#: src/pages/Search.tsx:178
#: src/pages/Search.tsx:215
msgid "{label} ({count})"
msgstr "{label} ({count})"
@@ -207,13 +207,13 @@ msgstr "# Bienvenue sur {site} !\n"
#: src/components/dump/create/DumpCreateModal.tsx:736
#: src/pages/PlaylistDetail.tsx:98
#: src/pages/UserPublicProfile.tsx:125
#: src/pages/UserPublicProfile.tsx:129
msgid "← Back"
msgstr "← Retour"
#: src/pages/Dump.tsx:353
#: src/pages/Dump.tsx:378
#: src/pages/Dump.tsx:605
#: src/pages/Dump.tsx:375
#: src/pages/Dump.tsx:400
#: src/pages/Dump.tsx:627
#: src/pages/DumpEdit.tsx:234
msgid "← Back to all dumps"
msgstr "← Retour à toutes les recos"
@@ -226,7 +226,7 @@ msgstr "+ Inviter quelqu'un"
msgid "+ New playlist"
msgstr "+ Nouvelle collection"
#: src/pages/Dump.tsx:452
#: src/pages/Dump.tsx:474
msgid "+ Playlist"
msgstr "+ Collection"
@@ -345,10 +345,6 @@ msgstr "Administrateur"
msgid "All"
msgstr "Tout"
#: src/components/layout/UserMenu.tsx:108
msgid "All playlists"
msgstr "Toutes les collections"
#. placeholder {0}: first.ownerUsername
#. placeholder {1}: relativeTime(first.createdAt)
#: src/components/dump/create/DuplicateNotice.tsx:39
@@ -419,8 +415,8 @@ msgstr "par @{username}"
msgid "email.digest.playlistMeta"
msgstr "par @{username} · {n, plural, one {# reco} other {# recos}}"
#: src/contexts/ws/WSProvider.tsx:376
#: src/contexts/ws/WSProvider.tsx:765
#: src/contexts/ws/WSProvider.tsx:377
#: src/contexts/ws/WSProvider.tsx:770
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."
@@ -440,7 +436,7 @@ msgstr "Connexion impossible"
#: src/components/profile/EmailEditor.tsx:83
#: src/components/shared/ConfirmModal.tsx:63
#: src/pages/Authorize.tsx:158
#: src/pages/Dump.tsx:494
#: src/pages/Dump.tsx:516
#: src/pages/DumpEdit.tsx:561
msgid "Cancel"
msgstr "Annuler"
@@ -454,7 +450,7 @@ msgstr "Annuler la suppression"
msgid "Cancel reply"
msgstr "Annuler la réponse"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/AppHeader.tsx:181
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -555,7 +551,7 @@ msgstr "Confirmer le nouveau mot de passe"
msgid "Connecting…"
msgstr "Connexion…"
#: src/contexts/ws/WSProvider.tsx:758
#: src/contexts/ws/WSProvider.tsx:763
msgid "Connection lost — this message may not have been sent"
msgstr "Connexion perdue — ce message n'a peut-être pas été envoyé"
@@ -583,7 +579,7 @@ msgstr "Impossible de changer le mot de passe"
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."
#: src/components/profile/ProfilePlaylistsTab.tsx:171
#: src/components/profile/ProfilePlaylistsTab.tsx:194
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Suppression impossible"
@@ -632,6 +628,10 @@ msgstr "Créer"
msgid "Create & Add"
msgstr "Créer et ajouter"
#: src/components/playlist/PlaylistCard.tsx:142
msgid "created {ago}"
msgstr "créée {ago}"
#: src/components/playlist/PlaylistCreateForm.tsx:121
msgid "Creating…"
msgstr "Création…"
@@ -679,9 +679,9 @@ msgstr "Supprimer la reco"
msgid "Delete failed ({0})"
msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:149
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist"
msgstr "Supprimer la collection"
@@ -698,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:200
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -753,7 +753,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/layout/AppHeader.tsx:201
#: src/components/layout/AppHeader.tsx:200
msgid "Dump"
msgstr "Reco"
@@ -770,8 +770,8 @@ msgid "Dumped!"
msgstr "Recommandé !"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/Search.tsx:174
#: src/pages/UserPublicProfile.tsx:160
#: src/pages/Search.tsx:211
#: src/pages/UserPublicProfile.tsx:164
msgid "Dumps"
msgstr "Recos"
@@ -789,7 +789,7 @@ msgstr "Plus tôt"
#: src/components/chat/PendingItem.tsx:93
#: src/components/comment/CommentNode.tsx:256
#: src/components/playlist/PlaylistHeader.tsx:60
#: src/pages/Dump.tsx:601
#: src/pages/Dump.tsx:623
msgid "Edit"
msgstr "Modifier"
@@ -807,7 +807,7 @@ msgid "Edit email"
msgstr "Modifier l'e-mail"
#: src/components/dump/create/TitleField.tsx:69
#: src/pages/Dump.tsx:511
#: src/pages/Dump.tsx:533
msgid "Edit title"
msgstr "Modifier le titre"
@@ -821,7 +821,7 @@ msgstr "modifié"
#: src/components/chat/ChatMessageItem.tsx:132
#: src/components/comment/CommentNode.tsx:205
#: src/components/playlist/PlaylistHeader.tsx:95
#: src/pages/Dump.tsx:554
#: src/pages/Dump.tsx:576
msgid "edited {0}"
msgstr "modifié {0}"
@@ -831,7 +831,7 @@ msgstr "modifié {0}"
#: src/components/chat/ChatMessageItem.tsx:128
#: src/components/comment/CommentNode.tsx:203
#: src/components/playlist/PlaylistHeader.tsx:93
#: src/pages/Dump.tsx:552
#: src/pages/Dump.tsx:574
msgid "Edited {0}"
msgstr "Modifié le {0}"
@@ -852,7 +852,7 @@ msgstr "E-mail"
msgid "Email address"
msgstr "Adresse e-mail"
#: src/pages/Search.tsx:230
#: src/pages/Search.tsx:267
msgid "Enter a query to search."
msgstr "Saisissez une recherche."
@@ -952,9 +952,9 @@ msgid "Follow some users to see their dumps here."
msgstr "Suivez des utilisateurs pour voir leurs recos ici."
#: src/components/feed/FeedTabBar.tsx:61
#: src/components/profile/ProfileFollowTab.tsx:144
#: src/components/profile/ProfileFollowTab.tsx:143
#: src/pages/Settings.tsx:57
#: src/pages/UserPublicProfile.tsx:163
#: src/pages/UserPublicProfile.tsx:167
msgid "Followed"
msgstr "Suivi"
@@ -1025,7 +1025,7 @@ msgstr "Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. V
msgid "image"
msgstr "image"
#: src/pages/Dump.tsx:621
#: src/pages/Dump.tsx:643
msgid "In collections"
msgstr "Dans les collections"
@@ -1071,7 +1071,7 @@ msgid "digest.invitedByYou"
msgstr "sur votre invitation"
#: src/components/profile/ProfileInviteesTab.tsx:77
#: src/pages/UserPublicProfile.tsx:164
#: src/pages/UserPublicProfile.tsx:168
msgid "Invitees"
msgstr "Invités"
@@ -1110,11 +1110,11 @@ msgstr "Lien"
msgid "Link or file"
msgstr "Lien ou fichier"
#: src/contexts/ws/WSProvider.tsx:764
#: src/contexts/ws/WSProvider.tsx:769
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/layout/AppHeader.tsx:234
#: src/components/layout/AppHeader.tsx:233
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
@@ -1126,7 +1126,7 @@ msgstr "Charger plus"
msgid "Load older messages"
msgstr "Charger les messages plus anciens"
#: src/pages/Dump.tsx:337
#: src/pages/Dump.tsx:359
#: src/pages/DumpEdit.tsx:210
msgid "Loading dump…"
msgstr "Chargement de la reco…"
@@ -1143,7 +1143,7 @@ msgstr "Chargement…"
msgid "Loading playlist…"
msgstr "Chargement de la collection…"
#: src/pages/UserPublicProfile.tsx:108
#: src/pages/UserPublicProfile.tsx:112
msgid "Loading profile…"
msgstr "Chargement du profil…"
@@ -1159,7 +1159,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:208
#: src/components/layout/AppHeader.tsx:207
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66
@@ -1180,8 +1180,8 @@ msgstr "Se connecter pour valider"
msgid "Log in with {0}"
msgstr "Se connecter avec {0}"
#: src/components/layout/UserMenu.tsx:139
#: src/pages/UserPublicProfile.tsx:133
#: src/components/layout/UserMenu.tsx:132
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Se déconnecter"
@@ -1198,10 +1198,10 @@ msgstr "Connexion échouée"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:126
#: src/components/layout/UserMenu.tsx:119
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:166
#: src/pages/UserPublicProfile.tsx:170
msgid "Manage"
msgstr "Administration"
@@ -1209,6 +1209,10 @@ msgstr "Administration"
msgid "Max {limit}"
msgstr "Max {limit}"
#: src/components/playlist/PlaylistSortTabs.tsx:34
msgid "Mine"
msgstr "Les miennes"
#: src/components/profile/RoleSwitcher.tsx:23
msgid "Moderator"
msgstr "Modérateur"
@@ -1218,6 +1222,10 @@ msgstr "Modérateur"
msgid "digest.monthly"
msgstr "Récap mensuel"
#: src/components/playlist/PlaylistSortTabs.tsx:33
msgid "Most dumps"
msgstr "Les plus fournies"
#: src/components/player/MediaPlayer.tsx:299
msgid "Mute"
msgstr "Couper le son"
@@ -1229,6 +1237,7 @@ msgstr "Nom"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:32
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "Nouveau"
@@ -1272,7 +1281,7 @@ msgstr "Piste suivante"
msgid "No dumps in this playlist yet."
msgstr "Aucune reco dans cette collection pour l'instant."
#: src/pages/Search.tsx:246
#: src/pages/Search.tsx:283
msgid "No dumps match \"{q}\"."
msgstr "Aucune reco ne correspond à « {q} »."
@@ -1285,7 +1294,7 @@ msgstr "Pas encore de recos. Soyez le premier !"
msgid "No emoji found."
msgstr "Aucun emoji trouvé."
#: src/components/profile/ProfileFollowTab.tsx:117
#: src/components/profile/ProfileFollowTab.tsx:116
msgid "No followed playlists yet."
msgstr "Pas encore de collections suivies."
@@ -1301,21 +1310,21 @@ msgstr "Aucun message pour l'instant. Dites bonjour !"
msgid "No one yet."
msgstr "Personne pour le moment."
#: src/pages/Search.tsx:287
#: src/pages/Search.tsx:330
msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:178
#: src/pages/Playlists.tsx:126
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/Playlists.tsx:180
msgid "No playlists yet."
msgstr "Pas encore de collections."
#: src/contexts/ws/WSProvider.tsx:934
#: src/contexts/ws/WSProvider.tsx:939
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é"
#: src/pages/Search.tsx:261
#: src/pages/Search.tsx:298
msgid "No users match \"{q}\"."
msgstr "Aucun utilisateur ne correspond à « {q} »."
@@ -1408,7 +1417,7 @@ msgstr "Ou connectez-vous avec votre compte d'un autre gerbeur"
msgid "Other instances"
msgstr "Autres instances"
#: src/pages/UserPublicProfile.tsx:159
#: src/pages/UserPublicProfile.tsx:163
msgid "Overview"
msgstr "Aperçu"
@@ -1468,14 +1477,14 @@ msgid "Playlist categories"
msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:172
#: src/components/layout/UserMenu.tsx:92
#: src/components/profile/ProfileFollowTab.tsx:148
#: src/components/profile/ProfilePlaylistsTab.tsx:149
#: src/pages/Playlists.tsx:53
#: src/pages/Playlists.tsx:114
#: src/pages/Search.tsx:177
#: src/pages/UserPublicProfile.tsx:162
#: src/components/layout/AppHeader.tsx:173
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:162
#: src/pages/Playlists.tsx:74
#: src/pages/Playlists.tsx:160
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
msgstr "Collections"
@@ -1499,10 +1508,10 @@ msgstr "Piste précédente"
#: src/components/dump/DumpCard.tsx:144
#: src/components/dump/JournalCard.tsx:145
#: src/components/playlist/PlaylistCard.tsx:106
#: src/components/playlist/PlaylistCard.tsx:117
#: src/components/playlist/PlaylistHeader.tsx:77
#: src/components/playlist/PlaylistMembershipPanel.tsx:81
#: src/pages/Dump.tsx:560
#: src/pages/Dump.tsx:582
msgid "private"
msgstr "privé"
@@ -1510,7 +1519,7 @@ msgstr "privé"
msgid "Private"
msgstr "Privé"
#: src/components/playlist/PlaylistCard.tsx:105
#: src/components/playlist/PlaylistCard.tsx:116
#: src/components/playlist/PlaylistHeader.tsx:77
msgid "public"
msgstr "public"
@@ -1545,7 +1554,7 @@ msgstr "Inscription…"
msgid "Registration failed"
msgstr "Inscription échouée"
#: src/pages/Dump.tsx:612
#: src/pages/Dump.tsx:634
msgid "Related"
msgstr "Connexe"
@@ -1632,7 +1641,7 @@ msgid "email.reset.title"
msgstr "Réinitialiser votre mot de passe"
#: src/components/chat/PendingItem.tsx:86
#: src/pages/Dump.tsx:371
#: src/pages/Dump.tsx:393
#: src/pages/DumpEdit.tsx:227
msgid "Retry"
msgstr "Réessayer"
@@ -1647,7 +1656,7 @@ msgstr "Rôle"
#: src/components/playlist/PlaylistEditForm.tsx:229
#: src/components/profile/DescriptionEditor.tsx:89
#: src/components/profile/EmailEditor.tsx:75
#: src/pages/Dump.tsx:486
#: src/pages/Dump.tsx:508
#: src/pages/DumpEdit.tsx:564
msgid "Save"
msgstr "Enregistrer"
@@ -1657,14 +1666,14 @@ msgstr "Enregistrer"
#: src/components/profile/ChangePasswordModal.tsx:119
#: src/components/profile/DescriptionEditor.tsx:86
#: src/components/profile/EmailEditor.tsx:72
#: src/pages/Dump.tsx:485
#: src/pages/Dump.tsx:507
#: src/pages/ResetPassword.tsx:191
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:182
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:90
#: src/pages/Search.tsx:106
msgid "Search"
msgstr "Rechercher"
@@ -1680,11 +1689,11 @@ msgstr "Rechercher un emoji"
msgid "Search emoji…"
msgstr "Rechercher un emoji…"
#: src/pages/Search.tsx:241
#: src/pages/Search.tsx:278
msgid "Search failed"
msgstr "Recherche échouée"
#: src/pages/Search.tsx:236
#: src/pages/Search.tsx:273
msgid "Searching…"
msgstr "Recherche…"
@@ -1710,7 +1719,7 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…"
msgstr "Envoi…"
#: src/components/layout/AppHeader.tsx:198
#: src/components/layout/AppHeader.tsx:197
msgid "Server unreachable"
msgstr "Serveur inaccessible"
@@ -1724,7 +1733,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "email.settings"
msgstr "Paramètres"
#: src/components/layout/UserMenu.tsx:116
#: src/components/layout/UserMenu.tsx:109
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1766,7 +1775,7 @@ msgstr "Quelqu'un a demandé à réinitialiser le mot de passe de votre compte {
#: src/components/form/FormError.tsx:31
#: src/components/layout/PageError.tsx:29
#: src/contexts/ws/WSProvider.tsx:733
#: src/contexts/ws/WSProvider.tsx:738
#: src/pages/PlaylistDetail.tsx:155
msgid "Something went wrong"
msgstr "Une erreur est survenue"
@@ -1831,7 +1840,7 @@ msgstr "Le nouvel ordre n'a pas pu être enregistré. Réessayez."
msgid "This comment couldn't be deleted. Try again."
msgstr "Ce commentaire n'a pas pu être supprimé. Réessayez."
#: src/pages/Dump.tsx:346
#: src/pages/Dump.tsx:368
msgid "This dump was deleted, or made private."
msgstr "Cette reco a été supprimée, ou rendue privée."
@@ -1972,6 +1981,14 @@ msgstr "Se désabonner"
msgid "Unsubscribed"
msgstr "Désabonné"
#: src/components/playlist/PlaylistSortTabs.tsx:31
msgid "Updated"
msgstr "Mises à jour"
#: src/components/playlist/PlaylistCard.tsx:141
msgid "updated {ago}"
msgstr "mise à jour {ago}"
#. js-lingui-explicit-id
#: api/mail/digest.ts:237
msgid "email.digest.upload"
@@ -1999,7 +2016,7 @@ msgid "Upvote"
msgstr "Valider"
#: src/components/profile/ProfileDumpsTab.tsx:46
#: src/pages/UserPublicProfile.tsx:161
#: src/pages/UserPublicProfile.tsx:165
msgid "Upvoted"
msgstr "Validé"
@@ -2028,7 +2045,7 @@ msgstr "Utilisateur"
msgid "User management"
msgstr "Gestion utilisateur"
#: src/components/layout/UserMenu.tsx:67
#: src/components/layout/UserMenu.tsx:68
msgid "User menu"
msgstr "Menu utilisateur"
@@ -2040,8 +2057,8 @@ msgstr "Menu utilisateur"
msgid "Username"
msgstr "Nom d'utilisateur"
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/pages/Search.tsx:176
#: src/components/profile/ProfileFollowTab.tsx:146
#: src/pages/Search.tsx:213
msgid "Users"
msgstr "Utilisateurs"
@@ -2151,7 +2168,7 @@ msgstr "Vous recevez cet e-mail car une réinitialisation de mot de passe a ét
msgid "email.welcome.reason"
msgstr "Vous recevez cet e-mail car un compte {site} a été créé avec cette adresse."
#: src/contexts/ws/WSProvider.tsx:915
#: src/contexts/ws/WSProvider.tsx:920
msgid "You're offline — not sent"
msgstr "Hors ligne — message non envoyé"

View File

@@ -1,7 +1,8 @@
/**
* Flattens the Markdown users write into one line of plain text, where a
* quote of it can't render markup: the chat's reply previews and the digest
* email's quotes of a comment or a note.
* quote of it can't render markup: the chat's reply previews, the digest
* email's quotes of a comment or a note, and the line of a description on a
* playlist's card and on a user in the search results.
*
* @module
*/

View File

@@ -6,7 +6,9 @@
* categories. Under a user each segment is a fixed tab name, and under `dumps`
* or `playlists` a slug. A view of a page is one more segment, and a default
* view is the bare path. A category narrowing a list on another page, a
* profile's or a search's, is a query, `?category=`, not a segment.
* profile's or a search's, is a query, `?category=`, not a segment, and so is
* the order of a list of playlists, `?sort=`, the default order being the
* bare path, and the playlists page's list of the viewer's own, `?mine=1`.
*
* The client links every page through the builders (`src/utils/shared/urls.ts`
* wraps the dump and playlist ones), the emails prefix them with the
@@ -19,6 +21,11 @@
*/
import type { CategoryKind } from "./category.ts";
import {
parsePlaylistSort,
PLAYLIST_SORTS,
type PlaylistSort,
} from "./playlist.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.ts";
@@ -64,6 +71,15 @@ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
/** One of the {@link SEARCH_TABS}. */
export type SearchTab = (typeof SEARCH_TABS)[number];
/**
* The tabs of the playlists page, in the tab bar's order: each order of every
* public playlist, `?sort=`, then the viewer's own playlists, `?mine=1`,
* which only a signed-in user is offered.
*/
export const PLAYLISTS_TABS = [...PLAYLIST_SORTS, "mine"] as const;
/** One of the {@link PLAYLISTS_TABS}. */
export type PlaylistsTab = (typeof PLAYLISTS_TABS)[number];
/**
* The views of the community page, one per digest cadence: the week at
* `/community`, the month at `/community/monthly`.
@@ -142,10 +158,40 @@ export function feedPath(
/**
* Returns the path of the page of every public playlist, `/playlists`, or of a
* playlist category's at `/playlists/<slug>`.
* playlist category's at `/playlists/<slug>`, on the tab `tab` names: an
* order, the default one without it, or the viewer's own playlists.
*/
export function playlistsPath(categorySlug?: string): string {
return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
export function playlistsPath(
categorySlug?: string,
tab?: PlaylistsTab,
): string {
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
return `${path}${tab === "mine" ? "?mine=1" : playlistSortQuery(tab)}`;
}
/**
* Returns the tab of the playlists page its query names: the viewer's own
* playlists with a `mine`, else the order `sort` names, as
* `parsePlaylistSort` reads it.
*/
export function parsePlaylistsTab(params: URLSearchParams): PlaylistsTab {
return params.has("mine") ? "mine" : parsePlaylistSort(params.get("sort"));
}
/**
* Returns the query string that puts a list of playlists in the order `sort`
* names, with its `?`, or `""` for the default order. `extra` holds the
* list's other parameters, such as a profile's `category`.
*/
export function playlistSortQuery(
sort: PlaylistSort = PLAYLIST_SORTS[0],
extra?: URLSearchParams,
): string {
const params = new URLSearchParams(extra);
if (sort === PLAYLIST_SORTS[0]) params.delete("sort");
else params.set("sort", sort);
const query = params.toString();
return query ? `?${query}` : "";
}
/**

View File

@@ -8,6 +8,26 @@
import type { Dump } from "./dump.ts";
/**
* The orders a list of playlists comes in, the first being the default one:
* `updated`, by {@link playlistActivity}, the latest first; `new`, by
* creation, the newest first; `dumps`, the fullest first. `GET /api/playlists`
* and `GET /api/users/:key/playlists` take one as `?sort=`, the `/playlists`
* page and a profile's playlists tab show them as tabs and keep the one
* picked in their URL's `?sort=`.
*/
export const PLAYLIST_SORTS = ["updated", "new", "dumps"] as const;
/** One of the {@link PLAYLIST_SORTS}. */
export type PlaylistSort = (typeof PLAYLIST_SORTS)[number];
/**
* Returns the order a `?sort=` names, the default one when it is missing or
* names none: like a page number, an order never fails a list.
*/
export function parsePlaylistSort(value: string | null): PlaylistSort {
return PLAYLIST_SORTS.find((sort) => sort === value) ?? PLAYLIST_SORTS[0];
}
/**
* Returned by the playlist routes, and by the search, users and dumps routes
* that list playlists. The `playlist_created` and `playlist_updated` WebSocket
@@ -40,10 +60,16 @@ export interface Playlist {
/** It is undefined when the playlist has no cover. */
imageMime?: string;
/**
* It counts the dumps the viewer can't see too. It is undefined when the
* query didn't count them.
* It counts the dumps the viewer may see in it, as its page lists them. It
* is undefined when the query didn't count them.
*/
dumpCount?: number;
/**
* When the latest of the dumps the viewer may see was added to it, which
* {@link playlistActivity} reads. It is undefined when it holds none, or
* when the query didn't count them.
*/
lastDumpAt?: Date;
/** The owner's current name, which the playlist's path starts with. */
ownerUsername: string;
/**
@@ -53,6 +79,40 @@ export interface Playlist {
categoryIds?: string[];
}
/**
* Returns when `playlist` last took a dump, or was created if it holds none:
* what the `updated` order sorts by and the cards show. Its title, cover and
* description may change after without bringing it back up a list.
*/
export function playlistActivity(
playlist: Pick<Playlist, "createdAt" | "lastDumpAt">,
): Date {
return playlist.lastDumpAt ?? playlist.createdAt;
}
/**
* Returns the comparison that puts playlists in the order `sort` names, as
* the API pages them, the newer first on a tie. `usePlaylistListSync` finds
* with it where a playlist the socket announces goes in a list.
*/
export function comparePlaylists(
sort: PlaylistSort,
): (a: Playlist, b: Playlist) => number {
const byCreation = (a: Playlist, b: Playlist) =>
b.createdAt.getTime() - a.createdAt.getTime();
switch (sort) {
case "updated":
return (a, b) =>
playlistActivity(b).getTime() - playlistActivity(a).getTime() ||
byCreation(a, b);
case "dumps":
return (a, b) =>
(b.dumpCount ?? 0) - (a.dumpCount ?? 0) || byCreation(a, b);
case "new":
return byCreation;
}
}
/**
* Returned by `GET /api/users/:key/playlists/:slug` to `PlaylistDetail`, and
* by `GET /api/playlists/:playlistId`, with the dumps the caller may see, in

View File

@@ -3,7 +3,10 @@ import {
dumpPath,
feedPath,
parsePagePath,
parsePlaylistsTab,
playlistPath,
playlistSortQuery,
playlistsPath,
searchPath,
userPath,
} from "../paths.ts";
@@ -37,6 +40,27 @@ Deno.test("builders: one hierarchy, default views bare", () => {
assertEquals(searchPath(), "/search");
assertEquals(searchPath("users", "a b"), "/search/users?q=a%20b");
assertEquals(searchPath("dumps", "x"), "/search?q=x");
assertEquals(playlistsPath(), "/playlists");
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new");
assertEquals(playlistsPath("mixes", "mine"), "/playlists/mixes?mine=1");
// The page's tab is one or the other: its own playlists come in one order.
const tab = (query: string) => parsePlaylistsTab(new URLSearchParams(query));
assertEquals(tab(""), "updated");
assertEquals(tab("sort=dumps"), "dumps");
assertEquals(tab("sort=nope"), "updated");
assertEquals(tab("mine=1"), "mine");
assertEquals(tab("mine=1&sort=dumps"), "mine");
// The query of a list that has other parameters: the default order takes
// an old `sort` out, and leaves the rest.
const category = new URLSearchParams({ category: "mixes", sort: "new" });
assertEquals(
playlistSortQuery("dumps", category),
"?category=mixes&sort=dumps",
);
assertEquals(playlistSortQuery("updated", category), "?category=mixes");
assertEquals(playlistSortQuery("updated"), "");
});
Deno.test("parsePagePath: what a path under a user names", () => {

View File

@@ -281,6 +281,22 @@ export interface DumpDeletedMessage {
dumpId: string;
}
/**
* Sent by the server when the dumps related to some dumps changed: a note or
* a comment gained or lost a link to a dump, or a dump that has such links
* turned public. It goes to the sockets whose user may see both ends of a
* link. The `Dump` page showing one of `dumpIds` fetches its related dumps
* again.
*/
export interface DumpRelatedChangedMessage {
type: "dump_related_changed";
/**
* The dump whose links changed, then the dumps at their other end, without
* those this socket's user may not see.
*/
dumpIds: string[];
}
/**
* Sent by the server when a playlist is created, to every socket if it is
* public, else to its owner's. It also goes to everyone but the owner when a
@@ -295,7 +311,8 @@ export interface PlaylistCreatedMessage {
/**
* Sent by the server when a playlist changes, to every socket if it is public,
* else to its owner's, as when it has just gone private.
* else to its owner's, as when it has just gone private. Its `dumpCount` and
* `lastDumpAt` are the socket's user's, as in `playlist_created`.
*/
export interface PlaylistUpdatedMessage {
type: "playlist_updated";
@@ -325,8 +342,11 @@ export interface PlaylistDumpsUpdatedMessage {
* private dump is left out.
*/
dumpIds: string[];
/** Counts every dump, hidden ones included, as `Playlist.dumpCount` does. */
dumpCount: number;
/**
* When the latest of them was added, the playlist's `lastDumpAt` for this
* socket's user. It is missing when `dumpIds` is empty.
*/
lastDumpAt?: Date;
}
/**
@@ -488,6 +508,7 @@ export type ServerToClientMessage =
| DumpCreatedMessage
| DumpUpdatedMessage
| DumpDeletedMessage
| DumpRelatedChangedMessage
| PlaylistCreatedMessage
| PlaylistUpdatedMessage
| PlaylistDeletedMessage

View File

@@ -2289,6 +2289,14 @@ body.has-player .chat-fab {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
/* A list's order tabs, as the playlists', take a line of their own under the
title where the row has no room for both, rather than scroll beside it. */
.profile-section-header:has(.playlist-sort-tabs) {
flex-wrap: wrap;
row-gap: 0.25rem;
}
/* ── Profile (own) page ── */
@@ -2761,11 +2769,9 @@ body.has-player .chat-fab {
padding: 1rem;
}
/* A list of dump or playlist cards in a group, or right under a page's header
as on /playlists, fills the column, its edges in line with the label's rule
or the header. */
.page-group .dump-feed,
.page-content--narrow > .dump-feed {
/* A list of dump or playlist cards in a group fills the column, its edges in
line with the label's rule. */
.page-group .dump-feed {
padding: 0;
max-width: none;
}
@@ -3109,16 +3115,39 @@ body.has-player .chat-fab {
--nav-icon-tint-strength: 100%;
}
/* Text links — visible only at wide viewports */
/* The text link to every playlist. A guest's header holds no other, so
theirs shows at every width. Signed in, the avatar's menu holds one too,
and this one shows where the row has room for it beside the icons: from
560px, where it fits with the "+ Dump" label in every style and language,
up to 860px, where the index page's tabs move into the header, and again
from 1150px, where the header holds both. */
.nav-links {
display: none;
display: flex;
align-items: center;
gap: 0.6rem;
}
@media (min-width: 1150px) {
.nav-links {
display: flex;
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
.app-header:not(.app-header--guest) .nav-links {
display: none;
}
}
/* A guest's row is the link, the search and "Log in": on the narrowest
phones it gives up its margins rather than the link. */
@media (max-width: 400px) {
.app-header--guest {
gap: 0.5rem;
padding: 0 0.75rem;
}
.app-header--guest .app-header-nav {
gap: 0.4rem;
}
.app-header--guest .app-header-nav button,
.app-header--guest .app-header-user {
padding-inline: 0.6rem;
}
}
@@ -4064,9 +4093,8 @@ body.has-player .chat-fab {
0.25);
}
/* ── Shared card preview icon ── */
.dump-card-preview-icon,
.playlist-card-icon {
/* ── Dump card preview icon ── */
.dump-card-preview-icon {
font-size: var(--text-3xl);
opacity: 0.7;
line-height: 1;
@@ -4355,6 +4383,82 @@ body.has-player .chat-fab {
}
}
/* ── Playlist grid ──
The playlists page shows its cards as tiles, the cover across the tile's
top and the text under it: the same card as in a .dump-feed, laid out by
the list it is in, so every theme's card skin carries over. */
.playlist-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
gap: 1rem;
}
.playlist-grid > li {
animation: card-enter 0.2s ease both;
}
/* The tiles of a row are as tall as the tallest, each one's text starting at
the same height under covers of one size. */
.playlist-grid .playlist-card {
display: flex;
}
.playlist-grid .playlist-card-inner {
flex: 1;
flex-direction: column;
align-items: stretch;
gap: 0.65rem;
padding: 0.6rem 0.6rem 0.8rem;
}
.playlist-grid .playlist-card-preview {
width: 100%;
height: auto;
aspect-ratio: 1;
}
/* A cover this size fills the tile: grown as a thumb is, it would spill over
the card's edge. */
.playlist-grid .playlist-card-inner:hover .playlist-card-preview {
transform: none;
}
.playlist-grid .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-display);
}
.playlist-grid .playlist-card-body {
margin-left: 0;
padding-inline: 0.15rem;
}
.playlist-grid .playlist-card-title,
.playlist-grid .playlist-card-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Owner, count and date wrap whole rather than run past a narrow tile. */
.playlist-grid .playlist-card-meta {
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 0.1rem 0.6rem;
}
@media (max-width: 512px) {
.playlist-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
}
.dump-card-vote {
flex-shrink: 0;
align-self: center;
@@ -5723,7 +5827,7 @@ body.has-player .chat-fab {
align-self: center;
}
.collections-section .playlist-card-icon {
.collections-section .playlist-card-preview .thumb-placeholder-initials {
font-size: var(--text-lg);
}
@@ -7718,6 +7822,9 @@ body.has-player .chat-fab {
.community-figure {
display: flex;
flex-direction: column-reverse;
/* In column-reverse, flex-end is the top: the figures stay on one line
when a label wraps to two. */
justify-content: flex-end;
align-items: center;
gap: 0.2rem;
min-width: 0;

View File

@@ -23,7 +23,8 @@
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
* playlist category's (`Playlists`).
* playlist category's, in the order `?sort=` names, or with `?mine=1` the
* signed-in user's own (`Playlists`).
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`

View File

@@ -17,7 +17,7 @@ import { UserMenu } from "./UserMenu.tsx";
import { SearchBar } from "./SearchBar.tsx";
import { IconClose, IconSearch } from "./NavIcons.tsx";
import { SITE_EMOJI, SITE_NAME } from "../../hooks/shared/useDocumentTitle.ts";
import { searchPath, userPath } from "../../../shared/paths.ts";
import { playlistsPath, searchPath } from "../../../shared/paths.ts";
/** Loaded on first open, so its editor stays out of every page's bundle. */
const DumpCreateModal = lazy(() =>
@@ -62,9 +62,10 @@ export interface AppHeaderProps {
/**
* Renders the site's header. It holds the search, which stays open on the
* results page and slides open under the header on small screens, the
* signed-in user's links and buttons, and a banner while live updates are down.
* It also hosts the dump creation dialog that its "+ Dump" button opens.
* results page and slides open under the header on small screens, the link to
* the playlists page, the signed-in user's menu and buttons or a guest's
* "Log in", and a banner while live updates are down. It also hosts the dump
* creation dialog that its "+ Dump" button opens.
*/
export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
@@ -142,7 +143,11 @@ export function AppHeader(
return (
<>
<header className="app-header app-header--has-center">
<header
className={`app-header app-header--has-center${
user ? "" : " app-header--guest"
}`}
>
<Link to="/" className="app-header-brand">
{SITE_EMOJI}
<span className="app-header-brand-name">
@@ -158,23 +163,17 @@ export function AppHeader(
<nav className="app-header-nav">
{
/* The user's own links lead the row, so the icons after them run
unbroken to the "+": Playlists on wide screens, then the
avatar's menu, which leads to their profile, at every width. */
/* The links lead the row, so the icons after them run unbroken to
the "+": the playlists page, at every width for a guest and
where the row has room for a signed-in user, then the avatar's
menu, which leads there too, at every width. */
}
{user && (
<>
<div className="nav-links">
<Link
to={userPath(user.username, "playlists")}
className="app-header-user"
>
<Trans>Playlists</Trans>
</Link>
</div>
<UserMenu user={user} />
</>
)}
<div className="nav-links">
<Link to={playlistsPath()} className="app-header-user">
<Trans>Playlists</Trans>
</Link>
</div>
{user && <UserMenu user={user} />}
<button
ref={searchToggleRef}
type="button"

View File

@@ -17,14 +17,15 @@ export interface PageHeaderProps {
title: ReactNode;
/**
* It goes at the end of the row, such as the notifications' unread count or
* the playlists' category menu.
* the playlists' category menu and order tabs.
*/
children?: ReactNode;
}
/**
* Renders the page's h1 with its icon, then `children`, as the first block of
* a narrow `PageShell`; `PageGroup`s follow it.
* a `PageShell`: a narrow one's `PageGroup`s follow it, or the playlists
* page's grid.
*/
export function PageHeader(
{ icon, title, children }: PageHeaderProps,

View File

@@ -1,7 +1,8 @@
/**
* Renders the avatar menu that leads the header's icon row at every width: it
* holds the profile link, the playlists one (on small screens, the only one),
* the community page, the settings, the site's tools, and logging out.
* holds the profile link, the playlists page (the header's text link leads
* there too, at the widths that have room for it), the community page, the
* settings, the site's tools, and logging out.
*
* @module
*/
@@ -29,7 +30,7 @@ export interface UserMenuProps {
/**
* Renders the signed-in user's avatar, which opens a menu with links to their
* profile, their playlists, `/community` and `/settings`, to `/manage` for
* profile, `/playlists`, `/community` and `/settings`, to `/manage` for
* those who hold `category:manage` or `federation:manage`, and a Log out
* button. A click outside or Escape closes it.
*/
@@ -84,7 +85,7 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
@{user.username}
</Link>
<Link
to={userPath(user.username, "playlists")}
to={playlistsPath()}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
@@ -99,14 +100,6 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
>
{i18n._(DIGEST_MESSAGES.community)}
</Link>
<Link
to={playlistsPath()}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
>
<Trans>All playlists</Trans>
</Link>
<Link
to="/settings"
className="user-menu-item"

View File

@@ -5,21 +5,24 @@
* @module
*/
import { Link, useNavigate } from "react-router";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import type { Playlist } from "../../../shared/playlist.ts";
import { type Playlist, playlistActivity } from "../../../shared/playlist.ts";
import { relativeTime } from "../../utils/shared/relativeTime.ts";
import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
import { useCardLink } from "../../hooks/shared/useCardLink.ts";
import {
isPlaylistVisited,
isRecent,
markPlaylistVisited,
} from "../../utils/shared/visited.ts";
import { ThumbnailPlaceholder } from "../shared/ThumbnailPlaceholder.tsx";
import { Tooltip } from "../shared/Tooltip.tsx";
import { CategoryChips } from "../category/CategoryChips.tsx";
import { userPath } from "../../../shared/paths.ts";
import { markdownToPlainText } from "../../../shared/markdown.ts";
/** Passed to {@link PlaylistCard} by each list of playlists. */
export interface PlaylistCardProps {
@@ -40,31 +43,34 @@ export interface PlaylistCardProps {
}
/**
* Renders a playlist as a card that opens the playlist when clicked, with the
* playlist categories it is filed under. Another user's recent playlist shows
* an unread dot until it is opened on this device. The card is an `<li>`, for
* a `<ul>`.
* Renders a playlist as a card that opens the playlist when clicked, in a new
* tab on a middle click (`useCardLink`), with its cover, or a tile of its own
* colour without one, the playlist categories it is filed under and when it
* last took a dump. Another user's recent playlist shows an unread dot until
* it is opened on this device. The card is an `<li>`, for a `<ul>`, whose
* class lays it out: a row in a `.dump-feed`, a tile in a `.playlist-grid`.
*/
export function PlaylistCard(
{ playlist, onDelete, isOwner, privateBadgeOnly }: PlaylistCardProps,
): React.JSX.Element {
const navigate = useNavigate();
const { mediaToken } = useAuth();
const { cardProps } = useCardLink(
playlistUrl(playlist),
() => markPlaylistVisited(playlist.id),
);
const unread = !isOwner && isRecent(playlist.createdAt) &&
!isPlaylistVisited(playlist.id);
const showBadge = !privateBadgeOnly || (!!isOwner && !playlist.isPublic);
function handleNavigate() {
markPlaylistVisited(playlist.id);
navigate(playlistUrl(playlist));
}
// The playlist's page renders the description's Markdown; the card has
// room for its text only, and none at all when it is a lone image.
const description = playlist.description &&
markdownToPlainText(playlist.description, "");
const activity = playlistActivity(playlist);
const ago = relativeTime(activity);
return (
<li className="playlist-card">
<div
className="playlist-card-inner"
onClick={handleNavigate}
>
<div className="playlist-card-inner" {...cardProps}>
<div className="playlist-card-preview">
{playlist.imageMime
? (
@@ -76,7 +82,12 @@ export function PlaylistCard(
decoding="async"
/>
)
: <span className="playlist-card-icon">📋</span>}
: (
<ThumbnailPlaceholder
seed={playlist.id}
siteName={playlist.title}
/>
)}
</div>
<div className="playlist-card-body">
<Link
@@ -91,8 +102,8 @@ export function PlaylistCard(
{unread && <span className="unread-dot" aria-hidden="true" />}
{playlist.title}
</Link>
{playlist.description && (
<p className="playlist-card-description">{playlist.description}</p>
{description && (
<p className="playlist-card-description">{description}</p>
)}
<div className="playlist-card-meta">
{showBadge && (
@@ -124,9 +135,11 @@ export function PlaylistCard(
/>
</span>
)}
<Tooltip text={playlist.createdAt.toLocaleString()}>
<time dateTime={playlist.createdAt.toISOString()}>
{relativeTime(playlist.createdAt)}
<Tooltip text={activity.toLocaleString()}>
<time dateTime={activity.toISOString()}>
{playlist.lastDumpAt
? <Trans>updated {ago}</Trans>
: <Trans>created {ago}</Trans>}
</time>
</Tooltip>
</div>

View File

@@ -15,18 +15,24 @@ export interface PlaylistCardListProps {
onDelete?: (playlist: Playlist) => void;
/** Passed to each {@link PlaylistCard}. */
privateBadgeOnly?: boolean;
/**
* Lays the cards out as tiles, cover first, several to a row, for a page
* that is a list of playlists and nothing else: the playlists page.
*/
grid?: boolean;
}
/**
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, a card being
* its owner's when the playlist is the signed-in user's.
* Renders `playlists` as a `.dump-feed` of {@link PlaylistCard}, one to a
* row, or as a `.playlist-grid` of them, a card being its owner's when the
* playlist is the signed-in user's.
*/
export function PlaylistCardList(
{ playlists, onDelete, privateBadgeOnly }: PlaylistCardListProps,
{ playlists, onDelete, privateBadgeOnly, grid }: PlaylistCardListProps,
): React.JSX.Element {
const { user } = useAuth();
return (
<ul className="dump-feed">
<ul className={grid ? "playlist-grid" : "dump-feed"}>
{playlists.map((p) => (
<PlaylistCard
key={p.id}

View File

@@ -0,0 +1,50 @@
/**
* Renders the tabs that put a list of playlists in another order, on the
* playlists page and on a profile's playlists tab, and the playlists page's
* tab of the viewer's own.
*
* @module
*/
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import type { PlaylistsTab } from "../../../shared/paths.ts";
import { TabBar } from "../shared/TabBar.tsx";
/**
* Passed to {@link PlaylistSortTabs} by `ProfilePlaylistsTab`, whose tabs
* are `PLAYLIST_SORTS`, and by `Playlists`, whose `PLAYLISTS_TABS` add the
* viewer's own playlists for a signed-in user. Both keep the active one in
* their URL's query.
*/
export interface PlaylistSortTabsProps<T extends PlaylistsTab> {
/** The tabs offered, in order. */
tabs: readonly T[];
/** The active tab. */
sort: T;
onChange: (sort: T) => void;
/** Goes before the tabs, as the playlists page's category menu does. */
leading?: ReactNode;
}
const LABELS: Record<PlaylistsTab, ReactNode> = {
updated: <Trans>Updated</Trans>,
new: <Trans>New</Trans>,
dumps: <Trans>Most dumps</Trans>,
mine: <Trans>Mine</Trans>,
};
/** Renders one tab per entry of `tabs`, `sort` being the active one. */
export function PlaylistSortTabs<T extends PlaylistsTab>(
{ tabs, sort, onChange, leading }: PlaylistSortTabsProps<T>,
): React.JSX.Element {
return (
<TabBar
tabs={tabs.map((key) => ({ key, label: LABELS[key] }))}
activeTab={sort}
onChange={onChange}
leading={leading}
className="playlist-sort-tabs"
/>
);
}

View File

@@ -106,7 +106,6 @@ function FollowedPlaylists({ userId }: { userId: string }) {
usePlaylistListSync(
state.status === "loaded" ? state.data.items : [],
setItems,
{ noNewEntries: true },
);
return (
<ListState

View File

@@ -11,7 +11,13 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../../config/api.ts";
import { userApiUrl } from "../../utils/shared/urls.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import {
parsePlaylistSort,
type Playlist,
PLAYLIST_SORTS,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { playlistSortQuery } from "../../../shared/paths.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
canLoadMore,
@@ -39,6 +45,7 @@ import { useCategoryFilter } from "../../hooks/category/useCategoryFilter.ts";
import { CategorySwitcher } from "../category/CategorySwitcher.tsx";
import { NewPlaylistButton } from "../playlist/NewPlaylistButton.tsx";
import { PlaylistCardList } from "../playlist/PlaylistCardList.tsx";
import { PlaylistSortTabs } from "../playlist/PlaylistSortTabs.tsx";
import { FeedFooter } from "../feed/FeedFooter.tsx";
import { ConfirmModal } from "../shared/ConfirmModal.tsx";
import { ErrorCard } from "../shared/ErrorCard.tsx";
@@ -53,19 +60,22 @@ export interface ProfilePlaylistsTabProps {
/**
* Renders the tab's list for the playlist category the URL's `?category=`
* picks, or every playlist without one. Each category's list is another,
* which the tab mounts afresh.
* picks, or every playlist without one, in the order its `?sort=` names.
* Each category's list in each order is another, which the tab mounts
* afresh.
*/
export function ProfilePlaylistsTab(
props: ProfilePlaylistsTabProps,
): React.JSX.Element {
const [params] = useSearchParams();
const categorySlug = params.get("category") || undefined;
const sort = parsePlaylistSort(params.get("sort"));
return (
<PlaylistList
key={categorySlug ?? ""}
key={`${categorySlug ?? ""}:${sort}`}
{...props}
categorySlug={categorySlug}
sort={sort}
/>
);
}
@@ -74,26 +84,28 @@ export function ProfilePlaylistsTab(
* Renders the playlists `user` made, from `GET /api/users/:userId/playlists`,
* the private ones to their owner only, a page at a time as the list scrolls;
* with `categorySlug`, only those in that playlist category, which the menu in
* the title row switches. The socket keeps them live, and going back restores
* the list and the scroll. The owner can make one, which goes first, and
* delete one.
* the title row switches, before the tabs that change the order. The socket
* keeps them live, and going back restores the list and the scroll. The owner
* can make one, which goes first, and delete one.
*/
function PlaylistList(
{ user, isOwnProfile, categorySlug }: ProfilePlaylistsTabProps & {
{ user, isOwnProfile, categorySlug, sort }: ProfilePlaylistsTabProps & {
categorySlug?: string;
sort: PlaylistSort;
},
): React.JSX.Element {
const [, setParams] = useSearchParams();
const { user: me, authFetch } = useAuth();
const { categories } = useCategories("playlist");
const { cached, saveState } = useFeedCache<Playlist>(
`feed:profile-playlists:${user.id}${
categorySlug ? `:${categorySlug}` : ""
}`,
`feed:profile-playlists:${user.id}:${categorySlug ?? ""}:${sort}`,
hydratePlaylist,
);
const url = `${userApiUrl(user, "playlists")}${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
)
}`;
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
Playlist
@@ -116,6 +128,7 @@ function PlaylistList(
usePlaylistListSync(loaded?.items ?? [], setItems, {
isOwner: isOwnProfile,
ownerId: user.id,
sort,
});
useOnReconnect(resync);
@@ -149,15 +162,25 @@ function PlaylistList(
title={<Trans>Playlists</Trans>}
actions={
<div className="profile-section-actions">
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
<PlaylistSortTabs
tabs={PLAYLIST_SORTS}
sort={sort}
// Another order of the same list, as a tab is: it takes the
// current history entry's place.
onChange={(s) =>
setParams((p) => playlistSortQuery(s, p), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) =>
setParams((p) => {
if (slug) p.set("category", slug);
else p.delete("category");
return p;
})}
/>
}
/>
{isOwnProfile && (
<NewPlaylistButton

View File

@@ -50,8 +50,11 @@ export interface PlaylistEvent {
* order.
*/
dumpIds?: string[];
/** Set when the dumps changed. It counts private dumps too. */
dumpCount?: number;
/**
* With `dumpIds`, when the latest of them was added; undefined when there
* is none.
*/
lastDumpAt?: Date;
}
/**
@@ -79,6 +82,16 @@ export interface DumpEvent {
dump: Dump;
}
/**
* Keeps the related dumps on a dump's page live. `WSProvider` emits it as a
* `related` event on each `dump_related_changed`, and the `Dump` page fetches
* its related dumps again when `dumpIds` names its dump.
*/
export interface RelatedEvent {
/** The dumps this user may see whose related dumps changed. */
dumpIds: string[];
}
/**
* Keeps a user's profile, and their name on what they posted, up to date.
* `WSProvider` emits it as a `user` event on each `user_updated`, which the
@@ -98,6 +111,7 @@ export interface UserEvent {
export interface WSEvents {
vote: VoteEvent;
dump: DumpEvent;
related: RelatedEvent;
playlist: PlaylistEvent;
comment: CommentEvent;
user: UserEvent;

View File

@@ -538,6 +538,10 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
break;
}
case "dump_related_changed":
events.emit("related", { dumpIds: msg.dumpIds });
break;
case "vote_ack": {
const { dumpId, action, voteCount } = msg;
// With more clicks in flight, the last ack tells how it ended.
@@ -595,12 +599,12 @@ export function WSProvider({ children }: WSProviderProps): React.JSX.Element {
}
case "playlist_dumps_updated": {
const { playlistId, dumpIds, dumpCount } = msg;
const { playlistId, dumpIds, lastDumpAt } = msg;
events.emit("playlist", {
type: "dumps_updated",
playlistId,
dumpIds,
dumpCount,
lastDumpAt: lastDumpAt ? new Date(lastDumpAt) : undefined,
});
break;
}

View File

@@ -1,90 +1,21 @@
/**
* Gives a dump's card, which is a clickable `<li>` and not a link, a link's
* behaviour: a middle or modified click opens the dump in a new tab.
* Makes a dump's card open the dump as a link would, and mark it visited.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
import type { Dump } from "../../../shared/dump.ts";
import { dumpUrl } from "../../utils/shared/urls.ts";
import { markDumpVisited } from "../../utils/shared/visited.ts";
/** Checks whether `e` happened over an anchor, which keeps its own target. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
import { useCardLink, type UseCardLinkResult } from "../shared/useCardLink.ts";
/**
* Returned by {@link useDumpCardLink} to `DumpCard` and `JournalCard`. Every
* way it opens the dump marks it visited, which clears its unread dot.
*/
export interface UseDumpCardLinkResult {
/** Opens the dump in this tab, for a handler that would rather not play. */
go: () => void;
/**
* Opens the dump in a new tab and returns true when `e` is a ctrl, cmd or
* shift click away from any anchor. A click handler that plays media calls
* it first, so that such a click opens the dump instead.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** Spread onto the card element. */
cardProps: {
onClick: (e: MouseEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* Returns the handlers that make the card of `dump` open it: in this tab on a
* click, in a new tab on a middle click or a ctrl, cmd or shift click. Anchors
* inside the card, such as the title or a category, keep their own target.
* Returns `useCardLink`'s handlers for the card of `dump`, for `DumpCard` and
* `JournalCard`: every way they open the dump marks it visited, which clears
* its unread dot.
*/
export function useDumpCardLink(
dump: Pick<Dump, "id" | "slug" | "ownerUsername">,
): UseDumpCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
markDumpVisited(dump.id);
globalThis.open(dumpUrl(dump), "_blank", "noopener");
}
function go() {
markDumpVisited(dump.id);
navigate(dumpUrl(dump));
}
return {
go,
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();
e.stopPropagation();
openInNewTab();
return true;
},
cardProps: {
onClick(e: MouseEvent) {
if (wantsNewTab(e)) openInNewTab();
else go();
},
// Windows and Linux browsers start autoscroll on a middle press, which
// eats the auxclick that follows.
onMouseDown(e: MouseEvent) {
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
},
onAuxClick(e: MouseEvent) {
if (e.button !== 1 || isOverLink(e)) return;
e.preventDefault();
openInNewTab();
},
},
};
): UseCardLinkResult {
return useCardLink(dumpUrl(dump), () => markDumpVisited(dump.id));
}

View File

@@ -7,10 +7,14 @@
*/
import { useEffect, useLayoutEffect, useRef } from "react";
import type { Playlist } from "../../../shared/playlist.ts";
import {
comparePlaylists,
type Playlist,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { useWS, useWSEvent } from "../ws/useWS.ts";
import { insertAt, usePositionMemory } from "../feed/usePositionMemory.ts";
import { withByDate } from "../../utils/feed/pagedList.ts";
import { withInOrder } from "../../utils/feed/pagedList.ts";
/** Passed to {@link usePlaylistListSync} by each page, to fit its list. */
export interface PlaylistListSyncOptions {
@@ -22,20 +26,27 @@ export interface PlaylistListSyncOptions {
*/
ownerId?: string;
/**
* Adds no new playlist, for a list of followed playlists, since the socket
* doesn't say what the user follows, or of search results. Playlists
* already listed still change, go, and come back where they were.
* The order the list is in, for one the API sorts: the playlists page's and
* a profile's. A list in an order of its own leaves it out, the followed
* playlists being by the date they were followed and a search's results
* the search's: it takes no new playlist, since the socket says neither
* what the user follows nor what a search matches, and moves none.
* Playlists already listed still change, go, and come back where they
* were.
*/
noNewEntries?: boolean;
sort?: PlaylistSort;
}
/**
* Applies the socket's playlist events to `items`, the loaded list, newest
* first, which `setPlaylists` updates. A created playlist goes where its date
* puts it, first when new, an updated one changes in place or leaves when it
* turns private (unless `isOwner`), one that left comes back where it was
* when it returns, a deleted one leaves, and a playlist's `dumpCount` follows
* its dumps. A renamed user's playlists take their new name.
* Applies the socket's playlist events to `items`, the loaded list, which
* `setPlaylists` updates. A created playlist goes where the list's order puts
* it, an updated one changes in place or leaves when it turns private (unless
* `isOwner`), one that left comes back where it was when it returns, a
* deleted one leaves, and a playlist's `dumpCount` and `lastDumpAt` follow
* its dumps. One that a change brings up the order moves up to its place, as
* a playlist does in the `updated` order when it takes a dump; one that falls
* stays where it is, its place being perhaps on a page not loaded. A renamed
* user's playlists take their new name.
*/
export function usePlaylistListSync(
items: Playlist[],
@@ -53,7 +64,14 @@ export function usePlaylistListSync(
});
useWSEvent("playlist", (ev) => {
const { isOwner, ownerId, noNewEntries } = options ?? {};
const { isOwner, ownerId, sort } = options ?? {};
const compare = sort && comparePlaylists(sort);
// Returns `list` with the playlist at `idx` before the ones it now comes
// before, if it rose.
const risen = (list: Playlist[], idx: number) =>
compare && idx > 0 && compare(list[idx], list[idx - 1]) < 0
? withInOrder(list.toSpliced(idx, 1), list[idx], compare)
: list;
const shown = ev.playlist &&
(ev.playlist.isPublic || isOwner) &&
(!ownerId || ev.playlist.userId === ownerId);
@@ -66,15 +84,13 @@ export function usePlaylistListSync(
const idx = prev.findIndex((p) => p.id === playlist.id);
if (idx !== -1) {
if (!shown) return prev.filter((p) => p.id !== playlist.id);
const next = [...prev];
next[idx] = playlist;
return next;
return risen(prev.with(idx, playlist), idx);
}
if (!shown) return prev;
if (oldIdx !== undefined) return insertAt(prev, playlist, oldIdx);
// An update never adds a playlist the list doesn't hold.
if (ev.type === "updated" || noNewEntries) return prev;
return withByDate(prev, playlist);
if (ev.type === "updated" || !compare) return prev;
return withInOrder(prev, playlist, compare);
}
if (ev.type === "deleted") {
@@ -84,12 +100,13 @@ export function usePlaylistListSync(
if (ev.type === "dumps_updated" && ev.dumpIds) {
const idx = prev.findIndex((p) => p.id === ev.playlistId);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = {
...next[idx],
dumpCount: ev.dumpCount ?? ev.dumpIds.length,
// The dumps this user may see, which are those the playlist counts.
const playlist = {
...prev[idx],
dumpCount: ev.dumpIds.length,
lastDumpAt: ev.lastDumpAt,
};
return next;
return risen(prev.with(idx, playlist), idx);
}
return prev;

View File

@@ -0,0 +1,91 @@
/**
* Gives a dump's or a playlist's card, which is a clickable `<li>` and not a
* link, a link's behaviour: a middle or modified click opens its page in a
* new tab.
*
* @module
*/
import type { MouseEvent } from "react";
import { useNavigate } from "react-router";
/** Checks whether `e` happened over an anchor, which keeps its own target. */
function isOverLink(e: MouseEvent) {
return (e.target as Element).closest("a") !== null;
}
function wantsNewTab(e: MouseEvent) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
/**
* Returned by {@link useCardLink} to `PlaylistCard`, and through
* `useDumpCardLink` to `DumpCard` and `JournalCard`.
*/
export interface UseCardLinkResult {
/** Opens the page in this tab, for a handler that would rather not play. */
go: () => void;
/**
* Opens the page in a new tab and returns true when `e` is a ctrl, cmd or
* shift click away from any anchor. A click handler that plays media calls
* it first, so that such a click opens the page instead.
*/
handledAsNewTab: (e: MouseEvent) => boolean;
/** Spread onto the card element. */
cardProps: {
onClick: (e: MouseEvent) => void;
onMouseDown: (e: MouseEvent) => void;
onAuxClick: (e: MouseEvent) => void;
};
}
/**
* Returns the handlers that make a card open the page at `url`: in this tab
* on a click, in a new tab on a middle click or a ctrl, cmd or shift click.
* Every way it opens the page calls `onOpen` first, which marks the item
* visited. Anchors inside the card, such as the title or a category, keep
* their own target.
*/
export function useCardLink(
url: string,
onOpen: () => void,
): UseCardLinkResult {
const navigate = useNavigate();
function openInNewTab() {
onOpen();
globalThis.open(url, "_blank", "noopener");
}
function go() {
onOpen();
navigate(url);
}
return {
go,
handledAsNewTab(e: MouseEvent) {
if (!wantsNewTab(e) || isOverLink(e)) return false;
e.preventDefault();
e.stopPropagation();
openInNewTab();
return true;
},
cardProps: {
onClick(e: MouseEvent) {
if (wantsNewTab(e)) openInNewTab();
else go();
},
// Windows and Linux browsers start autoscroll on a middle press, which
// eats the auxclick that follows.
onMouseDown(e: MouseEvent) {
if (e.button === 1 && !isOverLink(e)) e.preventDefault();
},
onAuxClick(e: MouseEvent) {
if (e.button !== 1 || isOverLink(e)) return;
e.preventDefault();
openInNewTab();
},
},
};
}

View File

@@ -67,8 +67,7 @@ type DumpState =
* `DumpEdit` or a title edit here just saved it. Reached by a former name or
* slug, the URL takes the current ones. Its comments, related dumps and
* playlists come from their own endpoints once it is loaded, and the socket's
* events keep them current; a reconnection fetches the dump and its comments
* again. A dump deleted, or made private by its poster, while someone else
* events keep them current; a reconnection fetches them and the dump again. A dump deleted, or made private by its poster, while someone else
* views it gives way to a notice. The poster and moderators can edit the
* title in place, and get a link to `DumpEdit`.
*/
@@ -79,7 +78,7 @@ export function Dump(): React.JSX.Element {
const navigate = useNavigate();
// Taken once, since it stays in the history entry, where a reload or Back
// would show that snapshot forever. It is cleared from there below.
const [handedOver] = useState(
const [handedOver, setHandedOver] = useState(
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
);
const preloaded = isAt(handedOver, username, slug) ? handedOver : null;
@@ -95,6 +94,19 @@ export function Dump(): React.JSX.Element {
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
);
const loadedDump = dumpState.status === "loaded" ? dumpState.dump : null;
// The path last rendered. One that names another dump, as a related dump's
// link does, drops the dump on screen in the same render: left for the
// fetch below to drop, useCanonicalPath would first put its path back in
// the URL. The dump handed over goes with it, so that coming back to its
// path fetches it.
const [target, setTarget] = useState({ username, slug });
if (target.username !== username || target.slug !== slug) {
setTarget({ username, slug });
if (!isAt(loadedDump, username, slug)) {
setHandedOver(null);
setDumpState({ status: "loading" });
}
}
useCanonicalPath(loadedDump && dumpUrl(loadedDump));
// The dump on screen, which a path it was just given, by useCanonicalPath or
// a slug edited elsewhere, needn't fetch again.
@@ -233,6 +245,13 @@ export function Dump(): React.JSX.Element {
});
useDumpListSync(relatedDumps, setRelatedDumps);
// Counts the events that say this dump gained or lost a related dump.
const [relatedVersion, setRelatedVersion] = useState(0);
useWSEvent("related", ({ dumpIds }) => {
if (loadedDumpId && dumpIds.includes(loadedDumpId)) {
setRelatedVersion((n) => n + 1);
}
});
// Counts the playlist events, each of which may change the playlists
// holding the dump.
@@ -272,7 +291,9 @@ export function Dump(): React.JSX.Element {
})
.catch(() => {});
return () => controller.abort();
}, [loadedDumpId, authFetch]);
// The list sync above edits and removes its dumps, but can't tell which
// one a new link adds: that, like a reconnection, fetches it again.
}, [loadedDumpId, authFetch, relatedVersion, connectionEpoch]);
// The server leaves out the private playlists of others.
useEffect(() => {

View File

@@ -1,17 +1,33 @@
/**
* Renders every public playlist, `/playlists`, and those of one playlist
* category, `/playlists/:categorySlug`, newest first.
* category, `/playlists/:categorySlug`, in the order `?sort=` names, or with
* `?mine=1` the signed-in user's own. The header's link and the avatar
* menu's lead here.
*
* @module
*/
import { useCallback } from "react";
import { useNavigate, useParams } from "react-router";
import {
Navigate,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../../shared/playlist.ts";
import { playlistsPath } from "../../shared/paths.ts";
import { type Playlist, PLAYLIST_SORTS } from "../../shared/playlist.ts";
import {
parsePlaylistsTab,
PLAYLISTS_TABS,
playlistSortQuery,
playlistsPath,
type PlaylistsTab,
} from "../../shared/paths.ts";
import { loginPath } from "../utils/auth/next.ts";
import { userApiUrl } from "../utils/shared/urls.ts";
import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts";
import {
deserializePlaylist,
@@ -34,18 +50,23 @@ import { PageShell } from "../components/layout/PageShell.tsx";
import { PageHeader } from "../components/layout/PageHeader.tsx";
import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { PlaylistSortTabs } from "../components/playlist/PlaylistSortTabs.tsx";
import { FeedFooter } from "../components/feed/FeedFooter.tsx";
import { ListState } from "../components/shared/ListState.tsx";
import { NotFound } from "./NotFound.tsx";
/**
* Renders the list for the route's category, or every public playlist
* without one. Each category's list is another, which the page mounts
* afresh. A slug that names no playlist category, once the categories have
* loaded, is a page not found.
* without one, on the tab the URL's query names. Each category's list on
* each tab is another, which the page mounts afresh. A slug that names no
* playlist category, once the categories have loaded, is a page not found.
*/
export function Playlists(): React.JSX.Element {
const { categorySlug } = useParams();
const [params] = useSearchParams();
const location = useLocation();
const { user } = useAuth();
const tab = parsePlaylistsTab(params);
const { categories, isLoaded } = useCategories("playlist");
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
@@ -53,33 +74,57 @@ export function Playlists(): React.JSX.Element {
useDocumentTitle(category ? category.name : t`Playlists`);
if (categorySlug && isLoaded && !category) return <NotFound />;
// A guest isn't offered the tab of their own playlists, so only a link
// leads one there, or signing out on it.
if (tab === "mine" && !user) {
return (
<Navigate to={loginPath(location.pathname + location.search)} replace />
);
}
return (
<PlaylistsList
key={categorySlug ?? ""}
key={`${categorySlug ?? ""}:${tab}`}
categorySlug={categorySlug}
categoryId={category?.id}
tab={tab}
/>
);
}
/**
* Renders the public playlists, from `GET /api/playlists`, a page at a time
* as the list scrolls; with `categorySlug`, only those in that playlist
* category, which the menu in the title row switches. The socket keeps them
* live, a reconnection fetches the pages loaded again, and going back
* Renders the public playlists, from `GET /api/playlists`, as a grid of
* covers, a page at a time as the list scrolls; with `categorySlug`, only
* those in that playlist category, which the menu before the tabs switches,
* keeping the tab, while a tab changes the order in place of the current
* history entry, as a feed's tabs do. A signed-in user's last tab lists
* their own instead, the private ones too, from
* `GET /api/users/:userId/playlists` in the default order. The socket keeps
* them live, a reconnection fetches the pages loaded again, and going back
* restores the list and the scroll.
*/
function PlaylistsList(
{ categorySlug, categoryId }: { categorySlug?: string; categoryId?: string },
{ categorySlug, categoryId, tab }: {
categorySlug?: string;
categoryId?: string;
tab: PlaylistsTab;
},
) {
const navigate = useNavigate();
const { user, authFetch } = useAuth();
// `Playlists` shows the tab to no guest.
const owner = tab === "mine" && user ? user : undefined;
const sort = tab === "mine" ? PLAYLIST_SORTS[0] : tab;
const { cached, saveState } = useFeedCache<Playlist>(
`feed:playlists:${categorySlug ?? "all"}`,
`feed:playlists:${categorySlug ?? "all"}:${tab}`,
hydratePlaylist,
);
const url = `${API_URL}/api/playlists${
categorySlug ? `?category=${encodeURIComponent(categorySlug)}` : ""
const url = `${
owner ? userApiUrl(owner, "playlists") : `${API_URL}/api/playlists`
}${
playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
)
}`;
const { state, setItems: setAllItems, loadMore, resync } = usePagedList<
Playlist
@@ -97,6 +142,7 @@ function PlaylistsList(
usePlaylistListSync(
loaded?.items ?? [],
useCategoryFilter(setAllItems, categoryId),
{ sort, isOwner: !!owner, ownerId: owner?.id },
);
useOnReconnect(resync);
@@ -110,12 +156,20 @@ function PlaylistsList(
useScrollRestore(cached?.scrollY, !!loaded);
return (
<PageShell narrow>
<PageShell>
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug))}
<PlaylistSortTabs
tabs={user ? PLAYLISTS_TABS : PLAYLIST_SORTS}
sort={tab}
onChange={(next) =>
navigate(playlistsPath(categorySlug, next), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug, tab))}
/>
}
/>
</PageHeader>
<ListState
@@ -129,7 +183,7 @@ function PlaylistsList(
>
{({ data }) => (
<>
<PlaylistCardList playlists={data.items} />
<PlaylistCardList playlists={data.items} grid privateBadgeOnly />
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}

View File

@@ -53,6 +53,7 @@ import {
userPath,
} from "../../shared/paths.ts";
import { NotFound } from "./NotFound.tsx";
import { markdownToPlainText } from "../../shared/markdown.ts";
/**
* Held by `Search` for a query, from the first page of `SearchResults`. The
@@ -165,7 +166,7 @@ export function Search(): React.JSX.Element {
setData((d) => ({ ...d, playlists: fn(d.playlists) })),
[setData],
),
{ isOwner: true, noNewEntries: true },
{ isOwner: true },
);
// What changed while the socket was down: every page of dumps loaded is
@@ -297,21 +298,27 @@ export function Search(): React.JSX.Element {
? <p className="search-page-empty">{t`No users match "${q}".`}</p>
: (
<ul className="user-results">
{results.users.map((u) => (
<li key={u.id}>
<Link
to={userPath(u.username)}
className="user-result-item"
>
<span className="user-result-name">@{u.username}</span>
{u.description && (
<span className="user-result-description">
{u.description}
</span>
)}
</Link>
</li>
))}
{results.users.map((u) => {
// The profile renders the description's Markdown; this
// line has room for its text only.
const description = u.description &&
markdownToPlainText(u.description, "");
return (
<li key={u.id}>
<Link
to={userPath(u.username)}
className="user-result-item"
>
<span className="user-result-name">@{u.username}</span>
{description && (
<span className="user-result-description">
{description}
</span>
)}
</Link>
</li>
);
})}
</ul>
)
)}

View File

@@ -44,8 +44,12 @@ import { NotFound } from "./NotFound.tsx";
const OVERVIEW = "overview";
type Tab = ProfileTab | typeof OVERVIEW;
const TABS: readonly Tab[] = [OVERVIEW, ...PROFILE_TABS];
/** The lists of dumps, which `?category=` narrows; the other tabs drop it. */
const DUMP_LISTS: readonly Tab[] = ["dumps", "upvoted"];
/**
* The lists of dumps, which one `?category=` narrows, so it passes from one to
* the other. Any other change of tab drops the query: a tab's is its own, as
* the playlists' category, of another kind, and their `?sort=`.
*/
const DUMP_LISTS: readonly (Tab | null)[] = ["dumps", "upvoted"];
/**
* Renders `/@:username` and its tabs, `/@:username/:tab`, and the follow
@@ -168,7 +172,12 @@ export function UserPublicProfile(): React.JSX.Element {
]}
activeTab={tab}
onChange={(key) =>
setTab(key, DUMP_LISTS.includes(key) ? undefined : "")}
setTab(
key,
DUMP_LISTS.includes(key) && DUMP_LISTS.includes(tab)
? undefined
: "",
)}
className="profile-tabs-scroller"
innerClassName="profile-tabs"
/>

View File

@@ -13,9 +13,13 @@ import { makeSession } from "../../fixtures.ts";
import { currentPath, renderWithContexts } from "../../render.tsx";
describe("AppHeader", () => {
test("guests get a login button", async () => {
test("guests get the playlists link and a login button", async () => {
const user = userEvent.setup();
renderWithContexts(<AppHeader />);
expect(screen.getByRole("link", { name: "Playlists" })).toHaveAttribute(
"href",
"/playlists",
);
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(currentPath()).toBe("/login");
});
@@ -26,7 +30,7 @@ describe("AppHeader", () => {
});
expect(screen.getByRole("link", { name: "Playlists" })).toHaveAttribute(
"href",
"/@ann/playlists",
"/playlists",
);
expect(screen.getByRole("button", { name: "User menu" }))
.toBeInTheDocument();

View File

@@ -22,10 +22,10 @@ describe("UserMenu", () => {
"href",
"/@ann",
);
expect(screen.getByRole("menuitem", { name: "Playlists" })).toHaveAttribute(
"href",
"/@ann/playlists",
);
// One playlists link, to the page of everyone's, whose last tab is the
// user's own: the header's text link is hidden where it has no room.
expect(screen.getByRole("menuitem", { name: "Playlists" }))
.toHaveAttribute("href", "/playlists");
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
"href",
"/community",

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import { fireEvent, screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { PlaylistCard } from "../../../components/playlist/PlaylistCard.tsx";
import { makePlaylist } from "../../fixtures.ts";
@@ -38,6 +38,78 @@ describe("PlaylistCard", () => {
expect(currentPath()).toBe("/@ann/playlists/mix");
});
test("a middle click anywhere on the card opens it in a new tab", () => {
const opened = vi.spyOn(globalThis, "open").mockReturnValue(null);
const p = makePlaylist({ slug: "mix", ownerUsername: "ann" });
const { container } = renderWithContexts(
<ul>
<PlaylistCard playlist={p} />
</ul>,
);
fireEvent(
container.querySelector(".playlist-card-preview")!,
new MouseEvent("auxclick", { bubbles: true, button: 1 }),
);
expect(opened).toHaveBeenCalledWith(
"/@ann/playlists/mix",
"_blank",
"noopener",
);
expect(currentPath()).toBe("/");
opened.mockRestore();
});
test("it dates its last dump, or its creation while it holds none", () => {
const day = 86_400_000;
const createdAt = new Date(Date.now() - 40 * day);
const filled = renderWithContexts(
<ul>
<PlaylistCard
playlist={makePlaylist({
createdAt,
lastDumpAt: new Date(Date.now() - 2 * day),
})}
/>
</ul>,
);
expect(screen.getByText("updated 2 days ago")).toBeInTheDocument();
filled.unmount();
renderWithContexts(
<ul>
<PlaylistCard playlist={makePlaylist({ createdAt })} />
</ul>,
);
expect(screen.getByText("created last month")).toBeInTheDocument();
});
test("the description shows as text, without its Markdown or its images", () => {
const withText = renderWithContexts(
<ul>
<PlaylistCard
playlist={makePlaylist({
description:
"**Late** [tunes](https://x.y) ![](/api/attachments/a1)",
})}
/>
</ul>,
);
expect(
withText.container.querySelector(".playlist-card-description"),
).toHaveTextContent(/^Late tunes$/);
withText.unmount();
const imageOnly = renderWithContexts(
<ul>
<PlaylistCard
playlist={makePlaylist({ description: "![](/api/attachments/a1)" })}
/>
</ul>,
);
expect(
imageOnly.container.querySelector(".playlist-card-description"),
).toBeNull();
});
test("the owner doesn't see their own name, or an unread dot", () => {
const p = makePlaylist({ ownerUsername: "me", createdAt: new Date() });
const { container } = renderWithContexts(

View File

@@ -55,6 +55,7 @@ function setup({ signedIn = true } = {}) {
events: {
vote: useEvents("vote"),
dump: useEvents("dump"),
related: useEvents("related"),
playlist: useEvents("playlist"),
comment: useEvents("comment"),
user: useEvents("user"),
@@ -458,6 +459,13 @@ describe("dumps, playlists, comments, users", () => {
expect(ws().recentDumps).toEqual([]);
});
test("changed links are passed on as a related event", () => {
const { socket, events } = setup();
socket.welcome();
socket.receive({ type: "dump_related_changed", dumpIds: ["a", "b"] });
expect(events().related).toEqual([{ dumpIds: ["a", "b"] }]);
});
test("injectDump adds my own new dump once", () => {
const { ws } = setup();
const d = makeDump({ id: "mine" });
@@ -481,13 +489,11 @@ describe("dumps, playlists, comments, users", () => {
type: "playlist_dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
expect(events().playlist.at(-1)).toEqual({
type: "dumps_updated",
playlistId: "p",
dumpIds: ["x"],
dumpCount: 2,
});
socket.receive({ type: "playlist_deleted", playlistId: "p", userId: "u" });
expect(ws().deletedPlaylistIds.has("p")).toBe(true);

View File

@@ -26,7 +26,9 @@ describe("usePlaylistListSync", () => {
}
test("prepends a created playlist once", () => {
const { send, items } = setup([makePlaylist({ id: "a" })]);
const { send, items } = setup([makePlaylist({ id: "a" })], {
sort: "new",
});
const p = makePlaylist({ id: "b" });
send("created", p);
send("created", { ...p });
@@ -36,28 +38,83 @@ describe("usePlaylistListSync", () => {
test("a playlist made public goes where its date puts it", () => {
const at = (id: string, day: number) =>
makePlaylist({ id, createdAt: new Date(Date.UTC(2026, 0, day)) });
const { send, items } = setup([at("c", 30), at("a", 10)]);
const { send, items } = setup([at("c", 30), at("a", 10)], {
sort: "new",
});
send("created", at("b", 20));
// Older than every one loaded: a later page holds it.
send("created", at("old", 1));
expect(ids(items())).toEqual(["c", "b", "a"]);
});
test("created: filtered by owner, privacy and noNewEntries", () => {
test("created: filtered by owner and privacy, and added to a sorted list only", () => {
const p = makePlaylist({ id: "p", userId: "them" });
const byOwner = setup([], { ownerId: "me" });
const byOwner = setup([], { ownerId: "me", sort: "new" });
byOwner.send("created", p);
expect(byOwner.items()).toEqual([]);
const priv = setup([]);
const priv = setup([], { sort: "new" });
priv.send("created", { ...p, isPublic: false });
expect(priv.items()).toEqual([]);
const followed = setup([], { noNewEntries: true });
// A list in an order of its own, as the followed playlists'.
const followed = setup([]);
followed.send("created", p);
expect(followed.items()).toEqual([]);
});
test("a created playlist goes where the list's order puts it", () => {
const at = (id: string, day: number, dumpCount: number, added?: number) =>
makePlaylist({
id,
dumpCount,
createdAt: new Date(Date.UTC(2026, 0, day)),
lastDumpAt: added ? new Date(Date.UTC(2026, 1, added)) : undefined,
});
const loaded = [at("a", 1, 3, 20), at("b", 2, 2, 10), at("c", 3, 0)];
// Made between b and c, it took a dump after b did and before a did.
const made = at("new", 2.5, 1, 15);
const updated = setup(loaded, { sort: "updated" });
updated.send("created", made);
expect(ids(updated.items())).toEqual(["a", "new", "b", "c"]);
const dumps = setup(loaded, { sort: "dumps" });
dumps.send("created", made);
expect(ids(dumps.items())).toEqual(["a", "b", "new", "c"]);
});
test("a playlist that rises in the order moves up, one that falls stays", () => {
const at = (id: string, day: number) =>
makePlaylist({
id,
dumpCount: 1,
lastDumpAt: new Date(Date.UTC(2026, 0, day)),
});
const loaded = [at("a", 30), at("b", 20), at("c", 10)];
const dumped = (playlistId: string, day?: number) => ({
type: "dumps_updated" as const,
playlistId,
dumpIds: day ? ["x", "y"] : [],
lastDumpAt: day ? new Date(Date.UTC(2026, 0, day)) : undefined,
});
const sorted = setup(loaded, { sort: "updated" });
sorted.emit("playlist", dumped("c", 25));
expect(ids(sorted.items())).toEqual(["a", "c", "b"]);
expect(sorted.items()[1].dumpCount).toBe(2);
sorted.send("updated", { ...at("b", 31), title: "Risen" });
expect(ids(sorted.items())).toEqual(["b", "a", "c"]);
// Emptied, it comes after every other, perhaps on a page not loaded.
sorted.emit("playlist", dumped("b"));
expect(ids(sorted.items())).toEqual(["b", "a", "c"]);
expect(sorted.items()[0].lastDumpAt).toBeUndefined();
const unsorted = setup(loaded);
unsorted.emit("playlist", dumped("c", 25));
expect(ids(unsorted.items())).toEqual(["a", "b", "c"]);
});
test("updated: in place, removed when private, never added by itself", () => {
const p = makePlaylist({ id: "p", title: "one" });
const other = makePlaylist({ id: "q" });
@@ -72,7 +129,7 @@ describe("usePlaylistListSync", () => {
test("one that left comes back where it was, even to a followed list", () => {
const initial = ["a", "b", "c"].map((id) => makePlaylist({ id }));
const { set, send, items } = setup(initial, { noNewEntries: true });
const { set, send, items } = setup(initial);
// Gone private for others, as a deletion.
set({ deletedPlaylistIds: new Set(["b"]) });
expect(ids(items())).toEqual(["a", "c"]);
@@ -99,7 +156,7 @@ describe("usePlaylistListSync", () => {
expect(ids(items())).toEqual(["c"]);
});
test("dumps_updated sets the count", () => {
test("dumps_updated counts the dumps this viewer may see", () => {
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 1 })]);
emit("playlist", {
type: "dumps_updated",
@@ -109,17 +166,6 @@ describe("usePlaylistListSync", () => {
expect(items()[0].dumpCount).toBe(3);
});
test("dumps_updated counts the members this viewer can't see too", () => {
const { emit, items } = setup([makePlaylist({ id: "a", dumpCount: 3 })]);
emit("playlist", {
type: "dumps_updated",
playlistId: "a",
dumpIds: ["x", "y"],
dumpCount: 4,
});
expect(items()[0].dumpCount).toBe(4);
});
test("a renamed owner's playlists take the new name", () => {
const { emit, items } = setup([
makePlaylist({ id: "a", userId: "u1", ownerUsername: "old" }),

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { act, screen, waitFor, within } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Route } from "react-router";
import { Link, Route } from "react-router";
import { Dump } from "../../pages/Dump.tsx";
import { DumpEdit } from "../../pages/DumpEdit.tsx";
import { RestrictedLoggedIn } from "../../pages/RestrictedLoggedIn.tsx";
@@ -225,6 +225,73 @@ describe("Dump page", () => {
expect(await screen.findByText("Best of")).toBeInTheDocument();
});
test("a related dump's link leads to that dump", async () => {
const first = dumpRoutes();
const second = makeDump({
id: "d2",
slug: "next-find",
title: "Next find",
});
route(
"GET /api/users/:u/dumps/:slug",
({ params }) => wire(params.slug === second.slug ? second : first),
);
route(
"GET /api/dumps/:id/related",
({ params }) => wire(params.id === first.id ? [second] : []),
);
const { user } = openDump();
await user.click(await screen.findByRole("link", { name: "Next find" }));
expect(await screen.findByRole("heading", { name: "Next find", level: 1 }))
.toBeInTheDocument();
expect(currentPath()).toBe("/@owner/dumps/next-find");
// Its parts are the new dump's.
await waitFor(() =>
expect(screen.queryByRole("heading", { name: "Related" })).not
.toBeInTheDocument()
);
});
test("a dump handed over is fetched when come back to", async () => {
const first = dumpRoutes();
const second = makeDump({
id: "d2",
slug: "next-find",
title: "Next find",
});
route(
"GET /api/users/:u/dumps/:slug",
({ params }) => wire(params.slug === second.slug ? second : first),
);
route(
"GET /api/dumps/:id/related",
({ params }) => wire([params.id === first.id ? second : first]),
);
const user = userEvent.setup();
renderApp(<Dump />, {
route: "/start",
path: "/:handle/dumps/:slug",
extraRoutes: (
<Route
path="/start"
element={
<Link to="/@owner/dumps/a-find" state={{ dump: first }}>open</Link>
}
/>
),
});
FakeWebSocket.last.welcome();
await user.click(screen.getByRole("link", { name: "open" }));
await user.click(await screen.findByRole("link", { name: "Next find" }));
await user.click(await screen.findByRole("link", { name: "A find" }));
expect(await screen.findByRole("heading", { name: "A find", level: 1 }))
.toBeInTheDocument();
expect(calls("GET /api/users/:u/dumps/:slug").map((c) => c.path)).toEqual([
"/api/users/@owner/dumps/next-find",
"/api/users/@owner/dumps/a-find",
]);
});
test("a live update of this dump is applied", async () => {
const d = dumpRoutes();
const { socket } = openDump();
@@ -237,6 +304,25 @@ describe("Dump page", () => {
.toBeInTheDocument();
});
test("a new link to or from this dump shows among its related dumps", async () => {
dumpRoutes();
const { socket } = openDump();
await screen.findByRole("heading", { name: "A find" });
expect(screen.queryByRole("heading", { name: "Related" })).toBeNull();
route(
"GET /api/dumps/:id/related",
() => [wire(makeDump({ id: "d2", slug: "linked", title: "Linked" }))],
);
// Links between other dumps leave this page alone.
socket.receive({ type: "dump_related_changed", dumpIds: ["d2", "d3"] });
socket.receive({ type: "dump_related_changed", dumpIds: ["d2", "d1"] });
expect(await screen.findByRole("link", { name: "Linked" }))
.toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Related" }))
.toBeInTheDocument();
expect(calls("GET /api/dumps/:id/related")).toHaveLength(2);
});
test("a dump deleted, or made private, while on screen gives way to a notice", async () => {
dumpRoutes();
const { socket } = openDump(other);

View File

@@ -423,7 +423,6 @@ describe("Followed feed", () => {
type: "playlist_dumps_updated",
playlistId: "mix",
dumpIds: ["x"],
dumpCount: 1,
});
expect(await screen.findByText("Added to the mix")).toBeInTheDocument();
});

View File

@@ -452,7 +452,6 @@ describe("PlaylistDetail", () => {
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["a", "c"],
dumpCount: 3,
});
expect(titles()).toEqual(["Alpha", "Charlie"]);
socket.receive({
@@ -463,7 +462,6 @@ describe("PlaylistDetail", () => {
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["a", "b", "c"],
dumpCount: 3,
});
expect(await screen.findByText("Bravo")).toBeInTheDocument();
expect(titles()).toEqual(["Alpha", "Bravo", "Charlie"]);
@@ -522,7 +520,6 @@ describe("PlaylistDetail", () => {
type: "playlist_dumps_updated",
playlistId: "p1",
dumpIds: ["d", "a", "b", "c"],
dumpCount: 4,
});
expect(await screen.findByText("Delta")).toBeInTheDocument();
socket.receive({

View File

@@ -1,9 +1,17 @@
import { describe, expect, test } from "vitest";
import { Route } from "react-router";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Playlists } from "../../pages/Playlists.tsx";
import { calls, route } from "../fetch.ts";
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
import {
makeCategory,
makePlaylist,
makeSession,
page,
wire,
} from "../fixtures.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
@@ -15,12 +23,14 @@ const mixes = makeCategory({
});
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
function open(url: string) {
function open(url: string, session?: Session) {
const user = userEvent.setup();
renderApp(<Playlists />, {
route: url,
path: "/playlists/:categorySlug?",
categories: [music, mixes],
session,
extraRoutes: <Route path="/login" element={null} />,
});
return { user, socket: FakeWebSocket.last };
}
@@ -85,6 +95,132 @@ describe("Playlists", () => {
expect(screen.queryByText("Filed")).toBeNull();
});
test("the tabs change the order, in the URL, which picking a category keeps", async () => {
route(
"GET /api/playlists",
(req) =>
page([
makePlaylist({
title: `By ${req.url.searchParams.get("sort") ?? "default"}`,
}),
]),
);
const { user } = open("/playlists");
expect(await screen.findByText("By default")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Most dumps" }));
expect(currentPath()).toBe("/playlists?sort=dumps");
expect(await screen.findByText("By dumps")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "All" }));
await user.click(screen.getByRole("option", { name: "Mixes" }));
expect(currentPath()).toBe("/playlists/mixes?sort=dumps");
const asked = calls("GET /api/playlists").at(-1)!.url.searchParams;
expect([asked.get("category"), asked.get("sort")]).toEqual([
"mixes",
"dumps",
]);
// The default order is the bare path, and so is one the URL misnames.
await user.click(screen.getByRole("button", { name: "Updated" }));
expect(currentPath()).toBe("/playlists/mixes");
});
test("a playlist that takes a dump goes first in the updated order", async () => {
const at = (id: string, day: number) =>
makePlaylist({
id,
title: `Playlist ${id}`,
dumpCount: 1,
lastDumpAt: new Date(Date.UTC(2026, 0, day)),
});
route("GET /api/playlists", () => page([at("a", 20), at("b", 10)]));
const { socket } = open("/playlists");
await screen.findByText("Playlist a");
socket.welcome();
socket.receive({
type: "playlist_dumps_updated",
playlistId: "b",
dumpIds: ["x", "y"],
lastDumpAt: "2026-01-25T00:00:00.000Z",
});
expect(
screen.getAllByRole("link", { name: /^Playlist \w$/ }).map((a) =>
a.textContent
),
).toEqual(["Playlist b", "Playlist a"]);
expect(screen.getByText("2 dumps")).toBeInTheDocument();
});
test("a signed-in user's last tab lists their own, private ones too", async () => {
const session = makeSession({ id: "ann-id", username: "ann" });
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Theirs" })]),
);
route(
"GET /api/users/:u/playlists",
() =>
page([
makePlaylist({ title: "Hidden", userId: "ann-id", isPublic: false }),
]),
);
const { user, socket } = open("/playlists/mixes?sort=dumps", session);
expect(await screen.findByText("Theirs")).toBeInTheDocument();
// Their own come in one order: the tab takes the order's place.
await user.click(screen.getByRole("button", { name: "Mine" }));
expect(currentPath()).toBe("/playlists/mixes?mine=1");
expect(await screen.findByText("Hidden")).toBeInTheDocument();
expect(screen.queryByText("Theirs")).toBeNull();
const asked = calls("GET /api/users/:u/playlists").at(-1)!.url;
expect(asked.pathname).toBe("/api/users/ann-id/playlists");
expect([asked.searchParams.get("category"), asked.searchParams.get("sort")])
.toEqual(["mixes", null]);
// The socket brings their new ones, private or not, and no one else's.
socket.welcome();
for (
const playlist of [
makePlaylist({
title: "Mine too",
userId: "ann-id",
isPublic: false,
categoryIds: ["pc1"],
}),
makePlaylist({
title: "Bob's",
userId: "bob-id",
categoryIds: ["pc1"],
}),
]
) {
socket.receive({ type: "playlist_created", playlist: wire(playlist) });
}
expect(screen.getByText("Mine too")).toBeInTheDocument();
expect(screen.queryByText("Bob's")).toBeNull();
// Picking a category keeps the tab, an order leaves it.
await user.click(screen.getByRole("button", { name: "Mixes" }));
await user.click(screen.getByRole("option", { name: "All" }));
expect(currentPath()).toBe("/playlists?mine=1");
await user.click(screen.getByRole("button", { name: "New" }));
expect(currentPath()).toBe("/playlists?sort=new");
});
test("a guest has no such tab", async () => {
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Theirs" })]),
);
open("/playlists");
expect(await screen.findByText("Theirs")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mine" })).toBeNull();
});
test("a guest sent to their own playlists signs in first", () => {
open("/playlists?mine=1");
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1");
});
test("a slug that names no playlist category is not found", async () => {
route("GET /api/playlists", () => page([]));
open("/playlists/music");

View File

@@ -292,6 +292,42 @@ describe("UserPublicProfile", () => {
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
});
test("the playlists tab's order is in its URL, beside the category, and no other tab's", async () => {
const mixes = makeCategory({
id: "pc1",
kind: "playlist",
slug: "mixes",
name: "Mixes",
});
const { user } = open({ url: "/@ann/playlists", categories: [mixes] });
route(
"GET /api/users/:u/playlists",
() => page([makePlaylist({ title: "Mix", userId: "ann" })]),
);
const asked = () =>
calls("GET /api/users/:u/playlists").at(-1)!.url.searchParams;
await screen.findByText("Mix");
expect(asked().get("sort")).toBeNull();
await user.click(screen.getByRole("button", { name: "New" }));
expect(currentPath()).toBe("/@ann/playlists?sort=new");
await waitFor(() => expect(asked().get("sort")).toBe("new"));
await user.click(screen.getByRole("button", { name: "All" }));
await user.click(screen.getByRole("option", { name: "Mixes" }));
expect(currentPath()).toBe("/@ann/playlists?sort=new&category=mixes");
await waitFor(() => expect(asked().get("category")).toBe("mixes"));
expect(asked().get("sort")).toBe("new");
// The default order is the bare tab's.
await user.click(screen.getByRole("button", { name: "Updated" }));
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
// Neither the order nor a playlist category means anything to the dumps.
await user.click(screen.getByRole("button", { name: "Most dumps" }));
expect(currentPath()).toBe("/@ann/playlists?category=mixes&sort=dumps");
await user.click(screen.getByRole("button", { name: "Dumps" }));
expect(currentPath()).toBe("/@ann/dumps");
});
test("the dumps tab loads more as its end scrolls into view", async () => {
open({ url: "/@ann/dumps" });
// After open's, so that it wins.

View File

@@ -478,7 +478,6 @@
[data-style="nyt"] .notifications-empty-icon,
[data-style="nyt"] .page-title-icon,
[data-style="nyt"] .dump-card-preview-icon,
[data-style="nyt"] .playlist-card-icon,
[data-style="nyt"] .rich-content-compact-icon,
[data-style="nyt"] .journal-card-glyph,
[data-style="nyt"] .fdz__file-icon,

View File

@@ -105,22 +105,39 @@ export function withFirst<T extends { id: string }>(items: T[], item: T): T[] {
}
/**
* Returns `items`, newest first as the API pages them, with `item` where its
* `createdAt` puts it: before the first item no newer than it. An item older
* than every one loaded is left to a later page, and `items` returned as they
* are, as they are when `item` is already there. The list sync hooks put a
* new or newly visible dump or playlist in a list with it.
* Returns `items`, in the order of `compare` as the API pages them, with
* `item` where that order puts it: before the first item that doesn't come
* before it. An item that comes after every one loaded is left to a later
* page, and `items` returned as they are, as they are when `item` is already
* there. `usePlaylistListSync` puts a new, newly visible or risen playlist in
* a list with it, whatever its order.
*/
export function withInOrder<T extends { id: string }>(
items: T[],
item: T,
compare: (a: T, b: T) => number,
): T[] {
if (items.some((i) => i.id === item.id)) return items;
if (items.length === 0) return [item];
const idx = items.findIndex((i) => compare(i, item) >= 0);
if (idx === -1) return items;
return [...items.slice(0, idx), item, ...items.slice(idx)];
}
/**
* Returns `items`, newest first, with `item` where its `createdAt` puts it,
* as {@link withInOrder} does. `useDumpListSync` puts a new or newly visible
* dump in a list with it.
*/
export function withByDate<T extends { id: string; createdAt: Date }>(
items: T[],
item: T,
): T[] {
if (items.some((i) => i.id === item.id)) return items;
if (items.length === 0) return [item];
const time = item.createdAt.getTime();
const idx = items.findIndex((i) => i.createdAt.getTime() <= time);
if (idx === -1) return items;
return [...items.slice(0, idx), item, ...items.slice(idx)];
return withInOrder(
items,
item,
(a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
);
}
/**

View File

@@ -95,7 +95,10 @@ export function deserializeChatMessage(raw: Wire<ChatMessage>): ChatMessage {
/** Returns `raw` as a {@link Playlist}. */
export function deserializePlaylist(raw: Wire<Playlist>): Playlist {
return dates(raw);
return {
...dates(raw),
lastDumpAt: raw.lastDumpAt ? new Date(raw.lastDumpAt) : undefined,
};
}
/**
@@ -110,7 +113,10 @@ export function hydratePlaylist(raw: unknown): Playlist {
export function deserializePlaylistWithDumps(
raw: Wire<PlaylistWithDumps>,
): PlaylistWithDumps {
return { ...dates(raw), dumps: raw.dumps.map(deserializeDump) };
return {
...deserializePlaylist(raw),
dumps: raw.dumps.map(deserializeDump),
};
}
/** Returns `raw` as a {@link PlaylistMembership}. */